Skip to Content
WebhooksVerifying signatures

Verifying signatures

Anyone who knows your endpoint URL can send it requests. Skoup signs every delivery with the endpoint’s signing secret (whsec_…) so you can check that a request really comes from Skoup and that its body was not altered on the way.

The signature header

X-Skoup-Signature: t=1790150400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix timestamp (seconds) at which Skoup signed the delivery.
  • v1 is HMAC-SHA256(secret, "{t}.{raw body}"), hex-encoded.

The timestamp is part of the signed string, so an attacker cannot replay an old delivery with a fresh timestamp.

During a secret rotation the header carries several v1 values, one per valid secret. A delivery is authentic if any of them matches.

Verification steps

  1. Read the raw request body, byte for byte, before any JSON parsing.
  2. Split the header on , and collect t and every v1 value.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) and hex-encode it.
  4. Compare it with each v1 value using a constant-time comparison.
  5. Reject the request if t is more than 5 minutes away from your clock.

The number one cause of failed verifications is signing a re-serialized body. Frameworks that parse JSON before your handler runs change spaces and key order. Always sign the raw bytes you received.

Code samples

import crypto from 'node:crypto' import express from 'express' const app = express() const SECRET = process.env.SKOUP_WEBHOOK_SECRET // whsec_… const TOLERANCE_SECONDS = 300 function verifySkoupSignature(rawBody, header, secret) { const parts = header.split(',').map((part) => part.split('=', 2)) const timestamp = parts.find(([key]) => key === 't')?.[1] const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value) if (!timestamp || signatures.length === 0) return false if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest() return signatures.some((signature) => { const received = Buffer.from(signature, 'hex') return received.length === expected.length && crypto.timingSafeEqual(received, expected) }) } // express.raw keeps the body as a Buffer: never use express.json() on this route. app.post('/skoup', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('X-Skoup-Signature') ?? '' if (!verifySkoupSignature(req.body.toString('utf8'), header, SECRET)) { return res.status(400).send('Invalid signature') } const event = JSON.parse(req.body.toString('utf8')) queue.push(event) // process asynchronously res.sendStatus(200) })

Rotating the secret

In the endpoint’s page, Roll secret generates a new secret. For 24 hours, the previous secret stays valid and every delivery carries two v1 signatures — one per secret — so you can deploy the new secret without dropping a single event. After 24 hours only the new secret signs.

If a secret has leaked, choose Roll immediately: the old secret stops signing at once.

Debugging a failed verification

Each delivery in Settings → Developers → Webhooks shows the exact headers and body that were sent. Recompute the signature from that body with your secret and compare: if it matches, your code reads a different body than the one Skoup sent — usually a parsed and re-serialized one.

Last updated on