Errors & blockers

Webhook retry best practices

How a well-built webhook consumer should handle retries, duplicate deliveries, and out-of-order events.

Quick answer

Treat webhook delivery as at-least-once and unordered. Verify the signature, persist or enqueue the event, and return a 2xx fast — PostGate retries any timeout or non-2xx response. Deduplicate on the stable event ID (X-PostGate-Event-Id), order by occurred_at rather than arrival time, and reconcile against GET /api/posts/{id} plus a dead letter queue once retries run out.

A webhook sender that retries on failure is doing you a favor — it means a brief outage on your end doesn't silently drop an event. But that favor only pays off if your consumer is built to receive the same event more than once, sometimes years apart in delivery order, without corrupting its own state. Most webhook bugs aren't in the sending; they're in a consumer that assumed "received once, in order, exactly one time" and got none of those three guarantees.

PostGate handles OAuth, publishing and delivery evidence for every network through one API. Start free or read the docs.

01

PostGate’s delivery model: acknowledge fast, or expect a retry

PostGate retries a webhook delivery whenever your endpoint times out or responds with a non-2xx status. The practical implication is that your handler has exactly one job on the request path: verify the signature, persist or enqueue the event, and return a 2xx — quickly. Anything slower belongs after the response, not before it. See Webhooks for the full contract, and use POST /api/webhooks/{id}/test to confirm the complete path — verification through acknowledgment — after every deploy or infrastructure change, not just at initial setup.

Acknowledge before doing the real workPostGate API
app.post('/webhooks/postgate', async (req, res) => {
  const event = verifyAndParse(req); // see webhook-signature-verification
  if (!event) return res.status(400).send('invalid signature');

  await enqueueForProcessing(event); // fast: write to a queue/table, don't process inline
  res.status(200).send('ok');        // ack immediately — processing happens out of band
});
02

Deduplicate by event ID, not by content

Every PostGate event carries a stable id, and the same ID is also sent as the X-PostGate-Event-Id header — read that header before you even need to parse the body. Because retries resend the identical event, your consumer should treat delivery as at-least-once and de-duplicate on that ID, not by comparing payload contents (which is slower and breaks the moment two distinct events happen to look similar). A unique constraint on event_id in whatever table or queue receives the event is usually enough — a duplicate insert fails cleanly, and you still return 200 either way.

Dedup with a unique constraintPostGate API
CREATE TABLE processed_webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- On each delivery: INSERT ... ON CONFLICT (event_id) DO NOTHING;
-- If zero rows were inserted, you've already processed this event — skip it, still return 200.
03

Can webhook events arrive out of order?

A retry after a timeout, combined with normal network variance across multiple event types firing close together, means a post.published event and a later post.insights event for the same post are not guaranteed to arrive in the order they occurred. Use each event's occurred_at field to reason about ordering explicitly rather than trusting arrival order — for example, only overwrite a stored status if the incoming event's occurred_at is newer than what you already have, so a delayed but stale retry can't clobber a more recent state.

04

Handle the envelope defensively

Every event shares the same shape — id, type, occurred_at, and a resource-specific data object. If your consumer relays events into your own internal pipeline, keep unknown fields on the way through rather than mapping only the fields you currently use; PostGate can add new fields to data for a given event type without warning, and a strict allowlist-only mapper will silently drop them. See Webhooks for the event catalog, including the auth-state events (profile.expired, profile.reauth_required) that fire once per state transition, not on every internal check — don't build logic that expects them repeatedly.

05

When retries stop, you still need a fallback

No retry policy runs forever. If your endpoint is down for an extended incident, PostGate's retry window for that delivery will eventually be exhausted, and the event is gone from the sender's side even though your system never processed it. Two things bound the blast radius: recovering from delivery-lifecycle data by polling GET /api/posts/{id} for anything you might have missed, and routing genuinely failed deliveries — signature failures, malformed events, or anything your handler rejects after real retries — into a dead letter queue instead of silently dropping them. See Dead letter queues for webhook delivery.

Frequently asked questions

When does PostGate retry a webhook delivery?

Whenever your endpoint times out or responds with a non-2xx status. That means your handler has exactly one job on the request path: verify the signature, persist or enqueue the event, and return a 2xx quickly — anything slower belongs after the response. Use POST /api/webhooks/{id}/test to confirm the complete path after every deploy or infrastructure change.

How do I handle duplicate webhook events?

Treat delivery as at-least-once and de-duplicate on the stable event ID — sent both in the payload and as the X-PostGate-Event-Id header — not by comparing payload contents. A unique constraint on event_id in whatever table or queue receives the event is usually enough: use INSERT ... ON CONFLICT (event_id) DO NOTHING, skip the event if zero rows were inserted, and still return 200 either way.

Are webhook events guaranteed to arrive in order?

No. A retry after a timeout, combined with normal network variance across event types firing close together, means a post.published event and a later post.insights event for the same post can arrive out of sequence. Use each event’s occurred_at field to reason about ordering — for example, only overwrite a stored status if the incoming event’s occurred_at is newer than what you already have.

What happens if my webhook endpoint is down for a long time?

PostGate’s retry window for a delivery will eventually be exhausted, and the event is gone from the sender’s side even though your system never processed it. Bound the blast radius two ways: reconcile by polling GET /api/posts/{id} against delivery-lifecycle data for anything you might have missed, and route genuinely failed deliveries into a dead letter queue instead of silently dropping them.

Can PostGate add new fields to webhook payloads?

Yes — PostGate can add new fields to an event type’s data object without warning, so a strict allowlist-only mapper will silently drop them. Every event shares the same envelope (id, type, occurred_at, and a resource-specific data object); if you relay events into your own pipeline, keep unknown fields on the way through rather than mapping only the fields you currently use.

Build it with PostGate

One API for every social platform your customers care about — sandboxed, documented, and ready today.

Start building Read the docs