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.
| Header | When | Value |
|---|---|---|
| Fora-Event-Id | Always | UUIDv4. Stable across retry attempts of the same delivery; new for each fresh job. Use as your dedupe key. |
| Fora-Signature | Webhook is signed | t=<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=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429Smoke 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_NOWVerifying 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=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429Test 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=88698fee7c28560c6c74e6a3e80e9fecc0a800ef7a413bd7eb8374a53c97b429The 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.