All articles

Webhook best practices: Standard Webhooks spec, signature verify, retry pattern

Build idempotent, secure, retry-tolerant integrations with Sendnomi webhooks. HMAC verification, dead-letter handling, exponential backoff.

A webhook is a push mechanism: Sendnomi POSTs an event to your endpoint, you return 2xx. Synchronous, simple, scalable. But in production, four critical concerns appear:

  1. Security — how do you prove who it’s from?
  2. Idempotency — what happens if the same event arrives twice?
  3. Retry behavior — what does Sendnomi do when you go down?
  4. Dead-letter — how do you handle persistently failing events?

This post covers Sendnomi’s side and the patterns to code on yours.

1. Standard Webhooks Spec

Sendnomi webhooks follow the standardwebhooks.com open spec. Three core headers:

webhook-id: msg_2KqWfX...        # unique event ID (idempotency key)
webhook-timestamp: 1716895200    # event creation time (replay attack protection)
webhook-signature: v1,K6xYBy... # HMAC-SHA256 signature

2. Signature verification (HMAC-SHA256)

Your endpoint reads the body and:

import crypto from 'crypto';

function verifyWebhook(
  payload: string,        // raw request body — BEFORE JSON.parse
  headers: Record<string, string>,
  secret: string,         // panel → webhook → signing secret
): boolean {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const sig = headers['webhook-signature'];

  if (!id || !ts || !sig) return false;

  // 1. Timestamp tolerance check — reject events older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - parseInt(ts, 10));
  if (age > 300) return false;

  // 2. Compute HMAC
  const signedPayload = `${id}.${ts}.${payload}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('base64');

  // 3. Timing-safe compare — DO NOT use `===` (timing attack)
  const provided = sig.split(',')[1]; // "v1,<base64>" format
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(provided),
  );
}

Critical:

  • payload must be the raw body — after JSON.parse the bytes won’t match the signed payload.
  • Without timing-safe compare, an attacker can brute-force the signature.
  • Timestamp tolerance ~5 minutes — clocks must be synced (NTP active).

3. Idempotency — same event arrives twice

Because of retries, an event may arrive 1+ times. Your handler must answer have I processed this event before? with certainty.

Pattern: persist webhook-id

async function handleWebhook(event, headers) {
  const eventId = headers['webhook-id'];

  // 1. Already processed?
  const existing = await db.processedWebhook.findUnique({
    where: { eventId },
  });
  if (existing) {
    return { status: 200, body: 'already-processed' };
  }

  // 2. Atomic insert + process — UNIQUE constraint against race conditions
  try {
    await db.processedWebhook.create({
      data: { eventId, processedAt: new Date() },
    });
  } catch (e) {
    if (e.code === 'P2002') { // unique violation
      return { status: 200, body: 'race-already-processed' };
    }
    throw e;
  }

  // 3. Actual work
  await processBusinessLogic(event);
  return { status: 200, body: 'ok' };
}

You can sweep processedWebhook rows older than 90 days via cron — Sendnomi won’t retry past 7 days.

4. Retry behavior

Default retry policy on Sendnomi’s side:

AttemptWaitTotal age
1 (initial push)0
21 minute1 min
35 minutes6 min
430 minutes36 min
52 hours2h 36min
66 hours8h 36min
712 hours20h 36min
8 (final)24 hours44h 36min

We retry unless your endpoint returns 2xx. After the 8th attempt the event goes to dead-letter.

Which responses trigger a retry?

  • 5xx error — retry
  • Connection timeout, DNS failure — retry
  • 2xx — success, no retry
  • 4xx — permanent failure, NO retry (a 4xx cannot be made retriable since the input is malformed)

4xx cases:

  • 400 — payload format error (won’t fix without your update)
  • 401/403 — bad signature (rotate the secret in the panel)
  • 410 — endpoint permanently gone

5. Dead-letter pattern

If the 8th attempt also fails, Sendnomi stores the event for 30 days in dead-letter. Panel → Webhook → Dead-letter tab:

  • See the event JSON
  • Manually retry (after fixing your endpoint)
  • Close the event as failed (no longer relevant)

It’s smart to keep your own dead-letter too:

async function handleWebhook(event, headers) {
  try {
    // ... idempotency check + business logic
  } catch (err) {
    await dlq.push({ event, err: err.message, headers });
    if (isTransient(err)) {
      return { status: 500, body: 'retry-me' };  // Sendnomi will retry
    }
    return { status: 200, body: 'logged-to-dlq' }; // ack, we kept the event
  }
}

6. Performance patterns

Async processing

Your handler should return 2xx within 5 seconds. For heavy work:

async function handleWebhook(event, headers) {
  await verifySignature(...);
  await checkIdempotency(eventId);
  await jobQueue.enqueue('process-webhook', event); // BullMQ, sidekiq, etc.
  return { status: 200, body: 'queued' };
}

Connection pooling

A webhook endpoint is a high-frequency HTTP endpoint. Keep keepalive active in your reverse proxy (nginx, Caddy) and ensure your backend connection pool is healthy.

Eventual consistency

If the webhook writes to your DB and another system (CRM, analytics) reads the same data, indicate the eventual-consistency window in your UI (“new events appear within 10 seconds”).

Checklist

  • HMAC-SHA256 signature verification + timing-safe compare
  • webhook-timestamp 5-minute tolerance check
  • webhook-id idempotency (DB UNIQUE constraint)
  • 2xx response within 5 seconds — heavy work to an async queue
  • Clear distinction between 4xx (validation) and 5xx (transient)
  • Your own DLQ (in addition to Sendnomi’s)
  • Health endpoint (so we can ping from the panel)

Conclusion

A webhook is a “push API” and reliability requires discipline on both sides in production. Sendnomi follows Standard Webhooks; on your side, the patterns above give you an idempotent, secure, retry-tolerant integration.

Detailed docs: /en/inbound-webhooks/

— Yazılım Koçu