Authorization
OAuth 2.1 with PKCE, tokens, the refresh grant, revocation, and the error table.
On this page
This page is for anyone implementing the app install flow. By the end you know every parameter of the consent URL, the shape and lifetime of each token, how to refresh and handle revocation, and what every error means.
The model#
- Your app is a confidential client: it presents
client_idandclient_secretat token exchange and uses PKCE (S256) at the same time. Both, not either. - Every install binds one app to one store and issues its own tokens. No token ever covers more than one store.
- The effective scopes of any request are: the scopes granted at consent ∩ the granting member's live permissions. If the member loses a permission, your app loses it on the very next request.
The consent URL#
GET https://dukkan.one/apps/oauth/authorize| Parameter | Required | Value |
|---|---|---|
response_type |
yes | code |
client_id |
yes | your app's client id |
redirect_uri |
yes | a URI registered on the app, byte for byte |
scope |
yes | space-separated scopes within what your app's active version requests; anything else is dropped, and an empty result is an error |
state |
recommended | a random value up to 512 characters, returned unchanged |
code_challenge |
yes | BASE64URL(SHA256(code_verifier)) |
code_challenge_method |
no | S256 only (the default) |
install_token |
no | a private-distribution token, for installing an app that is not published in the marketplace. See App lifecycle |
The server answers with a direct HTTP error — no redirect — whenever it cannot trust the redirect URI: 400 for an unknown client or an unregistered redirect_uri, 403 for an app that is not available for installation. Every later error is returned to redirect_uri as an error parameter (see Errors).
The consent screen#
The merchant sees every scope as a plain Arabic sentence (for example "Read orders, line items and fulfillment status"). Financial scopes carry a warning color, orders:create carries the caption "These orders appear in your admin marked with the app name. Stock is reduced like any other order.", and clients:read and clients:write sit in their own section, unchecked by default, because they expose customer data. A merchant may grant a subset, so read scope from the token response and never assume what you asked for.
Exchange the code for tokens#
POST https://dukkan.one/apps/oauth/tokenSend client credentials in an Authorization: Basic header (or in the body as client_id and client_secret) and the body as application/x-www-form-urlencoded:
import { exchangeCode } from "@dukkan.one/app-sdk/oauth";
const tokens = await exchangeCode({
credentials: { clientId: CLIENT_ID, clientSecret: CLIENT_SECRET },
code: AUTH_CODE,
redirectUri: "https://app.example.com/callback",
codeVerifier: PKCE_VERIFIER,
});curl -X POST https://dukkan.one/apps/oauth/token \
-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code=AUTH_CODE \
-d redirect_uri=https://app.example.com/callback \
-d code_verifier=PKCE_VERIFIER{
"access_token": "dk_app_at_REDACTED_EXAMPLE_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dk_app_rt_REDACTED_EXAMPLE_TOKEN",
"scope": "orders:read orders:write",
"install_id": "8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
"store_id": "3065a1f2-0c4d-4e8b-b7a9-5d2f8c1e9a44",
"store_slug": "sham-perfumes"
}| Field | Meaning |
|---|---|
access_token |
dk_app_at_…, a bearer token valid for one hour |
expires_in |
always 3600 seconds |
refresh_token |
dk_app_rt_…, valid 30 days, rotated on every use |
scope |
the scopes the merchant actually granted, space-separated |
install_id |
the install this token belongs to. Key your storage on it; it is what every webhook envelope carries |
store_id |
the store the install is bound to; the same value GET /installation and every envelope report |
store_slug |
the store's public slug, for display only. It can change; never bind data to it |
A code is single-use. Any attempt to reuse a consumed code revokes every token of that install (theft protection, per RFC 6749).
GET /installation returns the same identity later, together with the effective scopes, the store's currency exponent, locale and timezone, and the current webhook subscription; see the Installation reference.
The refresh grant#
curl -X POST https://dukkan.one/apps/oauth/token \
-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token=YOUR_REFRESH_TOKEN{
"access_token": "dk_app_at_REDACTED_NEW_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dk_app_rt_REDACTED_NEW_TOKEN",
"scope": "orders:read orders:write",
"install_id": "8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
"store_id": "3065a1f2-0c4d-4e8b-b7a9-5d2f8c1e9a44",
"store_slug": "sham-perfumes"
}- Every refresh returns a new pair; store both atomically and discard the old ones. The refresh response also carries
scope,install_id,store_idandstore_slug. - Reusing a refresh token that was already rotated is treated as theft: the install's entire token chain is revoked and the request returns
invalid_grant. Send the merchant back through the consent URL. - One exception keeps multi-instance apps safe: presenting a token that was rotated less than 30 seconds earlier, while its successor is still valid, is treated as a race between two of your instances. The request returns
invalid_grantand nothing is revoked; re-read your token store and use the pair the other instance saved. Serialize refreshes per install anyway. - Do not refresh long before the access token expires; refresh on
401or a few minutes before expiry.
Token prefixes#
| Prefix | Token | Lifetime |
|---|---|---|
dk_app_at_ |
app access token | one hour |
dk_app_rt_ |
app refresh token | 30 days, rotating |
dk_whsec_ |
webhook signing secret | until you rotate it via PUT /webhooks |
dk_cli_at_ / dk_cli_rt_ |
theme CLI tokens | one hour / 30 days |
Every token is stored server-side as a SHA-256 hash only and can never be read back.
Revocation#
- When a merchant uninstalls, the install's tokens are revoked immediately and you receive
app.uninstalled— no scope required — even after revocation. Handle it by deleting that store's data. - When your app is suspended (by you or by Dukkan) tokens are revoked and subscriptions disabled; see App lifecycle.
- Calling the Platform API with a revoked or expired token returns
401with theerrorbody and codeunauthorized.
Errors#
A consent-URL error comes back to redirect_uri as the error parameter (with state); a token-endpoint error is a JSON body { "error": "…" }.
| Error | Where | Cause |
|---|---|---|
unsupported_response_type |
authorize | response_type is not code |
invalid_request |
authorize | invalid code_challenge or a method other than S256 |
invalid_scope |
authorize | no valid scope remains after filtering against the active version's scopes |
invalid_client (401) |
token | wrong client_id or client_secret, or the app is suspended |
invalid_grant (400) |
token | expired, consumed or mismatched code (redirect_uri, code_verifier, client), or an expired/revoked/reused refresh token |
unsupported_grant_type (400) |
token | grant_type is neither authorization_code nor refresh_token |
Limits: 60 requests per 10 minutes per IP address on both endpoints, and 600 requests per 10 minutes per client on the token endpoint. Exceeding them returns 429.
Security checklist#
- Generate a random
stateper session and verify it on return. - Keep
code_verifierin a server-side session, never in the browser. - Store the client secret and refresh tokens encrypted on the server only; never send any token to a client.
- The secret rotation schedule with an overlap window (24 hours by default) is described in App lifecycle.
- Treat
invalid_granton refresh as a signal to re-authorize, not to retry.