message.comDevelopers

Build a durable webhook receiver

Verify, store and acknowledge events before processing them asynchronously.

Use a durable inbox

Create a table keyed by logical receiver and Message eventId. Keep the payload and processing state so a worker can recover pending events after a crash.

Use a durable inbox
CREATE TABLE webhook_inbox (
  receiver_key text NOT NULL,
  event_id text NOT NULL,
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  PRIMARY KEY (receiver_key, event_id)
);

Verify the raw request

Use the verification helper below with the endpoint’s own secret. Capture raw bytes before parsing JSON.

Verify the raw request
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyOutgoing(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== 'string') return false;
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header);
  if (!match) return false;
  const timestamp = Number(match[1]);
  if (!Number.isSafeInteger(timestamp) || timestamp <= 0) return false;
  if (Math.abs(Math.floor(now / 1000) - timestamp) > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(match[1] + '.')
    .update(rawBody)
    .digest();
  const received = Buffer.from(match[2], 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}

Persist before acknowledging

This Express example assumes you provide express, db and the verified helper. PostgreSQL commits the insert before the receiver returns 200. An existing key means the event is already durably stored; it may still be pending processing.

Persist before acknowledging
// Express example. Mount this route BEFORE a global JSON parser.
// db is your PostgreSQL client. Import verifyOutgoing from the example above.
app.post('/webhooks/message', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyOutgoing(req.body, req.get('x-message-signature'), process.env.MESSAGE_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  let event;
  try { event = JSON.parse(req.body.toString('utf8')); }
  catch { return res.sendStatus(400); }
  if (!event || typeof event.eventId !== 'string' || !event.eventId || typeof event.topic !== 'string') {
    return res.sendStatus(400);
  }
  try {
    // Store the payload, not only a 'seen' flag. A worker processes pending rows.
    // receiver_key scopes deduplication to this logical consumer.
    await db.query(
      'INSERT INTO webhook_inbox (receiver_key, event_id, payload) VALUES ($1, $2, $3::jsonb) ON CONFLICT DO NOTHING',
      ['message-primary', event.eventId, JSON.stringify(event)]
    );
    // Only acknowledge after the database commits the insert.
    return res.sendStatus(200);
  } catch {
    return res.sendStatus(503); // safe for Message to retry
  }
});

Process with retries

Run a worker that claims pending rows, performs the intended operation and records completion. Use transactions for local database changes. For an external API, use its supported idempotency mechanism or a durable outbox and reconciliation. Exactly-once external side effects do not follow automatically from deduplicating receipt.

Test failure cases

Test a duplicate delivery, altered body, malformed signature, expired timestamp, database failure and worker crash. Keep records longer than your replay/recovery requirements; deleting a dedupe record permits that event to be accepted again.