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'ssubscribes - Tool calls (
"type": "tool_call") when the Dunea AI uses one of your AI tools
Headers
| Header | Value |
|---|---|
dunea-signature | t=<unix seconds>,v1=<hex signature> |
dunea-event | The event name, for example shop.order.paid |
dunea-delivery-id | The ID of this delivery |
dunea-install-id | The 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:
tis the Unix time in seconds when Dunea signed the request.rawBodyis 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
tis 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.
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:
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:
| Situation | Status | Body |
|---|---|---|
| Missing, malformed, wrong or expired signature | 401 | { "error": "invalid_signature", "reason": "missing" | "malformed" | "mismatch" | "expired" } |
| Signed, but not a Dunea event or tool call | 400 | { "error": "invalid_payload", "message": "…" } |
| Event with no handler | 200 | { "received": true, "ignored": true } |
| Event handler finished | 200 | { "received": true } |
| Event handler threw | 500 | { "error": "handler_failed", "message": "…" }, so Dunea retries |
| Tool call | 200 | A 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:
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:
reason | When | What 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:
- Dedupe on the envelope
id. Record eachidyou have processed, and skip any you have seen. - 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.
- 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