Build for Dunea

Webhooks and signatures

Receive signed events and tool calls, verify them, and handle retries safely.

Dunea sends two kinds of request to your webhookUrl, both as signed JSON POSTs:

  • Events ("type": "event") for everything in your manifest's subscribes
  • Tool calls ("type": "tool_call") when the Dunea AI uses one of your AI tools

Headers

HeaderValue
dunea-signaturet=<unix seconds>,v1=<hex signature>
dunea-eventThe event name, for example shop.order.paid
dunea-delivery-idThe ID of this delivery
dunea-install-idThe install the request is for

The SDK exports these names as SIGNATURE_HEADER, EVENT_HEADER, DELIVERY_HEADER and INSTALL_HEADER.

The signature

dunea-signature: t=1767225600,v1=5f2b…

v1 is the hex HMAC-SHA256 of `${t}.${rawBody}`, keyed with your listing's client secret:

  • t is the Unix time in seconds when Dunea signed the request.
  • rawBody is the request body exactly as received. If you parse the JSON and serialise it again, the bytes change and the signature will not match.
  • Reject a request whose t is more than 5 minutes (300 seconds, SIGNATURE_TOLERANCE_SECONDS) from your clock. This stops someone replaying an old request.
  • The header can carry more than one v1= value while Dunea rotates keys. Accept the request if any of them matches.

Your client secret is shown once, when you create the listing. Keep it in a secret store, never in your repository or in browser code. You can rotate it from the developer portal (or POST /api/marketplace/dev/listings/:slug/rotate-secret), which returns a new secret, also shown once.

Verify with the SDK

createWebhookHandler verifies the signature, checks the timestamp, parses the body and routes it to your handlers.

src/webhook.ts
import { createWebhookHandler, toFetchHandler } from '@dunea/plugin-sdk';

// Your own storage.
declare function saveInstall(installId: string, accessToken: string, settings: Record<string, unknown>): Promise<void>;
declare function alreadyProcessed(eventId: string): Promise<boolean>;

const handler = createWebhookHandler({
  clientSecret: process.env.DUNEA_CLIENT_SECRET!,
  onEvent: {
    'plugin.installed': async (envelope) => {
      // envelope.data is typed: accessToken, grantedPermissions, settings, app
      await saveInstall(envelope.installId, envelope.data.accessToken, envelope.data.settings);
    },
    'shop.order.paid': async (envelope) => {
      if (await alreadyProcessed(envelope.id)) return;
      // ...do the work, then remember envelope.id
    },
  },
  onError: (error, body) => console.error('Dunea webhook failed', body.id, error),
});

// Cloudflare Workers, Next.js route handlers, Hono, Bun, Deno:
export default { fetch: toFetchHandler(handler) };

On Express, mount the route with a raw body parser, so the handler sees the original bytes:

src/server.ts
import express from 'express';
import { createWebhookHandler, toExpressMiddleware } from '@dunea/plugin-sdk';

const app = express();
const handler = createWebhookHandler({ clientSecret: process.env.DUNEA_CLIENT_SECRET!, onEvent: { /* ... */ } });

app.post('/dunea', express.raw({ type: '*/*' }), toExpressMiddleware(handler));
app.listen(3000);

What the handler returns:

SituationStatusBody
Missing, malformed, wrong or expired signature401{ "error": "invalid_signature", "reason": "missing" | "malformed" | "mismatch" | "expired" }
Signed, but not a Dunea event or tool call400{ "error": "invalid_payload", "message": "…" }
Event with no handler200{ "received": true, "ignored": true }
Event handler finished200{ "received": true }
Event handler threw500{ "error": "handler_failed", "message": "…" }, so Dunea retries
Tool call200A tool response. Unknown tools and thrown errors become { "output": "", "error": "…" }

If you only need the check, use verifyWebhook(rawBody, headers, clientSecret). It returns the parsed body, or throws DuneaSignatureError (with a reason) or DuneaPayloadError:

import { verifyWebhook, DuneaSignatureError } from '@dunea/plugin-sdk';

declare const rawBody: Buffer; // the request body, unparsed
declare const headers: Record<string, string | string[] | undefined>;

try {
  const body = verifyWebhook(rawBody, headers, process.env.DUNEA_CLIENT_SECRET!);

  if (body.type === 'event') {
    // body is a WebhookEnvelope
  } else {
    // body is a ToolCallRequest
  }
} catch (error) {
  if (error instanceof DuneaSignatureError) {
    // respond 401
  }
  throw error;
}

Rotating your secret without downtime

clientSecret can be an array. While you roll out a new secret, pass both, and a request signed with either is accepted:

import { createWebhookHandler } from '@dunea/plugin-sdk';

createWebhookHandler({
  clientSecret: [process.env.DUNEA_CLIENT_SECRET!, process.env.DUNEA_CLIENT_SECRET_OLD!].filter(Boolean),
  // ...
});

Remove the old one once your new secret is in place.

Verify without the SDK

The same check with node:crypto, for any Node.js 18+ server:

verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function isValidDuneaSignature(rawBody: Buffer, header: string | undefined, secret: string): boolean {
  if (!header) return false;

  let timestamp: string | undefined;
  const signatures: string[] = [];

  for (const part of header.split(',')) {
    const eq = part.indexOf('=');
    if (eq === -1) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === 't') timestamp = value;
    if (key === 'v1' && value) signatures.push(value.toLowerCase());
  }

  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`, 'utf8')
    .update(rawBody)
    .digest();

  const matched = signatures.some((sig) => {
    if (!/^[0-9a-f]+$/.test(sig) || sig.length !== expected.length * 2) return false;
    return timingSafeEqual(expected, Buffer.from(sig, 'hex'));
  });

  if (!matched) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  return age <= TOLERANCE_SECONDS;
}

Always compare with timingSafeEqual, not ===, so the comparison time does not leak the expected signature.

The envelope

Every event has the same outer shape (WebhookEnvelope in the SDK):

interface WebhookEnvelope<E extends PublicEvent = PublicEvent, D = Record<string, unknown>> {
  /** Use it to dedupe: deliveries are at-least-once. */
  id: string;
  type: 'event';
  event: E;
  createdAt: string;   // ISO 8601
  installId: string;
  appId: string;
  /** 'test' for previews and shops that aren't live yet. */
  mode: 'test' | 'live';
  data: D;
}

plugin.installed

Sent when an owner installs your plugin. It is the only time you receive the install's access token.

interface PluginInstalledData {
  /** Bearer token for the Plugin API, delivered ONCE. Store it encrypted. */
  accessToken: string;
  grantedPermissions: Permission[];
  settings: Record<string, string | number | boolean>;
  app: { appId: string; name: string; siteUrl: string | null };
}

Store accessToken encrypted, keyed by installId. Dunea keeps only a hash of it and cannot send it again. If your handler fails, return a non-2xx status so the delivery is retried.

plugin.uninstalled

Sent when your plugin stops running on an app. data.reason says why:

reasonWhenWhat to do
"owner"The owner uninstalled the plugin.Delete the token and any data you no longer need.
"suspended"Dunea suspended your listing (for example after a policy breach).Stop work for the install.
"payment"The owner's monthly subscription lapsed: it was cancelled and its paid period ended, or renewals kept failing past the grace period. Dunea suspends the install and emails the owner a link to renew.Pause work for the install, but keep its token and data: the owner can still renew.
"refund"The owner's one-time purchase was fully refunded. Dunea suspends the install and emails the owner.Stop work for the install. The owner can buy it again, which creates a new install.

In every case the install's token stops working when the event is sent. After a "payment" suspension, if the owner pays the outstanding invoice, the install becomes active again, you receive plugin.reactivated, and the same token works again.

plugin.reactivated

Sent when a suspended install is active again because the owner paid (data.reason is "payment"). Resume work for the install. Its existing token works again; if you deleted it, get a fresh one with the token exchange. Delivered whether or not you list it in subscribes, like the other plugin.* events.

For the other events, data is typed as Record<string, unknown> in the SDK. Treat it as a pointer, not the full record: shop.order.paid, for example, carries the order's orderId. When you need the full, current record, fetch it from the Plugin API with the install's token. That also guarantees you only see the fields the install's permissions allow.

Delivery and retries

Deliveries are at-least-once. The same event can reach you more than once, so:

  1. Dedupe on the envelope id. Record each id you have processed, and skip any you have seen.
  2. Respond fast. Return a 2xx as soon as the event is safely stored, and do slow work (emails, third-party API calls) afterwards, for example from a queue.
  3. Fail loudly. A non-2xx response, a timeout or a network error marks the attempt as failed, and Dunea tries again.

Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours, up to 8 attempts in total. After the last attempt the delivery is marked dead and not retried.

Delivery log and test deliveries

The developer portal shows recent deliveries for each listing, with their status (pending, delivered, failed or dead), the number of attempts, and the last status code and error. The same data is available from GET /api/marketplace/dev/listings/:slug/deliveries?status=.

To check your endpoint end to end, send a signed test delivery with dunea-plugin test-webhook, or POST /api/marketplace/dev/listings/:slug/test-webhook. It returns the delivery, so you can see the status code your endpoint returned.

In your own unit tests, sign a body with signPayload(secret, rawBody) and pass it to your handler:

import { createWebhookHandler, signPayload } from '@dunea/plugin-sdk';

const secret = 'test_secret';
const handler = createWebhookHandler({ clientSecret: secret, onEvent: { 'shop.order.paid': async () => {} } });

const rawBody = JSON.stringify({
  id: 'evt_1', type: 'event', event: 'shop.order.paid', createdAt: new Date().toISOString(),
  installId: 'inst_1', appId: 'app_1', mode: 'test', data: {},
});

const result = await handler({ rawBody, headers: { 'dunea-signature': signPayload(secret, rawBody) } });
// result.status === 200

On this page