Skip to main content
On this page

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#

  1. Read the raw body bytes exactly as received — before any JSON parsing.
  2. Build the string {X-NK-Timestamp}.{raw body} (the timestamp, a dot, the body).
  3. Compute HMAC-SHA256 keyed with your endpoint's secret (the text as shown, see above), as lower-case hex.
  4. Compare it with the hex after sha256= in X-NK-Signature using a constant-time comparison.
  5. Reject deliveries older than 5 minutes (compare X-NK-Timestamp with your clock) to stop replays.
  6. Deduplicate by data.id: the body is { "event", "data", "timestamp" } and data.id is the event id (Common fields); a delivery can arrive more than once.
  7. Answer 2xx fast, then process. A slow or failing answer is retried (see Events and deliveries).

Node#

js
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:

js
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