Polling a parse job wastes requests and adds latency. Webhooks invert it, at the cost of two things you have to get right: proving the request came from us, and surviving the same event arriving more than once.
Register the endpoint
curl -X POST "$MONEYLINE_BASE_URL/v1/webhooks/register" \
-H "Authorization: Bearer $MONEYLINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/hooks/moneyline",
"events": ["document.completed", "document.failed"]
}'The response contains a signing secret. It is shown once. Store it where you keep other secrets, not in the repository.
Verify the signature
Every delivery carries Moneyline-Signature and Moneyline-Timestamp. The signature is an HMAC over the timestamp and the raw body.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(req: Request, rawBody: Buffer): boolean {
const signature = req.header('Moneyline-Signature') ?? '';
const timestamp = req.header('Moneyline-Timestamp') ?? '';
// Reject anything old enough to be a replay of a captured request.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', process.env.MONEYLINE_WEBHOOK_SECRET!)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
// Constant-time compare: a plain === leaks the correct prefix
// through how long the comparison takes.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Handle redelivery
A delivery is retried when your endpoint does not answer with a 2xx, and a response that times out may still have been processed. Assume at-least-once and make the handler idempotent by keying on the event id.
// A unique index on event_id turns a duplicate delivery into a
// constraint violation instead of a second write.
const inserted = await db
.insertInto('processed_events')
.values({ eventId: event.id })
.onConflict((c) => c.column('event_id').doNothing())
.executeTakeFirst();
if (Number(inserted.numInsertedOrUpdatedRows ?? 0) === 0) {
return reply.code(200).send(); // Already handled. Acknowledge and stop.
}
await handle(event);Answer fast, work later
Acknowledge with a 2xx as soon as the event is durably recorded, then do the work on a queue. Handlers that parse, enrich, and write to three systems before responding are the usual cause of retry storms: the work succeeds, the response times out, and the same event arrives again.
When delivery fails
Failed deliveries retry with exponential backoff and then land in a dead-letter queue. Inspect and replay from the dashboard or the CLI once your endpoint is healthy again.
moneyline relay replay --failed --since 24h