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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis the Unix timestamp (seconds) at which Skoup signed the delivery.v1isHMAC-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
- Read the raw request body, byte for byte, before any JSON parsing.
- Split the header on
,and collecttand everyv1value. - Compute
HMAC-SHA256(secret, t + "." + rawBody)and hex-encode it. - Compare it with each
v1value using a constant-time comparison. - Reject the request if
tis 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
Node.js
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.