AnswerLine Start free

, Engineering · Tutorials

Webhook receiver design: retries, duplicates and ordering

A reliable webhook receiver never loses a result and never processes one twice, whatever happens to your endpoint, your database or the network. Sender retries are bounded: at most 10 attempts over roughly 21 to 43 minutes. The pattern below handles crashes, deploys, duplicates and out-of-order arrivals, and a small reconciliation job covers outages longer than the retry window.

It assumes you already verify webhook signatures and covers everything after verification.

The delivery contract you are designing for

Every design decision below follows from these documented properties of deliveries (see webhooks):

Property Value Consequence for the receiver
Body The GET /v1/async/task/{taskId} JSON for the finished task No event types to route; branch on task.status
Final statuses delivered COMPLETED or FAILED Handle both; FAILED tasks are not charged
Success 2xx within 15 seconds Acknowledge fast; do slow work elsewhere
Failure Any other status, a redirect, a timeout, a connection error 4xx is retried too
Attempts Up to 10, exponential backoff with jitter Last attempt about 21 to 43 minutes after the first
Target https URL resolving to a public address A URL that doesn’t qualify is not attempted again
Duplicates Possible (lost responses, redeliveries) Deduplicate by task.id
Order Not guaranteed; deliveries are concurrent Order by task data, not arrival
Test deliveries Signed, marked "test": true Acknowledge and ignore
Delivery log Pending, delivered, dead; kept 7 days Your audit trail for failures

The retry schedule

Attempt 1 happens when the task finishes. Before each later attempt the service waits a base delay that starts at 5 seconds and doubles, and each wait is shortened by a random amount of up to half. The cumulative figures below ignore the time each attempt itself takes (up to 15 seconds).

Attempt Base wait before it Earliest after attempt 1 Latest after attempt 1
2 5 s 2.5 s 5 s
3 10 s 7.5 s 15 s
4 20 s 17.5 s 35 s
5 40 s 37.5 s 75 s
6 80 s 77.5 s 155 s
7 160 s 157.5 s 315 s
8 320 s 317.5 s 635 s (about 10.6 min)
9 640 s 637.5 s 1,275 s (about 21 min)
10 1,280 s 1,277.5 s (about 21 min) 2,555 s (about 43 min)

A restart or deploy that takes a minute or two is covered by retries. An outage of more than about 21 minutes can exhaust a delivery’s attempts, so plan for that case explicitly.

The durable pattern: verify, write, acknowledge, process

A receiver split into a fast handler and a slow worker The HTTP handler does four things and stops: it verifies the signature over the raw body, deduplicates on task.id, writes the delivery to an inbox row, and answers 2xx within the 15-second limit. The inbox table, keyed by task_id, is the durable boundary. A worker reads unprocessed rows and does the real work afterwards, and a reconciler fetches any task whose delivery never arrived with GET on the task endpoint. the HTTP handler, and nothing more raw body, HMAC on task.id inbox row 2xx within 15 s inbox table primary key task_id, body jsonb reconciler GET /v1/async/task/{taskId} worker reads unprocessed rows verify dedupe enqueue acknowledge any non-2xx or timeout is retried, at most 10 attempts

The inbox row is written before the 2xx goes out, so there is no moment at which a delivery has been acknowledged but not stored.

Split the receiver into two parts with a durable store between them.

  1. The HTTP handler verifies the signature, drops test deliveries, writes the raw delivery to an inbox table keyed by task.id, and returns 2xx. Nothing else. It should answer in milliseconds, far inside the 15-second limit.
  2. A worker reads unprocessed inbox rows, does the real work (parsing results, updating dashboards, calling other services), and marks each row processed.

Why this shape:

The inbox table (Postgres)

create table webhook_inbox (
  task_id      text primary key,
  body         jsonb not null,
  received_at  timestamptz not null default now(),
  processed_at timestamptz
);

create index webhook_inbox_unprocessed on webhook_inbox (received_at) where processed_at is null;

The handler (Flask and psycopg)

verify_signature is the stdlib function from the signature verification post.

import json
import os

from flask import Flask, request
from psycopg_pool import ConnectionPool

from verify import verify_signature

app = Flask(__name__)
pool = ConnectionPool(os.environ["DATABASE_URL"])
SECRET = os.environ["WEBHOOK_SECRET"]


@app.post("/hooks/answers")
def answers():
    raw = request.get_data()
    if not verify_signature(raw, request.headers.get("Webhook-Signature"), SECRET):
        return "invalid signature", 400
    delivery = json.loads(raw)
    if delivery.get("test"):
        return "", 204  # verified sample from the dashboard: acknowledge, never store
    with pool.connection() as conn:  # commits when the block exits
        conn.execute(
            "insert into webhook_inbox (task_id, body) values (%s, %s::jsonb) on conflict (task_id) do nothing",
            (delivery["task"]["id"], raw.decode()),
        )
    return "", 204

A duplicate returns 204 like the first delivery; answering it with an error would trigger more retries of something you already have.

The worker

from psycopg.types.json import Jsonb


def process_batch(limit: int = 100) -> int:
    with pool.connection() as conn:
        rows = conn.execute(
            """select task_id, body from webhook_inbox
               where processed_at is null
               order by received_at
               limit %s
               for update skip locked""",
            (limit,),
        ).fetchall()
        for task_id, delivery in rows:
            task = delivery["task"]
            conn.execute(
                """insert into answers (task_id, job_key, status, created_at, response)
                   values (%s, %s, %s, %s, %s)
                   on conflict (task_id) do nothing""",
                (task_id, task["idempotencyKey"], task["status"], task["createdAt"], Jsonb(delivery["response"])),
            )
            conn.execute("update webhook_inbox set processed_at = now() where task_id = %s", (task_id,))
    return len(rows)

for update skip locked lets several workers run in parallel without claiming the same rows. Each batch commits as one transaction, so a crash mid-batch leaves every row in it unprocessed for the next run. If one malformed row can make processing raise, record the error on that row and move on, or a single bad row will block its whole batch forever.

For a COMPLETED task, response.result holds the engine’s structured answer. For a FAILED task, response holds the error; store it as-is and keep it out of your metrics.

Deduplication: use task.id, not Webhook-Id

Every delivery carries a Webhook-Id header, stable across retries and redeliveries. It is tempting as a deduplication key, but it is not covered by the signature. task.id is inside the signed body, identifies the unit of work, and is what you poll with, so it is the key for the inbox, the results table and reconciliation.

Deduplication also neutralizes replays. A captured request replayed within the signature’s 300-second tolerance still verifies; with the inbox keyed by task.id it inserts nothing.

Out-of-order results

Deliveries for different tasks are sent concurrently and retried independently. A task that finished first can be delivered last if its first attempt failed. Three rules keep that from corrupting your data:

  1. Never use arrival time as event time. Use task.createdAt for when you asked, and your own run key for which run a result belongs to.
  2. Carry your job identity in the task. The body includes task.idempotencyKey. If you derive keys from meaning, as idempotency keys explained recommends (prompt, market, day), the delivery tells the worker which logical job it answers without a lookup. A task created without a key has a null idempotencyKey, so always send one.
  3. Resolve conflicts deterministically. If one logical job ends up with more than one task, for example because you resubmitted after a failure under a new key, pick the winner by rule rather than by whichever arrived last. A reasonable rule: the most recently created COMPLETED task wins, and a FAILED task never replaces a COMPLETED one.

If a resubmitted task’s key is the original key plus a suffix (-r1, -r2), store the base key as job_key. The reporting query then reads:

select distinct on (job_key) *
from answers
where status = 'COMPLETED'
order by job_key, created_at desc;

Redelivery and reconciliation

A delivery that runs out of attempts, or targets a URL that isn’t https or doesn’t resolve to a public address, is marked dead. You have two recovery paths.

Redelivery from the dashboard

The Webhooks page lists deliveries, filterable by pending, delivered and dead, with attempts, the last status your endpoint answered, the last error, and timing. Owners can redeliver a delivered or dead delivery: it is sent again at once with a fresh set of attempts, keeps its Webhook-Id, and is signed with the secrets valid when it is sent. A pending delivery cannot be redelivered (it is still retrying on its own), nor can one whose task is no longer kept. Redeliveries are limited per account per hour, so this suits a handful of failures.

Reconciliation through the API

For a longer outage, poll the tasks you know about. GET /v1/async/task/{taskId} returns task, credits and, once the task has finished, response, the same body a delivery carries. Feed final ones into the same inbox and the worker handles them identically.

This requires recording each task id when you create the task:

import os
import time

import requests

API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['ANSWERLINE_API_KEY']}"}


def reconcile(limit: int = 500) -> None:
    with pool.connection() as conn:
        rows = conn.execute(
            """select s.task_id from submitted_tasks s
               left join webhook_inbox i on i.task_id = s.task_id
               where i.task_id is null and s.created_at < now() - interval '1 hour'
               order by s.created_at
               limit %s""",
            (limit,),
        ).fetchall()
    for (task_id,) in rows:
        res = requests.get(f"{API}/v1/async/task/{task_id}", headers=HEADERS, timeout=30)
        if res.status_code == 429:
            time.sleep(1)  # not admitted; pick the rest up next run
            continue
        if res.status_code == 404:
            continue  # log it: the task is unknown or no longer kept
        res.raise_for_status()
        if res.json()["task"]["status"] not in ("COMPLETED", "FAILED"):
            continue  # still queued or running; a later run or a delivery will catch it
        with pool.connection() as conn:
            conn.execute(
                "insert into webhook_inbox (task_id, body) values (%s, %s::jsonb) on conflict (task_id) do nothing",
                (task_id, res.text),
            )

The one-hour threshold is illustrative. Choose it from the lifecycle: a task has 4 hours from creation to start (after that it fails uncharged with QUEUE_WAIT_EXCEEDED) and five minutes from its first start to finish, and its delivery retries run for up to about 43 minutes after that. A task with no stored result well past that horizon needs investigation, not more waiting. Treat only COMPLETED and FAILED as final; a task can go from PROCESSING back to QUEUED.

The job pauses on 429 and runs in bounded batches. GET /v1/async/status returns queuedTasks and processingTasks, so after an outage you can confirm no tasks are still pending before declaring reconciliation done.

A note on task creation: if a create request times out and your retry with the same idempotencyKey gets 409 RESOURCE_CONFLICT, the task exists but you may not have its id. The delivery body carries task.idempotencyKey, so match on the key when it arrives and fill in the id.

Planned maintenance

For scheduled downtime, you have better options than relying on retries:

Choosing status codes

Because every non-2xx is retried, status codes are instructions to the sender:

Situation Answer Why
Verified, stored (first time) 2xx Done
Verified duplicate 2xx You already have it
Verified test delivery 2xx Acknowledge, don’t store
Signature invalid 400 or 401 Retries give you time to fix a wrong secret; alert on it
Inbox write failed (database down) 5xx You want the retry
Verified but unrecognized content 2xx, and log A retry would deliver the same body again

A spike in signature failures has two usual causes: a rotation deployed out of order, or someone probing the URL. The delivery log distinguishes them. If the dashboard shows your endpoint answering 400 to real deliveries, it is your configuration.

Failure modes to test

Run each of these against staging before relying on the receiver:

Checklist

For the trade-offs between webhooks, polling and synchronous calls, read sync calls, async tasks or webhooks, and for handling API errors on the submitting side, handle API errors like a pipeline. The task lifecycle is in async tasks and the delivery details are in webhooks.

Questions

How many times is a failed webhook delivery retried?

Up to 10 attempts in all. The wait before the second attempt is 5 seconds and doubles each time up to 1,280 seconds, each wait shortened by a random amount of up to half, so the last attempt comes about 21 to 43 minutes after the first.

What counts as a successful webhook delivery?

A 2xx status within 15 seconds. Any other status, including redirects and 4xx, a timeout or a connection error counts as a failed attempt and is retried.

Can webhook deliveries arrive twice or out of order?

Yes. Deliveries are sent concurrently and retried independently, and the same delivery can arrive more than once, for example when your response is lost or after a redelivery. Deduplicate by task.id and never rely on arrival order.

What happens if my endpoint is down for longer than the retry window?

The delivery becomes dead. Owners can redeliver it from the Webhooks page of the dashboard, which is limited per account per hour, or you can fetch each missing result with GET /v1/async/task/{taskId}.

Should a webhook receiver return 4xx for events it doesn't want?

No. Any non-2xx is retried, so a verified delivery you intend to ignore, such as a dashboard test delivery, should get a 2xx. Reserve non-2xx for signature failures and genuine failures you want retried.

How long is the webhook delivery log kept?

Delivered and dead deliveries are kept in the dashboard's delivery log for 7 days. Pending deliveries are listed while they wait for their next attempt.

Try it on your own prompts

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading