Skip to content

The Nuxt module

What @dukkan.one/nuxt mounts, the one contract it asks you to implement, its environment variables, and the client it gives every server route.

Updated 10 Sept 20264 min read
On this page

This page is for developers building a Dukkan app on Nuxt 4. By the end you know exactly what @dukkan.one/nuxt does on your behalf, what server/dukkan.ts must provide, and how to reach the Platform API from any server route.

Install#

Shell
npm install @dukkan.one/nuxt @dukkan.one/app-sdk
nuxt.config.tsTypeScript
export default defineNuxtConfig({
  modules: ["@dukkan.one/nuxt"],
});

The module reads dukkan.app.toml at build time; see The dukkan.app.toml file. dukkan app init writes both files for you.

What it mounts#

Route What happens
GET install path Bounces alias hosts to appBaseUrl, then starts the authorization-code flow with PKCE. state and the verifier live in a signed, HttpOnly, __Host- cookie for 10 minutes; the URL carries neither. ?store= and ?install_token= from the marketplace pass through.
GET callback path Verifies the signed state, exchanges the code, keys the install on install_id and store_id, registers the webhook subscription for the file's topics (minting a secret only when you hold none), calls onInstall, mints the session through your mintSession, and redirects to the same-origin return_to the install request carried.
POST webhook path Verifies the HMAC on the raw bytes, binds the delivery to the install named inside the signature, deduplicates through your inbox, answers small JSON, and dispatches handlers after the acknowledgement. app.uninstalled marks the install's tokens as needing reconnection before your handler runs.
POST webhook path /:ref The same receiver on a per-install suffix, for apps that registered one endpoint per install before the SDK.
/_dukkan/dev/* Only in nuxt dev, loopback only: what dukkan app dev talks to (tunnel announcements, rehearsal triggers, a status document for the setup checklist).

The three paths come from [urls] in the file. Every answer of the webhook route is the small JSON the platform expects; the statuses are in Webhooks.

Your side of the contract#

server/dukkan.ts default-exports defineDukkanApp. The module asks for exactly what it cannot know: where tokens, secrets and sessions live.

server/dukkan.tsTypeScript
import { createPostgresTokenStore } from "@dukkan.one/app-sdk/tokens/postgres";
import { createAesGcmSealer } from "@dukkan.one/app-sdk";
import { sql } from "./db";
import { inbox, secrets, sessions } from "./stores";

const sealer = createAesGcmSealer({ currentKey: process.env.APP_ENCRYPTION_KEY! });

export default defineDukkanApp({
  tokenStore: createPostgresTokenStore({ sql, sealer }),
  webhookSecrets: secrets,
  inbox,
  mintSession: (event, install) => sessions.mint(event, install.id),
  onInstall: async ({ install, client, isReinstall }) => {
    const { store } = await client.installation.get();
    await sql`update installs set store_name = ${store.name} where install_id = ${install.id}`;
  },
  onUninstall: async (event) => {
    await sql`delete from customers where install_id = ${event.installId}`;
  },
  handlers: {
    "order.paid": async (event) => {
      await sql`insert into receipts (order_id, amount_minor) values (${event.data.order_id}, ${event.data.amount_minor})`;
    },
  },
});
Field Required Meaning
tokenStore yes A TokenStore: load, save, withRefreshLock, markReconnectRequired. Postgres and key-value adapters ship with the SDK; the refresh lock is what keeps two instances from presenting the same refresh token.
webhookSecrets yes load(installId) returns the signing secrets (current first) and the store the install is bound to; save(installId, secret, storeId) is called from the callback with a freshly minted secret.
inbox recommended An InboxStore whose insertIfNew is atomic on the signed event id. Without it every delivery dispatches, duplicates included.
mintSession yes The only place a merchant session may be created. It receives the install identity straight from the token response.
onInstall no Runs after tokens and the subscription are saved, with a ready client. Seed defaults here; it also runs on re-consent (isReinstall).
onUninstall no app.uninstalled arrived and the tokens are dead. Delete what Data processing requires.
handlers no One async function per topic; event.data is typed for the topic. Errors are caught and reported to onHandlerError so the delivery is still acknowledged.
dispatch no after-ack (default) answers first and processes in waitUntil; inline awaits handlers before answering.
listInstallIds no The installs the app knows, so dukkan app dev can re-point their endpoint when the tunnel changes.
rateLimiter no A shared limiter (hit(bucket, max, windowSeconds)) for the module's routes and yours; defaults to an in-memory sliding window. See Rate limiting your own routes.

Environment#

Secrets come from the environment, never from the file:

Variable Purpose
NUXT_DUKKAN_CLIENT_ID The OAuth client id. Defaults to app.client_id from the file.
NUXT_DUKKAN_CLIENT_SECRET The client secret from the portal.
NUXT_DUKKAN_SESSION_SECRET 32 or more random characters; signs the OAuth state cookie.
NUXT_DUKKAN_APP_BASE_URL The public origin of this app. Defaults to urls.app_url from the file.
NUXT_DUKKAN_WEBHOOK_BASE_URL The origin the platform posts to when it differs from the app origin. dukkan app dev announces the tunnel here for you.
NUXT_DUKKAN_API_URL The platform origin; https://dukkan.one unless you run against a sandbox host.
NUXT_DUKKAN_ALLOWED_STORE_IDS Comma-separated store ids the callback accepts. dukkan app dev sets it to your sandbox while a public tunnel is up.

The app refuses to boot in production while the client id, the client secret, the session secret or the app origin is missing; in nuxt dev the problems are listed in the setup checklist instead.

Calling the API#

server/api/orders/recent.get.tsTypeScript
export default defineEventHandler(async (event) => {
  const session = await requireSession(event);
  const client = await useDukkanClient(event, session.installId);
  const page = await client.orders.list({ status: "placed", limit: 20 });
  return page.data.map((order) => ({ id: order.id, number: order.order_number, total: order.total }));
});

useDukkanClient(event, installId) returns a client wired to the same token store and credentials the module uses, so a rejected access token is refreshed once through your store's lock. Every method is listed in the SDK reference.

Note

Resolve the install from your session, never from a request parameter. Possession of an install id grants nothing on the platform, but a client for the wrong install would still read that store's data with your app's token.

Rate limiting your own routes#

The module throttles its install, callback and webhook routes through the limiter you pass as rateLimiter in defineDukkanApp (an in-memory sliding window when you pass none). Since 0.3.0 the same two helpers are exported for your routes:

server/api/quotes/index.post.tsTypeScript
import { clientIp, enforceLimit, MemoryRateLimiter } from "@dukkan.one/nuxt/runtime";

const limiter = new MemoryRateLimiter(); // or the shared store you gave defineDukkanApp

export default defineEventHandler(async (event) => {
  await enforceLimit(event, limiter, `quotes:create:${clientIp(event)}`, 30, 60);
  // ...
});

clientIp(event) prefers the edge's verified address (cf-connecting-ip), then the last x-forwarded-for hop, then the socket. enforceLimit(event, limiter, bucket, max, windowSeconds) returns when the hit is allowed; otherwise it sets Retry-After and throws a 429 whose body carries { code: "rate_limited" }, the same shape the platform uses.

Next steps#