Signature Verification#

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

Request format#

Afosto does not POST your entity on its own. It POSTs an envelope — a JSON object that wraps your payload under data together with delivery metadata:

{
  "data": { "id": "97915021-7430-4bff-910e-9c007cc989e4", "number": "28477", "...": "..." },
  "entity_id": "97915021-7430-4bff-910e-9c007cc989e4",
  "entity_type": "ORDER",
  "event": "ORDER_CREATED",
  "message_id": "55fd34df-b33e-484c-9136-095314402045",
  "triggered_at": 1783507342299
}
  • data — the event payload (the order, contact, etc.)
  • entity_id / entity_type — the entity this event is about
  • event — the event name you subscribed to (e.g. ORDER_CREATED)
  • message_id — unique per delivery; use it for idempotency
  • triggered_at — Unix timestamp in milliseconds

The signature is computed over this entire envelope, which is exactly the raw request body you receive. You never need to reconstruct the envelope yourself — just verify against the bytes as they arrive.

Each request also carries these headers:

HeaderDescription
X-Afosto-Hmac-Sha256Hex-encoded HMAC-SHA256 signature of the raw body
X-Afosto-EndpointThe ID of the endpoint that produced this delivery
X-Afosto-TenantYour tenant ID

How signing works#

  1. Afosto serializes the envelope to a UTF-8 JSON string (HTML escaping disabled, no leading/trailing whitespace or newline)
  2. It computes HMAC-SHA256(body, endpoint_secret), using the endpoint secret as a literal UTF-8 string key
  3. The hex-encoded digest (64 lowercase hex characters) is sent in the X-Afosto-Hmac-Sha256 request header

To verify, compute the same HMAC on your side over 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 changes whitespace and key order, which will break the signature. Do not base64-decode the secret either — it is used verbatim as the HMAC key.

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')

const expectedBuf = Buffer.from(expected, 'hex')
const signatureBuf = Buffer.from(signature, 'hex')

// timingSafeEqual throws when the buffers differ in length, so guard first.
// An unequal length also means the signature can never match.
if (expectedBuf.length !== signatureBuf.length) {
  return false
}

// Use timingSafeEqual to prevent timing attacks
return timingSafeEqual(expectedBuf, signatureBuf)
}

// Next.js route / Express 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 envelope = JSON.parse(rawBody)

// envelope.data holds your payload; envelope.event / message_id are metadata.
// Process asynchronously — return 200 immediately.
processEvent(envelope).catch(console.error)

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

Alternative: verify the source IP#

Instead of verifying the signature, you can establish that a request genuinely came from Afosto by allowlisting the IP addresses it delivers from. Afosto sends webhooks from a fixed set of egress IPs, published at https://afosto.app/api/ips:

{
  "data": {
    "outbound": ["34.77.95.131"],
    "webhooks": ["34.77.95.131"]
  }
}

Use the webhooks array — these are the IPs used to deliver webhooks. (outbound covers Afosto's other outbound traffic.)

Warning:

IP allowlisting and signature verification are two valid ways to confirm a request came from Afosto — pick whichever fits your setup, or combine them. They protect against different things: a signature also guarantees the body was not altered in transit, whereas an IP check does not. IP allowlisting also depends on seeing the real client IP, which is not the case behind a proxy that rewrites it. The list can change, so fetch it periodically (e.g. cache for a few hours) rather than hard-coding the values.

let cache: { ips: string[]; fetchedAt: number } | null = null

async function getWebhookIps(): Promise<string[]> {
const oneHour = 60 * 60 * 1000
if (cache && Date.now() - cache.fetchedAt < oneHour) {
  return cache.ips
}

const res = await fetch('https://afosto.app/api/ips')
const body = await res.json()
cache = { ips: body.data.webhooks, fetchedAt: Date.now() }
return cache.ips
}

async function isFromAfosto(clientIp: string): Promise<boolean> {
const ips = await getWebhookIps()
return ips.includes(clientIp)
}
Warning:

Behind a load balancer or proxy, the real client IP is in a forwarded header (e.g. X-Forwarded-For), not the direct socket address. Only trust that header if the proxy in front of you is under your control and strips inbound copies — otherwise it can be spoofed.

Security checklist#

  • Verify over the raw body — compute the HMAC over the exact bytes you received. Never re-serialize the parsed JSON; a JSON parser normalizes whitespace and key order and will invalidate the signature.
  • Use a timing-safe comparison (timingSafeEqual, hmac.compare_digest, hash_equals) — never a plain === string comparison. This prevents timing attacks.
  • Guard against length mismatchescrypto.timingSafeEqual throws if the two buffers differ in length, so check the length (or decode from hex) before comparing.
  • Use the secret verbatim — the endpoint secret is the HMAC key as-is; do not base64-decode it.
  • 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.