Signature Verification#

Every webhook request from Afosto is signed using HMAC-SHA256. Verifying the signature before processing a payload ensures the request genuinely came from Afosto and that the body was not tampered with in transit.

How signing works#

  1. Afosto serializes the JSON payload to a UTF-8 string (HTML escaping disabled, no trailing newline)
  2. It computes HMAC-SHA256(payload, endpoint_secret)
  3. The hex-encoded digest is sent in the X-Afosto-Hmac-Sha256 request header

To verify, compute the same HMAC on your side using the raw request body (before any JSON parsing) and compare it to the header value.

Warning:Always compute the HMAC over the raw bytes of the request body. Parsing the JSON first and re-serializing it can change whitespace or key order, causing the signature to not match.

Verification examples#

import { createHmac, timingSafeEqual } from 'crypto'

function verifyWebhookSignature(
rawBody: string,
secret: string,
signature: string,
): boolean {
const expected = createHmac('sha256', secret)
  .update(rawBody, 'utf8')
  .digest('hex')

// Use timingSafeEqual to prevent timing attacks
return timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
}

// Express / Next.js edge handler example:
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get('X-Afosto-Hmac-Sha256') ?? ''
const secret = process.env.AFOSTO_WEBHOOK_SECRET!

if (!verifyWebhookSignature(rawBody, secret, signature)) {
  return new Response('Unauthorized', { status: 401 })
}

const payload = JSON.parse(rawBody)

// Process asynchronously — return 200 immediately
processEvent(payload).catch(console.error)

return new Response('OK', { status: 200 })
}

Security checklist#

  • Use a timing-safe comparison (timingSafeEqual, hmac.compare_digest, hash_equals) — never a plain === string comparison. This prevents timing attacks.
  • Read the raw body before parsing — JSON parsers may normalize the payload, invalidating the signature.
  • Store your secret securely — use an environment variable or secret manager, never hard-code it.
  • Reject requests with a missing or empty signature header — treat them as untrusted.
  • Use message_id for idempotency — a verified signature does not mean the event was not already processed.
Query Runnerhttps://afosto.app/graphql

No query loaded

Click play on any code block in the docs to load a query here.