@duabalabs/sellub-webhooks
Verify the webhooks Sellub sends you, get a typed event back, and sign your own
deliveries the same way. No dependencies beyond Node’s built-in crypto.
Install
pnpm add @duabalabs/sellub-webhooksUse 0.4.0 or later: earlier versions can’t verify Sellub’s signature format.
Verify a Sellub webhook
Every delivery carries three headers:
Sellub-Event: order.placed
Sellub-Event-Id: <event id>
Sellub-Signature: t=1714050000,v1=4f4d…v1 is the HMAC-SHA256 of `${eventId}.${t}.${rawBody}` with your endpoint’s
whsec_… secret (the format is on Commerce API → Webhook signing).
Pass the raw body and the request headers; the package does the rest:
import { verifyAndParse } from "@duabalabs/sellub-webhooks";
export async function POST(req: Request) {
const raw = await req.text();
const r = verifyAndParse({
secret: process.env.SELLUB_WEBHOOK_SECRET!,
payload: raw,
headers: req.headers,
});
if (!r.success) return new Response(r.error?.message ?? "invalid", { status: 401 });
switch (r.data!.type) {
case "payment.succeeded":
await markOrderPaid(r.data.data.payment.reference);
break;
case "order.refunded":
await issueRefund(r.data.data.order.id, r.data.data.refundedAmount);
break;
case "fulfillment.shipped":
await notifyCustomer(r.data.data.order.id, r.data.data.fulfillment.trackingUrl);
break;
}
return Response.json({ ok: true });
}It checks, in order:
Sellub-SignatureandSellub-Event-Idare present and the signature header ist=<unix>,v1=<hex>.tis within 5 minutes of your clock (either way) — a replayed delivery fails. Change it withtoleranceSeconds.v1matches, compared in constant time.- The body is JSON shaped like a Sellub event.
Always read the body as a raw string (or Buffer) before parsing it. JSON serialisation is not byte-stable across runtimes; verifying a re-stringified body fails.
Deliveries are at-least-once: dedupe on Sellub-Event-Id (it is also
verifyWebhook’s eventId).
Express / Node http
headers takes a Fetch Headers object, Node’s req.headers, or any plain object
(header names are matched case-insensitively):
import express from "express";
import { verifyWebhook } from "@duabalabs/sellub-webhooks";
app.post("/sellub-events", express.raw({ type: "application/json" }), (req, res) => {
const result = verifyWebhook({
secret: process.env.SELLUB_WEBHOOK_SECRET!,
payload: req.body, // a Buffer, thanks to express.raw
headers: req.headers,
});
if (!result.valid) return res.status(401).send(result.code);
const event = JSON.parse(req.body.toString("utf8"));
// … handle event.type, dedupe on result.eventId
res.json({ ok: true });
});Send a signed webhook
dispatchWebhook signs and sends a delivery exactly the way Sellub does, so another
of your services can verify it with verifyAndParse:
import { dispatchWebhook } from "@duabalabs/sellub-webhooks";
await dispatchWebhook(
{
url: "https://my-service.example/sellub-events",
secret: process.env.MY_WEBHOOK_SECRET!,
},
{ id: "evt_123", type: "order.placed", createdAt: new Date().toISOString(), data: { orderId: "abc" } }
);The event id is eventId if you pass one, else the body’s id, else a random evt_….
API
verifyAndParse({ secret, payload, headers?, signature?, eventId?, toleranceSeconds? })
: ApiResult<WebhookEvent>
verifyWebhook({ secret, payload, headers?, signature?, eventId?, toleranceSeconds? })
: { valid: true; eventId; timestamp } | { valid: false; code; message }
verifySignature(sameOptions): boolean
signPayload({ secret, payload, eventId, timestamp? }): string // "t=…,v1=…"
computeSignature({ secret, eventId, timestamp, payload }): string // the v1 hex
parseSignatureHeader(header): { timestamp; signatures } | null
dispatchWebhook({ url, secret, eventId?, headers?, fetch? }, body)
: Promise<{ ok, status, text }>
// Re-exported from @duabalabs/sellub-types
isWebhookEvent(event, type): event is Extract<WebhookEvent, { type: T }>
SIGNATURE_HEADER, EVENT_ID_HEADER, EVENT_TYPE_HEADER, DEFAULT_TOLERANCE_SECONDS // 300payloadis astringorBuffer— the raw body.signature/eventIdoverride the values read fromheaders.- Error codes (
verifyWebhook().code,verifyAndParse().error.code):missing_signature,missing_event_id,malformed_signature,timestamp_out_of_tolerance,invalid_signature, and fromverifyAndParsealsoinvalid_json,invalid_shape. - A header with several
v1=values passes when any one matches. dispatchWebhookdefaults toglobalThis.fetch. In environments without one (older Node), pass{ fetch: undici.fetch }.
Upgrading from 0.3
0.3 compared a plain HMAC of the body with the whole header, so it rejected every genuine Sellub delivery. In 0.4:
- pass
headers: req.headers(orsignatureandeventId) instead ofsignature: req.headers.get("x-sellub-signature"); signPayloadneeds aneventIdand returnst=…,v1=…;dispatchWebhooksendsSellub-Signature/Sellub-Event-Idrather thanX-Sellub-Signature— upgrade both ends of your own services together.
Wiring on the Sellub side
When you add a webhook endpoint in the Sellub dashboard (Studio → Integrate) you get a
whsec_… secret. Store it in your environment as SELLUB_WEBHOOK_SECRET. Rotate it on
the same page (sellubRotateWebhookSecret in the Admin API) if it leaks.
Related
sellub-commerce-api-plugin— the server plugin that signs and delivers webhooks.sellub-types— typed event payloads.