Skip to Content
SellubSDKssellub-webhooks

@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.

npm

Install

pnpm add @duabalabs/sellub-webhooks

Use 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:

  1. Sellub-Signature and Sellub-Event-Id are present and the signature header is t=<unix>,v1=<hex>.
  2. t is within 5 minutes of your clock (either way) — a replayed delivery fails. Change it with toleranceSeconds.
  3. v1 matches, compared in constant time.
  4. 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 // 300
  • payload is a string or Buffer — the raw body.
  • signature / eventId override the values read from headers.
  • Error codes (verifyWebhook().code, verifyAndParse().error.code): missing_signature, missing_event_id, malformed_signature, timestamp_out_of_tolerance, invalid_signature, and from verifyAndParse also invalid_json, invalid_shape.
  • A header with several v1= values passes when any one matches.
  • dispatchWebhook defaults to globalThis.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 (or signature and eventId) instead of signature: req.headers.get("x-sellub-signature");
  • signPayload needs an eventId and returns t=…,v1=…;
  • dispatchWebhook sends Sellub-Signature / Sellub-Event-Id rather than X-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.

Last updated on