Migrate from hand-written HTTP
Moving an app that already talks to the Platform API with fetch onto the SDK, one layer at a time, without breaking live installs.
On this page
This page is for teams whose app already integrates with Dukkan through its own fetch calls, token refresh and signature check. By the end you have a staged plan where every step is its own deploy, existing installs keep working throughout, and nothing you stored has to be rewritten.
What maps to what#
| You wrote | The SDK gives you | Notes |
|---|---|---|
| The consent redirect with PKCE and a state cookie | generatePkce, generateState, buildAuthorizeUrl (or the Nuxt module's install route) |
Same URL, same parameters. |
| The code exchange | exchangeCode |
The response now carries install_id, store_id, store_slug. |
| Refresh with a compare-and-swap on the old token | TokenManager through a TokenStore with withRefreshLock |
Lock before the refresh instead of racing and reconciling after: the platform revokes an install when a rotated token is presented twice. |
A bearer fetch wrapper with a retry on 401 |
createDukkanClient |
Adds 429 and 5xx retries with Retry-After, typed errors, pagination, and Idempotency-Key on every write. |
| HMAC verification on the raw body | verifyWebhookSignature or the whole receiver |
Same scheme, same headers, constant time, on WebCrypto. |
| Dedupe on the delivery id | InboxStore.insertIfNew on the signed event id |
The delivery header is outside the signature; the event id is inside it. |
| Store binding from the first signed envelope | install_id and store_id from the token response, GET /installation for rows that predate it |
No more waiting for a webhook to learn who an install is. |
| One webhook endpoint per install | One endpoint; the envelope names the install | The Nuxt module also serves the per-install suffix, so registered URLs keep working. |
Stage by stage#
Each stage is one deploy and can be verified on its own. The first-party notifications app went through exactly these.
1. Verify with the SDK#
Replace the body of your verification function with verifyWebhookSignature and keep your tests. The scheme is the platform's, pinned by the shared test vectors, so a passing suite proves the swap.
import { verifyWebhookSignature } from "@dukkan.one/app-sdk/webhooks";
export async function verify(rawBody: Uint8Array, headers: Headers, secret: string) {
const verdict = await verifyWebhookSignature({
rawBody,
timestamp: headers.get("x-dukkan-timestamp"),
signature: headers.get("x-dukkan-hmac-sha256"),
secret,
});
return verdict.ok;
}2. Call through the client#
Add the SDK columns to your install table (access_expires_at, refresh_expires_at, reconnect_required; see the reference table), point createPostgresTokenStore at your existing column names, and route calls through the client. Existing ciphertext needs no rewrite when you keep your sealer: createAesGcmSealer is wire-compatible with the common iv.tag.ciphertext base64 envelope, and any other format fits behind the two-method SecretSealer interface.
Keep historical idempotency keys where they matter:
await client.orders.updateStatus(orderId, "approved", {
idempotency: { key: `confirm-${confirmationId}` },
});A retry across the migration then replays the platform's recorded answer instead of writing twice; recorded responses live 24 hours (Idempotency-Key).
3. Learn every install's identity#
Rows created before the token response carried ids have no install_id. Backfill them once at boot: for each row, call GET /installation with the stored access token (refreshing once if refused) and write install.id and store.id back. From then on the receiver looks installs up by the id inside the signed envelope.
{
"data": {
"install": {
"id": "8b2c1d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
"app_id": "5f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
"status": "active",
"granted_scopes": [
"orders:read",
"orders:write"
],
"effective_scopes": [
"orders:read",
"orders:write"
],
"distribution_channel": "public",
"installed_at": "2026-09-09T08:15:30.000Z",
"api_version": "2026-09-09",
"webhook": {
"id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
"endpoint_url": "https://app.example.com/dukkan/webhooks",
"topics": [
"order.created",
"order.paid",
"app.uninstalled"
],
"status": "active",
"consecutive_failures": 0,
"activated_at": "2026-09-09T08:15:31.000Z",
"disabled_at": null
}
},
"store": {
"id": "3065a1f2-0c4d-4e8b-b7a9-5d2f8c1e9a44",
"slug": "sham-perfumes",
"name": "عطور الشام",
"currency": "SYP",
"decimals": 0,
"locale": "ar",
"timezone": "Asia/Damascus",
"country": "SY",
"is_development": true
}
}
}A row whose refresh answers invalid_grant is dead: mark it reconnect-required and let the merchant install again.
4. Mount the SDK routes#
Swap your install, callback and webhook handlers for the receiver (or the Nuxt module). Keep your paths: in dukkan.app.toml set install_path, callback_path and webhook_path to what merchants and the platform already know, so registered redirect URIs and endpoint URLs stay valid.
Two behaviours change at this stage and are worth a release note:
- The OAuth state cookie becomes the SDK's signed
__Host-cookie; a merchant mid-consent at deploy time restarts the install. - Refusals answer the receiver's small JSON bodies (
{ "error": "bad_signature" }and friends). The platform only reads the status, so retries behave as before.
5. Delete the workarounds#
Once every live row has an install_id, remove the code that bound stores from envelopes, tried sibling secrets or reconciled refresh races. Watch a health counter reach zero before you do.
Checklist#
- Tokens are never logged; every SDK error message passes through
redactSecretsand your own logs should too. - Only one process refreshes an install at a time (
withRefreshLock). - Every write sends an
Idempotency-Key; historical keys are preserved where a retry could cross the migration. - Deliveries are deduplicated on the signed
id, not on the delivery header. app.uninstalleddeletes what Data processing requires.