Verifying signatures
Every webhook is signed with HMAC-SHA256 over the timestamp and the raw body. Check it, reject old ones, dedupe by data.id, and answer fast.
Outbound webhooks use a shared secret (shown once when you add the endpoint), not Ed25519. Every delivery is a POST with these headers:
| Header | Value |
|---|---|
X-NK-Signature |
sha256=<hex HMAC-SHA256(secret, "{timestamp}.{raw body}")> |
X-NK-Timestamp |
Unix seconds when we signed the delivery |
X-NK-Event |
the event type, e.g. item.imported |
The HMAC key is the secret as text. Use it exactly as shown: all 68 characters, the whs_ prefix included, as UTF-8 bytes. Do not strip the prefix and do not hex-decode it (in PHP hash_hmac('sha256', $data, $secret), never hex2bin; in Python secret.encode('utf-8')).
How to verify#
- Read the raw body bytes exactly as received — before any JSON parsing.
- Build the string
{X-NK-Timestamp}.{raw body}(the timestamp, a dot, the body). - Compute HMAC-SHA256 keyed with your endpoint's secret (the text as shown, see above), as lower-case hex.
- Compare it with the hex after
sha256=inX-NK-Signatureusing a constant-time comparison. - Reject deliveries older than 5 minutes (compare
X-NK-Timestampwith your clock) to stop replays. - Deduplicate by
data.id: the body is{ "event", "data", "timestamp" }anddata.idis the event id (Common fields); a delivery can arrive more than once. - Answer 2xx fast, then process. A slow or failing answer is retried (see Events and deliveries).
Node#
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifyNkWebhook({ secret, rawBody, timestampHeader, signatureHeader, now = Date.now() }) {
const timestamp = Number(timestampHeader)
if (!Number.isInteger(timestamp) || Math.abs(now / 1000 - timestamp) > 300) return false
const match = /^sha256=([0-9a-f]{64})$/.exec(signatureHeader ?? '')
if (!match) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest()
return timingSafeEqual(expected, Buffer.from(match[1], 'hex'))
}Pass the body as the exact string you received (for example with Express, express.raw({ type: 'application/json' }) and req.body.toString('utf8')). Parse the JSON only after the check passes:
if (!verifyNkWebhook({ secret, rawBody, timestampHeader, signatureHeader })) return res.status(401).end()
const { data } = JSON.parse(rawBody) // { event, data, timestamp }: the event is `data`
if (await alreadySeen(data.id)) return res.status(200).end()
res.status(200).end() // answer first, then work
await handle(data.type, data)To try it, add an endpoint and press Send test in the Partner Portal: a webhook.test delivery arrives within a minute.
Last updated