Webhooks
Signing and verification
Verify the X-Sellvik-Signature header — never trust an unverified webhook.
Every webhook delivery carries a signature derived from the raw request body and your webhook secret. Verifying it confirms two things:
- The request really came from Sellvik (not a spoofer).
- The body wasn't modified in flight.
An unverified webhook is a malicious-input vector. Never act on one.
The signature header
X-Sellvik-Signature: t=1716810000,v1=a1b2c3d4e5f6...t— UNIX timestamp (seconds) when Sellvik signed the request.v1— HMAC-SHA256 hex digest. The signing algorithm version isv1; future versions may addv2=...alongside without removingv1.
Verification recipe
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifySellvikWebhook(
rawBody: string,
signatureHeader: string | undefined,
secret: string,
toleranceSeconds = 300,
): boolean {
if (!signatureHeader) return false
// Parse the header.
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=", 2) as [string, string])
)
const timestamp = Number(parts.t)
const v1 = parts.v1
if (!timestamp || !v1) return false
// Reject stale signatures to prevent replay.
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - timestamp)
if (ageSeconds > toleranceSeconds) return false
// Compute the expected signature.
const payload = `${timestamp}.${rawBody}`
const expected = createHmac("sha256", secret).update(payload).digest("hex")
// Constant-time compare to defend against timing oracles.
const a = Buffer.from(v1, "hex")
const b = Buffer.from(expected, "hex")
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}Use it in a handler
Next.js (App Router)
// app/api/sellvik-webhook/route.ts
import { NextResponse } from "next/server"
import { verifySellvikWebhook } from "@/lib/sellvik-webhook"
export async function POST(req: Request) {
const rawBody = await req.text() // raw bytes BEFORE JSON.parse
const ok = verifySellvikWebhook(
rawBody,
req.headers.get("x-sellvik-signature") ?? undefined,
process.env.SELLVIK_WEBHOOK_SECRET!,
)
if (!ok) return new NextResponse("invalid signature", { status: 401 })
const event = JSON.parse(rawBody)
await handleEvent(event) // your business logic
return NextResponse.json({ received: true })
}Express
app.post(
"/sellvik-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8")
const ok = verifySellvikWebhook(
rawBody,
req.header("x-sellvik-signature"),
process.env.SELLVIK_WEBHOOK_SECRET!,
)
if (!ok) return res.status(401).send("invalid signature")
const event = JSON.parse(rawBody)
handleEvent(event)
res.json({ received: true })
},
)Note express.raw(...). If you use express.json() instead, the body is
already parsed and re-serialised, and the bytes you sign over will differ
from the bytes Sellvik signed — the signature will never match.
Common pitfalls
| Pitfall | Why it breaks |
|---|---|
| Verifying parsed JSON, not raw body | JSON.stringify(JSON.parse(s)) is not the identity for whitespace, key order, or number formatting. |
Comparing with === | Vulnerable to timing oracles. Use timingSafeEqual. |
| Skipping timestamp tolerance check | Lets attackers replay an old (and valid) signature later. |
| Logging the secret | Anyone with log access can forge any future webhook. |
| Hard-coding the secret | Rotation needs a deploy. Read from env or a secret manager. |
Rotating the secret
There is no in-place rotation — the secret is shown only at creation. To rotate:
POST /v1/admin/webhooksto create a new subscription with the same URL and events.- Configure your endpoint to accept either signature for a grace window (verify against both secrets; succeed if either matches).
DELETE /v1/admin/webhooks/{old-id}once the new one has delivered at least one event successfully.- Remove the old secret from your endpoint.
Total downtime: zero, as long as the grace window covers any in-flight deliveries (~5 minutes is enough).
Testing locally
For dev:
- Use
ngrok http 3000(orcloudflared tunnel) to expose your local endpoint. - Register the public URL as a webhook on a dev shop.
- The signature flow is identical; only the URL changes.
To craft a test event by hand:
secret="whsec_..."
body='{"id":"evt_test","type":"ping","createdAt":"2026-05-27T13:45:00.000Z","shopSubdomain":"acme","data":{}}'
ts=$(date +%s)
sig=$(printf "%s.%s" "$ts" "$body" | openssl dgst -sha256 -hmac "$secret" -hex | awk '{print $2}')
curl -X POST http://localhost:3000/api/sellvik-webhook \
-H "Content-Type: application/json" \
-H "X-Sellvik-Signature: t=$ts,v1=$sig" \
-d "$body"