Skip to main content
Moneyline
All guides
Intermediate8 minDelivery

Receive results with signed webhooks

Register an endpoint, verify the HMAC signature, and handle retries without processing anything twice.

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

bashbash
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.

Express, with the raw body preservedtypescript
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.

typescripttypescript
// 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.

bashbash
moneyline relay replay --failed --since 24h

Keep going

Run it yourself

Everything in this guide works against the open-source stack. Clone it, or start on the hosted API.