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.
Recommended client behavior
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=falseOut-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
});
}Webhook signing
Verify Feedjolt webhooks with HMAC-SHA256 over the raw body. Header format, the verification algorithm, code for Node, Python, Ruby, and Go, plus pitfalls.
API
An overview of the Feedjolt REST API: JSON over HTTPS with bearer-token auth. Covers the base URL, interactive reference, conventions, and v1 versioning.
