Sellvik / developers
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:

  1. The request really came from Sellvik (not a spoofer).
  2. 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 is v1; future versions may add v2=... alongside without removing v1.

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

PitfallWhy it breaks
Verifying parsed JSON, not raw bodyJSON.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 checkLets attackers replay an old (and valid) signature later.
Logging the secretAnyone with log access can forge any future webhook.
Hard-coding the secretRotation 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:

  1. POST /v1/admin/webhooks to create a new subscription with the same URL and events.
  2. Configure your endpoint to accept either signature for a grace window (verify against both secrets; succeed if either matches).
  3. DELETE /v1/admin/webhooks/{old-id} once the new one has delivered at least one event successfully.
  4. 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 (or cloudflared 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"

On this page