The SDK in any Node app
Using @dukkan.one/app-sdk without Nuxt: the client, the token store, the OAuth hops and the webhook receiver on Node, Workers, Bun or Deno.
On this page
This page is for developers whose app is not a Nuxt project: an Express or Hono service, a Cloudflare Worker, a queue consumer. By the end you have the four pieces the Nuxt module otherwise wires for you, each in a few lines, on any runtime that has fetch and WebCrypto.
Install#
npm install @dukkan.one/app-sdkThe package has two dependencies (zod and openapi-fetch), ships ESM and CommonJS with types, and runs on Node.js 18.17 or newer, Cloudflare Workers, Bun and Deno. The core never imports a Node built-in; the Postgres adapter takes your own postgres client.
Persist tokens#
The SDK never stores tokens itself. You implement TokenStore once, or use an adapter:
import postgres from "postgres";
import { createAesGcmSealer } from "@dukkan.one/app-sdk";
import { createPostgresTokenStore } from "@dukkan.one/app-sdk/tokens/postgres";
const sql = postgres(process.env.DATABASE_URL!);
export const tokenStore = createPostgresTokenStore({
sql,
table: "installs",
sealer: createAesGcmSealer({ currentKey: process.env.APP_ENCRYPTION_KEY! }),
});import { createAesGcmSealer, createKeyValueTokenStore } from "@dukkan.one/app-sdk";
export const tokenStore = createKeyValueTokenStore({
backend: {
get: (key) => env.TOKENS.get(key),
put: (key, value) => env.TOKENS.put(key, value),
delete: (key) => env.TOKENS.delete(key),
// Optional but strongly recommended: an atomic lock across instances.
putIfAbsent: async (key, value, { ttlSeconds }) => acquire(key, value, ttlSeconds),
},
sealer: createAesGcmSealer({ currentKey: env.APP_ENCRYPTION_KEY }),
});The reference table for the Postgres adapter:
create table installs (
install_id uuid primary key,
store_id uuid not null,
access_token_enc text not null,
access_expires_at timestamptz not null,
refresh_token_enc text not null,
refresh_expires_at timestamptz,
scopes text[] not null default '{}',
reconnect_required text,
updated_at timestamptz not null default now()
);withRefreshLock is the part that matters: the platform revokes an install when a rotated refresh token is presented again, so two instances must never refresh the same install at the same time. The Postgres adapter locks the row; the key-value adapter needs putIfAbsent for the same guarantee.
The two OAuth hops#
import { buildAuthorizeUrl, generatePkce, generateState } from "@dukkan.one/app-sdk/oauth";
export async function install(request: Request): Promise<Response> {
const pkce = await generatePkce();
const state = generateState();
await stash.put(state, { verifier: pkce.verifier }, { ttlSeconds: 600 }); // server-side, never in the URL
return Response.redirect(buildAuthorizeUrl({
clientId: env.DUKKAN_CLIENT_ID,
redirectUri: "https://app.example.com/dukkan/callback",
scopes: ["orders:read", "orders:write"],
state,
codeChallenge: pkce.challenge,
installToken: new URL(request.url).searchParams.get("install_token"),
}), 302);
}import { exchangeCode, parseCallbackQuery } from "@dukkan.one/app-sdk/oauth";
import { tokensFromResponse } from "@dukkan.one/app-sdk";
export async function callback(request: Request): Promise<Response> {
const query = parseCallbackQuery(new URL(request.url).searchParams);
const stashed = await stash.take(query.state);
if (!query.ok || !stashed) return new Response("state mismatch", { status: 400 });
const response = await exchangeCode({
credentials: { clientId: env.DUKKAN_CLIENT_ID, clientSecret: () => env.DUKKAN_CLIENT_SECRET },
code: query.code,
redirectUri: "https://app.example.com/dukkan/callback",
codeVerifier: stashed.verifier,
});
await tokenStore.save(tokensFromResponse(response.install_id!, null, response));
return mintSession(response.install_id!, response.store_id!);
}The token response carries install_id, store_id and store_slug; see Authorization. Key everything on the ids.
Call the API#
import { createDukkanClient, DukkanReconnectRequiredError } from "@dukkan.one/app-sdk";
const client = createDukkanClient({
installId,
tokenStore,
credentials: { clientId: env.DUKKAN_CLIENT_ID, clientSecret: () => env.DUKKAN_CLIENT_SECRET },
});
try {
for await (const order of client.orders.iterate({ status: "placed" })) {
await enqueue(order.id);
}
await client.orders.updateStatus(orderId, "approved", { idempotency: { ref: [orderId, jobId] } });
} catch (error) {
if (error instanceof DukkanReconnectRequiredError) await markReconnect(installId);
else throw error;
}Reads and idempotent writes retry on 429 and 5xx with backoff and honour Retry-After. Every write carries an Idempotency-Key: random by default and reused across the SDK's own retries, or derived from your own reference with idempotency: { ref } so a re-run job sends the same key. Every failure is a typed error with the platform's code, status, requestId and details; the list is in the SDK reference.
Receive webhooks#
import { createWebhookReceiver } from "@dukkan.one/app-sdk/webhooks";
const receiver = createWebhookReceiver({
secretsFor: async (installId) => {
const row = await loadInstall(installId);
return row ? { secrets: [row.webhookSecret], storeId: row.storeId } : null;
},
inbox: { insertIfNew: (record) => insertIgnoringDuplicates(record) },
handlers: {
"order.paid": async (event) => { await recordReceipt(event.data.order_id, event.data.amount_minor); },
"app.uninstalled": async (event) => { await purge(event.installId); },
},
});
export async function webhooks(request: Request, context: { waitUntil(p: Promise<unknown>): void }): Promise<Response> {
const rawBody = await request.arrayBuffer();
const { response, process } = await receiver.receive({ rawBody, headers: request.headers });
context.waitUntil(process());
return Response.json(response.body, { status: response.status });
}The receiver verifies the HMAC on the raw bytes in constant time, refuses stale timestamps and bodies over 256 KB, binds the delivery to the install named inside the signature, deduplicates on the signed event id, and only then dispatches. Answer with what it returns; the statuses match Delivery and retries.
Warning
Pass the request body exactly as received. Re-serialising the JSON before verifying changes the bytes and every signature fails.
Keep in sync without webhooks#
Webhooks are at-least-once and can be delayed; a nightly pass over updated_at closes any gap:
import { syncOrders } from "@dukkan.one/app-sdk/commerce";
const result = await syncOrders(client, {
since: await loadHighWaterMark(installId),
onPage: async (orders) => { for (const order of orders) await upsert(order); },
});
await saveHighWaterMark(installId, result.highWaterMark);Create an order#
orders.create (scope orders:create) sends the prices you agreed and reads the store's FX table so a quote in USD costs exactly what the same cart would cost at checkout. Re-validate the variants first, then derive the idempotency key from your quote so a retried job replays instead of creating a second order:
import { DukkanConflictError, DukkanValidationError } from "@dukkan.one/app-sdk";
import { buildCreateOrderLines, exchangeRateFor } from "@dukkan.one/app-sdk/commerce";
const store = await client.store.get();
const variants = await client.products.variants(quote.lines.map((line) => line.variantId).filter(Boolean));
const rate = exchangeRateFor(store, quote.currency); // null in the pricing currency, undefined when the store has no rate
if (rate === undefined) throw new Error(`store has no rate for ${quote.currency}`);
try {
const { data: order, replayed } = await client.orders.create({
currency: quote.currency,
items: buildCreateOrderLines(quote.lines, { from: store.pricing_currency.decimals, to: quote.decimals, rate }),
discount: { amount_minor: quote.discountMinor, title: quote.discountTitle },
shipping: { amount_minor: quote.shippingMinor, method: "delivery" },
customer: { name: quote.customerName, phone: quote.customerPhone, email: null },
shipping_address: { address: quote.address, province: quote.province, city: null, notes: null },
payment_method: "cod",
exchange_rate: rate,
note: quote.note,
tags: ["quote"],
attributes: { reference: { kind: "quote", number: quote.number, url: `https://offers.example.com/quotes/${quote.number}` } },
}, { idempotency: { ref: ["quote-accepted", quote.id] } });
await markConverted(quote.id, order.id, replayed);
} catch (error) {
if (error instanceof DukkanConflictError && error.code === "insufficient_stock") {
await askToRequote(quote.id, error.insufficientLines); // [{ variant_id, requested, available }]
} else if (error instanceof DukkanValidationError) {
log.warn("quote rejected", { fields: error.fieldPaths, requestId: error.requestId });
} else throw error;
}
await client.orders.update(order.id, { tags: { add: ["invoiced"] }, attributes: { invoice_id: invoice.id } }, {
idempotency: { ref: ["invoiced", invoice.id] },
});products.search({ q }) finds catalog items by name, variant name, SKU, DSIN or barcode; products.variants(ids) accepts any number of ids and fetches them in chunks of 100. orders.update (scope orders:write) edits the note, the tags as set operations and your app's own attributes. The whole contract is in Orders created by apps.
Next steps#
- Migrate from hand-written HTTP
- Money in minor units and the commerce helpers in the reference
- The contract for other languages