Feedjoltdocs
DevelopersWebhooks

Webhook retries and idempotency

How Feedjolt handles webhook delivery failures today: one attempt, delivery logs, manual replay, and how to build idempotent, out-of-order-safe handlers.

Delivery is fire-and-log: we POST your payload once. If your endpoint returns non-2xx or times out, the failure is recorded and an admin can replay the delivery from the dashboard.

What we do today

  • One delivery attempt per event, with a 10-second timeout.
  • Every attempt is logged in Settings -> Webhooks -> [endpoint] -> Deliveries - status code, response body (capped at 4 KB), success/failure flag.
  • Manual replay: every log row has a Replay button. Clicking re-enqueues the same payload. Useful for "I fixed the bug, reprocess yesterday's events".
  • Endpoints can be deactivated (is_active = false) manually in the dashboard.

What we don't do (yet)

  • No automatic retry schedule. A single transient failure means you don't see that event without a manual replay.
  • No automatic disable of unhealthy endpoints.
  • No log retention policy - logs are kept indefinitely (this will likely change).
  • No replay protection in the signature itself. See Signing -> Replay protection.

These are on the roadmap. Until they ship, the safe defaults are below.

Make your handler idempotent

The same logical event may be replayed manually. Use the payload's id field (when present - it's stable across replays of the same event) as a dedupe key:

async function handle(event: any) {
  if (!event.id) {
    // older events without an id - best-effort process
    await processEvent(event);
    return;
  }
  const seen = await redis.get(`feedjolt:event:${event.id}`);
  if (seen) return;
  await processEvent(event);
  await redis.set(`feedjolt:event:${event.id}`, "1", "EX", 30 * 24 * 60 * 60);
}

A 30-day TTL gives you generous margin for manual replays of older events.

For database-backed processing, a UNIQUE constraint on event_id achieves the same with stronger guarantees:

CREATE TABLE feedjolt_events (
  event_id TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  payload JSONB NOT NULL
);

INSERT INTO feedjolt_events (event_id, payload)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING;

Respond fast, queue heavy work

We have a 10-second timeout. A handler that takes 8 seconds works; one that takes 12 gets recorded as a failure. Push expensive work to a background queue and return 200 immediately.

Don't 5xx on application bugs

If your DB is down, a 503 is fair. If the event is malformed (per your application rules), respond 200 and log it - there's no automatic retry to keep the broken event off your queue.

Watch the delivery log

Until automatic disable lands, the delivery log is your alarm. A simple weekly job that scans for failure rate per endpoint will catch issues before they pile up. We expose this via the API:

GET /api/v1/webhooks/{endpoint_id}/deliveries?success=false

Out-of-order delivery

We don't guarantee event order. Two status.changed events for the same post may arrive in either order if the underlying changes happened seconds apart.

Ignore stale events using the timestamp on the post object inside the payload:

async function handleStatusChanged(event: any) {
  const post = await db.posts.find(event.post.id);
  if (post.last_status_event_at && new Date(event.post.updated_at) < post.last_status_event_at) {
    return;
  }
  await db.posts.update(event.post.id, {
    status: event.to_status.name,
    last_status_event_at: event.post.updated_at
  });
}

On this page