@duabalabs/sellub-webhooks
HMAC-SHA256 helpers for signing outbound webhooks (the Sellub server uses
this) and verifying inbound ones (your integration uses this). Tiny, no
dependencies beyond Node’s built-in crypto.
Install
pnpm add @duabalabs/sellub-webhooksVerify an inbound Sellub webhook
Sellub posts events with header X-Sellub-Signature: <hex hmac>. Verify
before doing any work:
import { verifySignature } from "@duabalabs/sellub-webhooks";
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get("x-sellub-signature") ?? "";
const ok = verifySignature({
secret: process.env.SELLUB_WEBHOOK_SECRET!,
payload: raw,
signature,
});
if (!ok) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(raw);
// … handle event.type
return Response.json({ ok: true });
}Always read the body as a raw string before parsing it. JSON serialisation is not byte-stable across runtimes; verifying against a re-stringified body breaks intermittently.
Send a signed webhook
If you need to fire your own signed webhooks (e.g. between two of your own services using the same primitives Sellub uses):
import { dispatchWebhook } from "@duabalabs/sellub-webhooks";
await dispatchWebhook(
{
url: "https://my-service.example/sellub-events",
secret: process.env.MY_WEBHOOK_SECRET!,
},
{ type: "order.placed", orderId: "abc" }
);API
signPayload({ secret, payload }): string
verifySignature({ secret, payload, signature }): boolean
// New in 0.2 — verify + parse in one step, get a typed event back.
verifyAndParse({ secret, payload, signature }): ApiResult<WebhookEvent>
dispatchWebhook(
{ url, secret, headers?, fetch? },
body: unknown
): Promise<{ ok, status, text }>
// Re-exported from @duabalabs/sellub-types
isWebhookEvent(event, type): event is Extract<WebhookEvent, { type: T }>verifyAndParse example
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,
signature: req.headers.get("x-sellub-signature") ?? "",
});
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 });
}verifySignatureusestimingSafeEqualso it’s safe against timing attacks.dispatchWebhookdefaults toglobalThis.fetch. In environments without one (older Node), pass{ fetch: undici.fetch }.
Wiring on the Sellub side
When you add a webhook endpoint in the Sellub dashboard you’ll get a secret
shown once. Store it in your environment as SELLUB_WEBHOOK_SECRET and
use it in the snippet above. Rotate via rotateWebhookSecret (Admin GraphQL)
if it leaks.
Related
sellub-commerce-api-plugin— the server plugin that signs and delivers webhooks.sellub-types— typed event payloads (in progress).