Verifying signatures
Confirm a delivery really came from Jetron before you act on it.
Your webhook URL is a public endpoint, so anyone can post to it. Every genuine
delivery carries an X-Jetron-Signature header, and you should reject anything
whose signature does not verify.
How deliveries are signed
Jetron computes an HMAC over the exact bytes of the request body:
X-Jetron-Signature = hex( HMAC_SHA512( yourPrivateApiKey, rawRequestBody ) )| Algorithm | HMAC-SHA512 |
| Key | Your private API key (jt_private_...). There is no separate webhook secret |
| Signed content | The raw request body only. No timestamp or prefix is mixed in |
| Encoding | Lowercase hex, 128 characters, with no sha512= or v1= prefix |
| Header | X-Jetron-Signature |
The private key is a server secret
Verification needs your private key, so it can only happen on your server. Never
ship it to the browser. This is a different key from the jt_public_... key
used to call the API.
Verifying in Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
export const SIGNATURE_HEADER = 'x-jetron-signature'
export function verifySignature(rawBody: string, signature: string | null): boolean {
const key = process.env.JETRON_PRIVATE_API_KEY
if (!key) throw new Error('JETRON_PRIVATE_API_KEY is not set')
if (!signature) return false
const expected = createHmac('sha512', key).update(rawBody, 'utf8').digest('hex')
// Compare equal-length buffers so timingSafeEqual cannot throw on a length
// mismatch, which would leak the result through the exception.
const received = Buffer.from(signature.trim(), 'utf8')
const digest = Buffer.from(expected, 'utf8')
if (received.length !== digest.length) return false
return timingSafeEqual(received, digest)
}Two details matter here:
- Compare in constant time. A plain
===on the hex string leaks timing information that can be used to forge a signature one character at a time. - Guard the length first. Node's
timingSafeEqualthrows when the buffers differ in length, and an unhandled throw is itself an observable difference.
Wiring it into a route
import { SIGNATURE_HEADER, verifySignature } from '@/lib/verify-webhook'
export const runtime = 'nodejs' // HMAC is unavailable on edge runtimes
export const dynamic = 'force-dynamic'
export async function POST(req: Request) {
const raw = await req.text()
const signature = req.headers.get(SIGNATURE_HEADER)
let verified: boolean
try {
verified = verifySignature(raw, signature)
} catch (err) {
// Missing key is a server misconfiguration, not a bad request
return Response.json({ error: (err as Error).message }, { status: 500 })
}
if (!verified) {
return Response.json({ error: 'invalid or missing signature' }, { status: 401 })
}
const event = JSON.parse(raw)
if (event.eventType === 'TICKET_PURCHASED') {
await fulfil(event.data)
}
return Response.json({ ok: true })
}Always verify against the raw body
await req.json() followed by JSON.stringify does not reproduce the bytes
that were signed. Key order and whitespace change, and the HMAC will never
match. Read the body as text first, verify, then parse that same text.
Verifying in other languages
import hashlib
import hmac
import os
def verify_signature(raw_body: bytes, signature: str | None) -> bool:
key = os.environ["JETRON_PRIVATE_API_KEY"].encode()
if not signature:
return False
expected = hmac.new(key, raw_body, hashlib.sha512).hexdigest()
return hmac.compare_digest(expected, signature.strip())Replay protection
Deliveries carry no timestamp and no nonce, so a captured request stays valid indefinitely. A verified signature proves the body came from Jetron, not that it is fresh or that you have not already seen it.
Deduplicate on data.reference, which is stable across retries of the same
purchase:
if (await alreadyProcessed(event.data.reference)) {
return Response.json({ ok: true }) // acknowledge, do nothing
}
await fulfil(event.data)
await markProcessed(event.data.reference)Testing your endpoint
Press Test on the subscription in your dashboard to send a
WEBHOOK_TEST delivery. It is signed exactly like a
real event, so it exercises your verification path end to end. Test deliveries
are never retried, so a 401 shows up as a single failed attempt rather than
several.

