Skip to content

Webhook Idempotency

Sangho guarantees at-least-once delivery: an event may be delivered more than once in the event of a network failure, timeout, or server restart. Your endpoint must be idempotent — processing the same event twice must not produce additional side effects.

Deduplication strategy

  • 1. Extract the Sangho-Event-ID from the header (or event.id from the body).
  • 2. Check in your database whether this ID has already been processed.
  • 3. If yes → return 200 immediately without reprocessing.
  • 4. If no → process the event, then record the ID.
Store IDs in your database

Do not use an in-memory Set in production — it resets on server restart. Use a SQL table with a PRIMARY KEY constraint on event_id, or Redis with SET NX (SET if Not eXists).

INSERT ... ON CONFLICT DO NOTHING

In PostgreSQL and SQLite, use INSERT … ON CONFLICT (event_id) DO NOTHING for atomic deduplication with no race condition, even under concurrent load.

Deduplication (Node.js)

javascript
// In production: replace processedEvents with Redis or a DB table
const processedEvents = new Set<string>();


app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const event = sangho.webhooks.constructEvent(
      req.body,
      req.headers['sangho-signature'] as string,
      process.env.WEBHOOK_SECRET!,
    );


    // Check if the event has already been processed
    if (processedEvents.has(event.id)) {
      console.log('Duplicate ignored:', event.id);
      return res.json({ received: true });
    }


    // Process the event
    await handleEvent(event);


    // Mark as processed
    processedEvents.add(event.id);


    res.json({ received: true });
  },
);

SQL deduplication model

Create a dedicated table to store already-processed event IDs. The PRIMARY KEY constraint on event_id guarantees uniqueness at the database level, even under concurrent requests.

Add an index on processed_at to facilitate periodic cleanup of old records (after 30 days, for example).

Retention period

Sangho will never redeliver an event after 48 hours. You can safely delete deduplication records older than 72 hours. Schedule a cleanup job via Celery Beat, cron, or a scheduled task.

SQL schema

sql
-- PostgreSQL / SQLite
CREATE TABLE processed_webhook_events (
  event_id     VARCHAR(50)  PRIMARY KEY,
  event_type   VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP    DEFAULT NOW()
);


-- Index for automatic cleanup
CREATE INDEX idx_webhook_processed_at
  ON processed_webhook_events (processed_at);


-- Idempotent insert (atomic, no race condition)
INSERT INTO processed_webhook_events (event_id, event_type)
VALUES ('evt_xxxx', 'payment_intent.succeeded')
ON CONFLICT (event_id) DO NOTHING;


-- Clean up old records (> 72h)
DELETE FROM processed_webhook_events
WHERE processed_at < NOW() - INTERVAL '72 hours';