Jetron TicketFluid

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 ) )
AlgorithmHMAC-SHA512
KeyYour private API key (jt_private_...). There is no separate webhook secret
Signed contentThe raw request body only. No timestamp or prefix is mixed in
EncodingLowercase hex, 128 characters, with no sha512= or v1= prefix
HeaderX-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

lib/verify-webhook.ts
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 timingSafeEqual throws when the buffers differ in length, and an unhandled throw is itself an observable difference.

Wiring it into a route

app/api/webhooks/jetron/route.ts
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.

On this page