fora
← Docs

Webhooks

Verifying Fora webhooks

Every outbound webhook from Fora is HMAC-SHA256 signed (when you opt in) and carries a stable event id you can dedupe on. This page documents the wire contract and gives you working Node + Python verification snippets pinned to a fixed test vector — paste either into your handler and it will pass against the value below.

Headers on every delivery

Fora sets these headers on every POST to your registered URL, alongside Content-Type: application/json.

HeaderWhenValue
Fora-Event-IdAlwaysUUIDv4. Stable across retry attempts of the same delivery; new for each fresh job. Use as your dedupe key.
Fora-SignatureWebhook is signedt=<unix>,v1=<hex> — see below.

Webhooks registered via POST /v1/webhooks default to signed: true. Legacy jobs that pass webhook_url on POST /v1/translate without registering first are auto-materialised as signed: false — they get theFora-Event-Id header but no signature. To opt into signing, register the same URL via POST /v1/webhooks and store the returned secret.

Signature scheme

The Fora-Signature header has the form:

Fora-Signature: t=<unix-seconds>,v1=<lowercase-hex>

where <lowercase-hex> is HMAC-SHA256(secret_bytes, "<t>.<raw_body>") — the bytes of <t> as ASCII digits, a literal period, then the exact request body bytes. Verify against the raw request body you receive — do NOT re-serialize the parsed JSON, or whitespace and key ordering will break the comparison.

The v1= prefix is a scheme version, following Stripe's convention. Future scheme revisions (different hash, different framing) will add v2= alongside v1=; existing verifiers won't break. For now there is only v1.

We recommend rejecting signatures whose timestamp is more than 5 minutes off your local clock — this is a replay-protection control on top of the integrity guarantee the HMAC provides.

Deduping with Fora-Event-Id

The same delivery is retried up to three times if your endpoint returns a non-2xx or fails to respond. Every retry of the same delivery carries the same Fora-Event-Id UUID. Different jobs (and operator-driven re-deliveries from the dashboard, when those ship) get a fresh id.

The simplest dedupe pattern: store Fora-Event-Id in a unique-indexed table inside the same transaction that processes the webhook. If the insert conflicts, the webhook has already been handled — return 200 and stop.

Verifying in Node.js

Uses only the standard library (node:crypto). Pass the raw request body — most frameworks (Express, Fastify, Hono) give you a way to access the unparsed body bytes; check your framework's docs for the equivalent of express.raw().

import { createHmac, timingSafeEqual } from 'node:crypto'

// Verify a Fora webhook. Pass the raw request body (Buffer or string —
// NOT a re-stringified JSON object) and the value of the Fora-Signature
// request header. Throws on tampering or staleness.
export function verifyForaWebhook(
  rawBody: string | Buffer,
  signatureHeader: string,
  secret: string,
  toleranceSeconds = 300,
): void {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('=', 2) as [string, string]),
  )
  const t = parseInt(parts.t ?? '', 10)
  const v1 = parts.v1 ?? ''
  if (!t || !v1) throw new Error('malformed Fora-Signature')

  // Replay protection — reject signatures more than `toleranceSeconds` old.
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - t) > toleranceSeconds) throw new Error('signature too old')

  const bodyBuf = typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody
  const signed = Buffer.concat([Buffer.from(`${t}.`), bodyBuf])
  const expected = createHmac('sha256', secret).update(signed).digest('hex')

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(v1, 'hex')
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('signature mismatch')
  }
}

// Test vector — copy/paste to verify your implementation matches Fora's:
//   secret    = whsec_test_constant_secret_value_x
//   timestamp = 1715000000
//   body      = {"hello":"world"}
//   header    = t=1715000000,v1=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429

Smoke test — paste this alongside the verifier and it should print ok.

import { verifyForaWebhook } from './fora.js'

// Stop the clock so the 300s tolerance check passes for the historical
// timestamp baked into the test vector.
const ORIG_NOW = Date.now
Date.now = () => 1715000000_000

verifyForaWebhook(
  '{"hello":"world"}',
  't=1715000000,v1=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429',
  'whsec_test_constant_secret_value_x',
)
console.log('ok')

Date.now = ORIG_NOW

Verifying in Python

Standard library only (hmac, hashlib). For Flask, the raw body is request.get_data(); for FastAPI, await request.body().

import hmac
import hashlib
import time

def verify_fora_webhook(raw_body: bytes, signature_header: str, secret: str,
                       tolerance_seconds: int = 300) -> None:
    """Verify a Fora webhook. Pass the raw request body (bytes — not a
    re-serialized dict) and the Fora-Signature header value. Raises on
    tampering or staleness."""
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        raise ValueError("malformed Fora-Signature")

    if abs(int(time.time()) - t) > tolerance_seconds:
        raise ValueError("signature too old")

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, v1):
        raise ValueError("signature mismatch")


# Test vector — copy/paste to verify your implementation matches Fora's:
#   secret    = whsec_test_constant_secret_value_x
#   timestamp = 1715000000
#   body      = {"hello":"world"}
#   header    = t=1715000000,v1=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429

Test vector

Both snippets above match the value Fora's signing code produces for these constants. If your implementation reproduces the same header, you're correct.

secret    = whsec_test_constant_secret_value_x
timestamp = 1715000000
body      = {"hello":"world"}

Fora-Signature: t=1715000000,v1=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429

The same constants are pinned in the server's unit tests (internal/worker/webhook_test.go TestComputeSignature_PinnedHex) so a drift on either side fails loudly in CI.

API reference

Manage your webhooks via the API: POST /v1/webhooks to register (returns the raw signing secret once); GET /v1/webhooks to list; DELETE /v1/webhooks/{id} to revoke. See the OpenAPI spec for full request/response schemas.