Token Exchange
curl --request POST \
--url https://api.launchmystore.io/apps/oauth/token \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"grant_type": "<string>",
"client_id": "<string>",
"client_secret": "<string>",
"code": "<string>",
"state": "<string>",
"code_verifier": "<string>",
"refresh_token": "<string>"
}
'import requests
url = "https://api.launchmystore.io/apps/oauth/token"
payload = {
"grant_type": "<string>",
"client_id": "<string>",
"client_secret": "<string>",
"code": "<string>",
"state": "<string>",
"code_verifier": "<string>",
"refresh_token": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
grant_type: '<string>',
client_id: '<string>',
client_secret: '<string>',
code: '<string>',
state: '<string>',
code_verifier: '<string>',
refresh_token: '<string>'
})
};
fetch('https://api.launchmystore.io/apps/oauth/token', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.launchmystore.io/apps/oauth/token",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'grant_type' => '<string>',
'client_id' => '<string>',
'client_secret' => '<string>',
'code' => '<string>',
'state' => '<string>',
'code_verifier' => '<string>',
'refresh_token' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.launchmystore.io/apps/oauth/token"
payload := strings.NewReader("{\n \"grant_type\": \"<string>\",\n \"client_id\": \"<string>\",\n \"client_secret\": \"<string>\",\n \"code\": \"<string>\",\n \"state\": \"<string>\",\n \"code_verifier\": \"<string>\",\n \"refresh_token\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.launchmystore.io/apps/oauth/token")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"grant_type\": \"<string>\",\n \"client_id\": \"<string>\",\n \"client_secret\": \"<string>\",\n \"code\": \"<string>\",\n \"state\": \"<string>\",\n \"code_verifier\": \"<string>\",\n \"refresh_token\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.launchmystore.io/apps/oauth/token")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"grant_type\": \"<string>\",\n \"client_id\": \"<string>\",\n \"client_secret\": \"<string>\",\n \"code\": \"<string>\",\n \"state\": \"<string>\",\n \"code_verifier\": \"<string>\",\n \"refresh_token\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"status": 123,
"state": "<string>",
"data": {
"access_token": "<string>",
"refresh_token": "<string>",
"token_type": "<string>",
"expires_in": 123,
"scope": "<string>"
}
}OAuth
Token Exchange
Exchange an authorization code or refresh token for an access token
POST
/
apps
/
oauth
/
token
Token Exchange
curl --request POST \
--url https://api.launchmystore.io/apps/oauth/token \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"grant_type": "<string>",
"client_id": "<string>",
"client_secret": "<string>",
"code": "<string>",
"state": "<string>",
"code_verifier": "<string>",
"refresh_token": "<string>"
}
'import requests
url = "https://api.launchmystore.io/apps/oauth/token"
payload = {
"grant_type": "<string>",
"client_id": "<string>",
"client_secret": "<string>",
"code": "<string>",
"state": "<string>",
"code_verifier": "<string>",
"refresh_token": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
grant_type: '<string>',
client_id: '<string>',
client_secret: '<string>',
code: '<string>',
state: '<string>',
code_verifier: '<string>',
refresh_token: '<string>'
})
};
fetch('https://api.launchmystore.io/apps/oauth/token', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.launchmystore.io/apps/oauth/token",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'grant_type' => '<string>',
'client_id' => '<string>',
'client_secret' => '<string>',
'code' => '<string>',
'state' => '<string>',
'code_verifier' => '<string>',
'refresh_token' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.launchmystore.io/apps/oauth/token"
payload := strings.NewReader("{\n \"grant_type\": \"<string>\",\n \"client_id\": \"<string>\",\n \"client_secret\": \"<string>\",\n \"code\": \"<string>\",\n \"state\": \"<string>\",\n \"code_verifier\": \"<string>\",\n \"refresh_token\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.launchmystore.io/apps/oauth/token")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"grant_type\": \"<string>\",\n \"client_id\": \"<string>\",\n \"client_secret\": \"<string>\",\n \"code\": \"<string>\",\n \"state\": \"<string>\",\n \"code_verifier\": \"<string>\",\n \"refresh_token\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.launchmystore.io/apps/oauth/token")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"grant_type\": \"<string>\",\n \"client_id\": \"<string>\",\n \"client_secret\": \"<string>\",\n \"code\": \"<string>\",\n \"state\": \"<string>\",\n \"code_verifier\": \"<string>\",\n \"refresh_token\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"status": 123,
"state": "<string>",
"data": {
"access_token": "<string>",
"refresh_token": "<string>",
"token_type": "<string>",
"expires_in": 123,
"scope": "<string>"
}
}Token Exchange
Exchanges either a one-time authorizationcode (from
GET /apps/oauth/authorize) or a long-lived refresh_token for a fresh
access/refresh token pair. Two grant types are supported:
authorization_code— first-time install flow.refresh_token— silent token rotation after the 24h access token expires.
No other grant types are supported. In particular, RFC 8693 token
exchange (
urn:ietf:params:oauth:grant-type:token-exchange) is not
available — an App Bridge session token cannot be exchanged for an Admin
API access token. Session tokens authenticate the embedded user to your
own backend; the Admin API access token comes from the
install handoff code exchanged
here with grant_type=authorization_code.Request
curl -X POST "https://api.launchmystore.io/apps/oauth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "lms_app_xxx",
"client_secret": "lms_secret_yyy",
"code": "9fbb1c3e8d4a7b2e...",
"state": "5d6f7c8b9e0d1c2a...",
"code_verifier": "OPTIONAL_PKCE_VERIFIER"
}'
curl -X POST "https://api.launchmystore.io/apps/oauth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"refresh_token": "lms_refresh_abc123..."
}'
Body Parameters
Grant type: authorization_code
string
required
Must be
authorization_code.string
required
Your app’s public client identifier.
string
required
Your app’s client secret, sent over TLS. Compared (constant-time) against
the value returned by
/apps/credentials. Because the secret doubles as the
HS256 signing key for App Bridge session tokens, it is stored in plaintext
(not hashed) — the host must sign JWTs with the same value your app verifies
with. Treat it as a signing key: never expose it client-side, and rotate it
from the developer dashboard if it leaks.string
required
The one-time authorization code from
GET /apps/oauth/authorize. Single-use, expires in 10 minutes.string
required
The server-generated state token from the authorize call. Must match
what was bound to the code server-side or the request fails with
Invalid state parameter.string
Required when the authorize call sent a
code_challenge. Length 43-128
chars. For S256 flows, the server SHA-256 hashes this and compares
(constant-time) against the stored challenge.Grant type: refresh_token
string
required
Must be
refresh_token.string
required
The current refresh token. Must not be expired, blacklisted, or
already rotated.
Response
integer
200 on success.
string
success or error.object
Show Token Object (RFC 6749 §5.1)
Show Token Object (RFC 6749 §5.1)
string
Opaque bearer token prefixed
lms_token_ followed by 64 hex chars.
Send as Authorization: Bearer <token> on /api/v1/* calls.string
Opaque refresh token prefixed
lms_refresh_. Use with
grant_type=refresh_token to get a new access token.string
Always
bearer.integer
Access token lifetime in seconds. Always
86400 (24 hours).string
Space-separated list of granted scopes (normalised — duplicates
removed and legacy aliases collapsed).
Example Response
{
"status": 200,
"state": "success",
"data": {
"access_token": "lms_token_9fbb1c3e8d4a7b2e0a5d6f7c8b9e0d1c2a3b4c5d6e7f8091a2b3c4d5e6f70819",
"refresh_token": "lms_refresh_5d6f7c8b9e0d1c2a3b4c5d6e7f80910a2b3c4d5e6f70819b8c4d5e6f7081923a",
"token_type": "bearer",
"expires_in": 86400,
"scope": "read_products write_metafields read_orders"
}
}
Token Lifetimes
| Token | TTL | Notes |
|---|---|---|
| Access token | 24 hours (expires_in: 86400) | Used as Authorization: Bearer on /api/v1/*. |
| Refresh token | 30 days | Resets on every successful rotation. |
| Authorization code | 10 minutes | Single-use; deleted after exchange. |
401 Invalid refresh token —
the replayed value no longer matches any installation, so the lookup fails
before the blacklist is even consulted. Either way the status is 401;
treat any 401 from the token endpoint as “re-run the install handoff”.
Install/Re-install Limits
Whengrant_type=authorization_code would create a brand new active
installation (or re-activate a previously disabled one), the server
checks per-shop function caps before issuing tokens. If the app ships
functions and the merchant already has too many active installs of apps
with the same function types, the endpoint returns:
{
"status": 409,
"state": "error",
"message": "Cannot install: this store already has 1 active cart_transform function, and the per-shop limit is 1. Uninstall another cart_transform app before installing this one."
}
Error Codes
| HTTP | Error message | When |
|---|---|---|
400 | Unsupported grant_type | grant_type is not authorization_code or refresh_token. |
400 | Invalid or expired authorization code | code unknown (consumed or > 10 minutes old). |
400 | Invalid state parameter | state doesn’t match the value bound to the code. |
400 | State validation failed | state unknown or client_id doesn’t match. |
400 | code_verifier is required for this authorization code | Authorize call sent PKCE challenge but token call omitted verifier. |
400 | code_verifier must be 43-128 characters | PKCE verifier length out of range. |
400 | code_verifier does not match the code_challenge | PKCE verification failed (constant-time compare). |
401 | Invalid client credentials | client_id/client_secret doesn’t match. |
401 | Invalid refresh token | No installation has this refresh token. |
401 | Refresh token has expired. Please re-authenticate. | > 30 days since last rotation. |
401 | Invalid refresh token | Refresh token was rotated (replaced by a newer one) or revoked. |
409 | Cannot install: this store already has <n> active <type> function(s), and the per-shop limit is <N>. ... | Function caps prevent a new active install. |
429 | (throttler) | More than 10 requests/minute from this IP. |
Refresh Loop Pattern
// Server-side helper — refresh before every API call when expiry is close.
async function withFreshToken(installation) {
const msLeft = new Date(installation.tokenExpiresAt) - Date.now();
if (msLeft > 60_000) return installation.accessToken;
const res = await fetch('https://api.launchmystore.io/apps/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: installation.refreshToken,
}),
});
const { data } = await res.json();
// Persist the new pair — both old tokens are now blacklisted.
await db.installation.update({
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenExpiresAt: new Date(Date.now() + data.expires_in * 1000),
});
return data.access_token;
}
Security Notes
- Always send this request server-side. Never expose
client_secretin browser code. client_secretis always required forgrant_type=authorization_code— PKCE is verified in addition to the secret, not instead of it, so even PKCE flows must exchange the code server-side.- Successful exchange consumes both the code and its state — both are deleted atomically.
client_secretverification is brute-force resistant: requests are throttled by the IP rate limit (10/min) and compared in constant time. The secret is stored in plaintext (it is also the App Bridge session-token signing key), so keep it server-side only and rotate it if exposed.