Skip to content

@dukkan.one/app-sdk/webhooks

Signature verification on raw bytes, the envelope schema, typed events, and the receiver pipeline with store binding and dedupe.

Package
@dukkan.one/app-sdk@0.3.0
API revision
2026-09-10
Import
import { … } from "@dukkan.one/app-sdk/webhooks"
On this page

Functions

constructEvent#

function constructEvent(input: { rawBody: Uint8Array | ArrayBuffer | string; headers: Headers | Record<string, string | string[] | undefined>; secret: string | readonly string[]; expectedStoreId?: string | null; toleranceSeconds?: number; nowMs?: number; }): Promise<DukkanEvent>

One-shot verification for apps that route deliveries themselves: verify, parse and type in a single call, throwing DukkanWebhookError on refusal.

createWebhookReceiver#

function createWebhookReceiver(options: WebhookReceiverOptions): { receive: (input: { rawBody: Uint8Array | ArrayBuffer | string; headers: Headers | Record<string, string | string[] | undefined>; }) => Promise<ReceiveResult>; headersOf: (source: Headers | Record<string, string | string[] | undefined>) => WebhookHeaders; }

Framework-neutral receiver (docs/apps/webhooks): size cap, signature on raw bytes, envelope parse, store binding, dedupe on the signed id, then typed dispatch. The answer is always small JSON; the platform treats large or HTML error bodies as failures.

eventFromEnvelope#

function eventFromEnvelope(envelope: WebhookEnvelope, headers: { deliveryId: string | null; apiVersion: string | null; }): DukkanEvent

Turns a verified envelope plus the unsigned delivery headers into a typed DukkanEvent.

isKnownTopic#

function isKnownTopic(topic: string): topic is WebhookTopic

True when the topic is one this SDK build knows; unknown topics still reach onUnknownTopic.

parseEnvelope#

function parseEnvelope(rawBody: string | Uint8Array): WebhookEnvelope | null

Parses and validates the signed body; returns null for anything that is not a v1 envelope.

readWebhookHeaders#

function readWebhookHeaders(source: Headers | Record<string, string | string[] | undefined>): WebhookHeaders

Reads the delivery headers from any headers-like source, case-insensitively.

verifyWebhookSignature#

function verifyWebhookSignature(input: VerifyWebhookInput): Promise<VerifyWebhookResult>

The platform's exact scheme (packages/contracts/vectors/webhook-signing.json): hex(HMAC-SHA256(secret, ${timestamp}.${rawBody})), verified on the raw bytes before any JSON parsing, in constant time, inside a freshness window.

Classes

MemoryInboxStore#

class MemoryInboxStore implements InboxStore

new MemoryInboxStore(options?

In-memory inbox for tests and single-process tools; bounded so it never grows forever.

Members
NameTypeDescription
insertIfNewRequired(record: InboxRecord): Promise<boolean>
markFailedRequired(eventId: string, error: string): Promise<void>

Called when a handler failed AFTER the event was recorded, so the app can keep the row visible for a replay instead of losing the event behind the dedupe. Optional; the receiver never throws past the acknowledgement.

failuresRequired(): Map<string, string>

Events whose handler failed, with the error message, for replays and assertions.

sizeRequirednumber
recordsRequired(): InboxRecord[]

Interfaces

DukkanEvent#

interface DukkanEvent<Topic extends WebhookTopic = WebhookTopic>

A verified, parsed delivery. data is typed by topic once narrowed.

Members
NameTypeDescription
idRequiredstring
topicRequiredTopic
storeIdRequiredstring
installIdRequiredstring
sequenceRequirednumber
occurredAtRequiredstring
testRequiredboolean

True on sandbox rehearsals (POST /webhooks/test); never in production.

dataRequiredEventDataByTopic[Topic]
deliveryIdRequiredstring | null

Delivery attempt id from the unsigned header (stable across retries).

apiVersionRequiredstring | null
envelopeRequired{ [x: string]: unknown; id: string; api_version: "v1"; topic: string; store_id: string; install_id: string; sequence: number; occurred_at: string; data: Record<string, unknown>; test?: boolean | undefined; }

The parsed envelope including any fields added after this SDK was built.

EventDataByTopic#

interface EventDataByTopic

data type for each topic, straight from the contract's x-dukkan-topic-payloads.

Members
NameTypeDescription
order.createdRequired{ order_id: string; order_number: string; status: string; total_minor: number; currency: string; source?: string | null; } & { [key: string]: unknown; }
order.status_changedRequired{ order_id: string; from_status: string; status: string; } & { [key: string]: unknown; }
order.paidRequired{ order_id: string; amount_minor: number; currency: string; } & { [key: string]: unknown; }
product.createdRequired{ product_id: string; } & { [key: string]: unknown; }
product.updatedRequired{ product_id: string; variant_id?: string; operation?: "insert" | "update" | "delete"; } & { [key: string]: unknown; }
product.deletedRequired{ product_id: string; } & { [key: string]: unknown; }
inventory.movement_createdRequired{ movement_id: string; variant_id: string | null; location_id: string; delta: number; stock_after: number; reason: string; } & { [key: string]: unknown; }
fulfillment.requestedRequired{ fulfillment_id: string; order_id: string; lines: ({ order_item_id: string; quantity: number; } & { [key: string]: unknown; })[]; } & { [key: string]: unknown; }
fulfillment.createdRequired{ fulfillment_id: string; order_id: string; status: "pending" | "shipped"; tracking_number: string | null; lines: ({ order_item_id: string; quantity: number; } & { [key: string]: unknown; })[]; order_fully_fulfilled: boolean; } & { [key: string]: unknown; }
fulfillment.updatedRequired{ fulfillment_id: string; order_id: string; status: "pending" | "shipped" | "delivered" | "cancelled"; previous_status: string; tracking_number: string | null; } & { [key: string]: unknown; }
refund.createdRequired{ refund_id: string; order_id: string; amount_minor: number; currency: string; lines?: ({ order_item_id: string | null; quantity: number; amount_minor: number; } & { [key: string]: unknown; })[]; source?: "pos"; } & { [key: string]: unknown; }
app.uninstalledRequired{ app_id: string; install_id: string; } & { [key: string]: unknown; }

InboxRecord#

interface InboxRecord

One accepted delivery as the receiver hands it to the inbox: the signed event id, the install and store, the topic, and the payload.

Members
NameTypeDescription
eventIdRequiredstring
installIdRequiredstring
storeIdRequiredstring
topicRequiredstring
sequenceRequirednumber
occurredAtRequiredstring
testRequiredboolean
payloadRequiredRecord<string, unknown>
receivedAtRequiredstring

InboxStore#

interface InboxStore

Durable inbox the webhook receiver deduplicates on. insertIfNew must be atomic (a unique index on the event id in SQL, a conditional put in KV).

Members
NameTypeDescription
insertIfNewRequired(record: InboxRecord): Promise<boolean>
markFailed((eventId: string, error: string) => Promise<void>) | undefined

Called when a handler failed AFTER the event was recorded, so the app can keep the row visible for a replay instead of losing the event behind the dedupe. Optional; the receiver never throws past the acknowledgement.

InstallSecrets#

interface InstallSecrets

What the receiver knows about the install a delivery claims to be for.

Members
NameTypeDescription
secretsRequiredReadonlyArray<string>

Current signing secret first, then any previous one still in its grace window.

storeIdRequiredstring | null

The store the app bound this install to; a mismatching envelope is refused.

ReceiveResult#

interface ReceiveResult

What receive returns: the small JSON answer to send, the accepted event (also for duplicates) and the deferred process step.

Members
NameTypeDescription
responseRequiredWebhookResponse
eventRequiredDukkanEvent<"order.created" | "order.status_changed" | "order.paid" | "product.created" | "product.updated" | "product.deleted" | "inventory.movement_created" | "fulfillment.requested" | "fulfillment.created" | "fulfillment.updated" | "refund.created" | "app.uninstalled"> | null

The accepted event, when verification passed (also for duplicates).

processRequired() => Promise<void>

Dispatches handlers. Await it before responding for inline processing, or hand it to waitUntil / a queue to acknowledge first. Resolves for duplicates and rejected deliveries without doing anything.

VerifyWebhookInput#

interface VerifyWebhookInput

Input of verifyWebhookSignature: the raw bytes, the two signature headers, and the secret or secrets to try.

Members
NameTypeDescription
rawBodyRequiredstring | ArrayBuffer | Uint8Array<ArrayBufferLike>

The request body EXACTLY as received (bytes or the raw string); never re-serialized JSON.

timestampRequiredstring | null | undefined

X-Dukkan-Timestamp: unix seconds at send time.

signatureRequiredstring | null | undefined

X-Dukkan-Hmac-Sha256: hex HMAC over ${timestamp}.${rawBody}.

secretRequiredstring | ReadonlyArray<string>

The install's signing secret, or candidates when you hold more than one (rotation).

toleranceSecondsnumber | undefined
nowMsnumber | undefined

Injectable clock (milliseconds) for tests.

WebhookHeaders#

interface WebhookHeaders

The delivery headers as read from the request, case-insensitively.

Members
NameTypeDescription
deliveryIdRequiredstring | null
installIdRequiredstring | null

Unsigned hint; the signed install_id in the envelope is authoritative.

topicRequiredstring | null
timestampRequiredstring | null
apiVersionRequiredstring | null
signatureRequiredstring | null
contentLengthRequiredstring | null

WebhookReceiverOptions#

interface WebhookReceiverOptions

Configuration of createWebhookReceiver: where secrets and the inbox live, the typed handlers, and the hooks around them.

Members
NameTypeDescription
secretsForRequired(installId: string) => Promise<InstallSecrets | null>

Looks up the signing secret(s) for the install named in the header hint. Return null for an install you do not know; the delivery is answered 404 and the platform retries until the app learns about the install.

inboxInboxStore | undefined

Durable dedupe on the signed event id (default: none, every delivery dispatches).

handlersWebhookHandlers | undefined
onUnknownTopic((event: DukkanEvent) => Promise<void> | void) | undefined
onEvent((event: DukkanEvent, outcome: "accepted" | "duplicate") => Promise<void> | void) | undefined

Runs before handlers on every accepted event (metrics, logging).

onHandlerError((error: unknown, event: DukkanEvent) => Promise<void> | void) | undefined

Handler errors are caught so the delivery stays acknowledged: the event is already recorded in the inbox and the platform would otherwise retry into a duplicate answer. Observe them here (and through InboxStore.markFailed) to replay from your own inbox.

maxBodyBytesnumber | undefined
toleranceSecondsnumber | undefined
loggerDukkanLogger | undefined
nowMs(() => number) | undefined

WebhookResponse#

interface WebhookResponse

The status and body the receiver wants returned to the platform. Always small JSON.

Members
NameTypeDescription
statusRequirednumber
bodyRequired{ ok: true; duplicate?: boolean; } | { error: WebhookRejection; }

Types

VerifyWebhookResult#

type VerifyWebhookResult = | { ok: true; secretIndex: number } | { ok: false; reason: Extract<WebhookRejection, "missing_headers" | "stale_timestamp" | "bad_signature"> }

WebhookEnvelope#

type WebhookEnvelope = z.infer<typeof envelopeSchema>

WebhookHandler#

type WebhookHandler<Topic extends WebhookTopic> = (event: DukkanEvent<Topic>) => Promise<void> | void

WebhookHandlers#

type WebhookHandlers = { [Topic in WebhookTopic]?: WebhookHandler<Topic> }

Constants

envelopeSchema#

const envelopeSchema: z.ZodObject<{ id: z.ZodString; api_version: z.ZodLiteral<"v1">; topic: z.ZodString; store_id: z.ZodString; install_id: z.ZodString; sequence: z.ZodNumber; occurred_at: z.ZodString; test: z.ZodOptional<z.ZodBoolean>; data: z.ZodRecord<z.ZodString, z.ZodUnknown>; }, z.core.$loose>

The signed body (docs/apps/webhooks#envelope). Loose on purpose: v1 is additive, so a field added later must parse on an older SDK. topic is NOT restricted to the known list here so an unknown topic reaches onUnknownTopic instead of failing verification-adjacent parsing.

WEBHOOK_MAX_BODY_BYTES#

const WEBHOOK_MAX_BODY_BYTES: number

Largest delivery body the receiver accepts (256 KB); the platform never sends more.

WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS#

const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS: 300

How far the signed timestamp may drift from now (5 minutes) before a delivery is refused as stale.