Delivery & retries

What happens after a publish is accepted: the retry schedule, delivery statuses, the dead-letter queue, replay and bulk replay, and per-attempt logs.

After Repost accepts a message, each subscribed endpoint gets its own delivery, with its own attempt history, schedule, and status. One customer's dead endpoint never affects another's, and a message to three endpoints is three independent deliveries.

What counts as delivered

A delivery attempt is an HTTPS POST of the signed envelope to the endpoint URL. A 2xx response completes the delivery. Anything else is a failure: 4xx, 5xx, timeouts, connection errors, and also 3xx, because redirects are never followed. Following one would replay a signature to a location the customer didn't register.

The body Repost sends is the body you published, byte for byte. It is not re-serialized on the way through, and a replay or a redelivery sends the same bytes again, so a receiver that hashes or dedupes on the raw body sees the same message every time.

The retry schedule

A failing delivery is tried 11 times: the first attempt plus ten retries. Each retry waits a base delay plus a jitter of between half and one base delay, so retries don't arrive in herds at a recovering endpoint. The wait is measured from when the previous attempt actually ran, not from when it was scheduled, so an attempt that ran late still gets a full gap before the next one.

RetryBase delayActual wait after the previous attempt
15 seconds7.5 to 10 seconds
230 seconds45 to 60 seconds
32 minutes3 to 4 minutes
410 minutes15 to 20 minutes
530 minutes45 to 60 minutes
61 hour1.5 to 2 hours
72 hours3 to 4 hours
84 hours6 to 8 hours
98 hours12 to 16 hours
108 hours12 to 16 hours

End to end that is between about 36 and 47 hours. If the endpoint responds with a Retry-After header longer than the scheduled wait, Repost honours it, counted from the moment the endpoint answered, up to a cap of one hour. When the last retry fails, the delivery moves to the dead-letter queue with its full attempt history and the reason exhausted.

An endpoint can carry its own schedule instead: set retryPolicy to {"version": 1, "delaysMs": [...]} when creating or updating it, with 1 to 20 base delays of 1 second to 24 hours each. The same jitter rule applies to each delay.

Endpoint state changes cut the schedule short instead of wasting attempts. Pausing an endpoint holds its deliveries, including retries that come due while it is paused, until you resume it. Disabling one fails its deliveries terminally as endpoint_disabled, and deleting one fails them, held ones included, as endpoint_gone. Updating an endpoint's URL or headers applies to pending retries too.

Delivery statuses

StatusMeaning
pendingCreated, first attempt not yet made. This includes a delivery held by a pause: a message published while its endpoint is paused gets a delivery straight away; the dashboard labels it held · endpoint paused, the portal lists it as pending. A pending delivery that is being held says so in holdReason: endpoint_paused while the endpoint is stopped, catch_up_deferred once it has resumed with the missed window kept rather than replayed. A pending delivery with holdReason: null is simply waiting for its first attempt.
retryingAt least one attempt failed and more are scheduled.
successAn attempt received a 2xx.
failedEnded without exhausting retries. failureReason says why, see below.
dlqParked in the dead-letter queue: the retries were exhausted (exhausted), or the delivery was lost.

A failed or dlq delivery carries a failureReason:

failureReasonWhat happened
exhaustedEvery retry failed.
endpoint_goneThe endpoint was deleted while the delivery was pending or retrying.
endpoint_disabledThe endpoint was disabled while the delivery was pending or retrying.
skippedThe endpoint was resumed with catchUp: skip, so the messages held during the pause were forgotten.
job_cancelledA bulk replay was cancelled before this generation was attempted.
lostA pending delivery, or a retry that was due, went 48 hours without an attempt. It is parked in the dead-letter queue and can be replayed.

Deliveries can be listed and inspected over the API, in the dashboard's Messages view, or by the customer in their portal:

curl "https://api.repost.sh/v1/deliveries?status=dlq&limit=50" \
  -H "Authorization: Bearer $REPOST_TOKEN"
 
curl "https://api.repost.sh/v1/messages/msg_01JZX.../deliveries" \
  -H "Authorization: Bearer $REPOST_TOKEN"

The message route lists every generation of every delivery, oldest first. The status=dlq list, the dead-letter queue counts, and the portal's DLQ leave out any delivery that a later replay generation has superseded, so a replayed dead letter stops counting as one the moment its replay is queued.

Attempt logs

Every attempt is recorded: the response status, latency, an error class such as TIMEOUT or CONNECTION_FAILED, and a bounded excerpt of the response body and headers (up to 4 KiB each). Sensitive request headers are stored as [redacted]; only content-type, user-agent, webhook-id, and webhook-timestamp are kept verbatim, and Set-Cookie response headers are dropped. There is enough to debug a failing endpoint without secrets ending up in logs.

The delivery detail in the dashboard and the portal shows the request and the response of each attempt, and the attempt log streams live, so a retry or a replay shows up as it happens. Attempts are labelled by generation and attempt number: #0, #1, #2 for the original delivery, g1 #0 for the first attempt of its first replay, and so on.

Customers see the same attempt history for their own endpoints, live, in the portal.

Replay and redeliver

A delivery in the dead-letter queue is not lost. Replay it with one call, or re-send an already-delivered message to a specific endpoint, for example after a customer restores from a backup:

# Replay a delivery
curl -X POST https://api.repost.sh/v1/deliveries/{deliveryId}/replay \
  -H "Authorization: Bearer $REPOST_TOKEN"
 
# Redeliver a message to one endpoint
curl -X POST https://api.repost.sh/v1/messages/{messageId}/redeliver \
  -H "Authorization: Bearer $REPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"endpointId": "..."}'

Both answer 202 with {"queued": true, "deliveryId": "dl_..."}. The new delivery is a new generation of the same message and endpoint: it re-enters the normal pipeline with the published bytes, signed with the endpoint's current secrets, and its attempts are recorded like any other. Delivery ids are deterministic, so the same message, endpoint and generation always name the same delivery. The new generation supersedes the one it replays: lists and the dead-letter queue count show the newest generation as the outcome for that message and endpoint, and the older one reads as superseded. Redelivering a message to an endpoint does the same for that endpoint's latest generation.

Replay is guarded. A second replay or redelivery of the same message and endpoint inside 10 minutes sends nothing and answers 409 with reason: "duplicate" and the deliveryId of the replay already queued; the dashboard, the portal and the CLI say the same. The other refusals:

RefusalResponse
The delivery is in flight (pending or retrying)409, reason: "in_flight". Wait for a terminal state.
The endpoint is paused409, reason: "in_flight", and the message names the pause. Resume the endpoint; the held delivery goes out with the resume.
The endpoint was deleted404.
The endpoint belongs to a different customer than the message (redeliver)409, reason: "endpoint_customer_mismatch". A message is only delivered to its own customer's endpoints.
The message payload is no longer retained409, reason: "source_payload_unavailable". Repost refuses rather than sending an empty body.

Customers can replay their own dead-lettered deliveries from the portal.

Bulk replay

To replay many deliveries at once, the dashboard, the portal, the CLI (repost send replays) and the agent operations run a replay job against one endpoint. The job goes through the same steps everywhere:

  1. Estimate. Pick a selection: failed and dead-lettered deliveries in a time window (the portal's Recover failed messages), a status filter, or a query. Repost counts what matches.
  2. Prepare. The selection is frozen into a manifest with a fixed upper bound and total, so what you confirm is what runs.
  3. Confirm. Confirmation is bound to that manifest; a selection that changed since is rejected before anything is sent.
  4. Run. Generations are handed to the endpoint at the rate you chose. An item reads queued until its attempt lands, so the job can report every item sent while the endpoint is still receiving.
  5. Settle. Once the endpoint has answered every generation, the job reports how many were delivered, dead-lettered and failed. The outcomes add up to the manifest total.

Cancelling a job stops dispatch. Generations already handed to the endpoint finish normally; the ones never attempted read failed · job_cancelled, and a cancelled generation supersedes nothing, so a delivery that had already succeeded keeps success as its outcome. An endpoint runs one job at a time, and pausing the endpoint pauses its job.

Receivers should treat delivery as at-least-once and dedupe on the webhook-id header. Retries after a lost 2xx, replays, and redeliveries can all deliver the same message more than once, and webhook-id is stable across all of them.

Continue