Skip to content

Retries & replay

When Topiic retries a failed webhook delivery, when it gives up, and how to recover.

A delivery succeeds when your endpoint returns any 2xx status within 10 seconds. Anything else — a 4xx, a 5xx, a timeout, a TLS error — is a failure, and Topiic schedules the next attempt.

AttemptDelay after previous
10 (immediate)
230 seconds
32 minutes
410 minutes
51 hour
66 hours
724 hours
824 hours

After attempt 8 fails, the delivery is marked Abandoned. The event still exists in Topiic’s history — you can replay it manually any time after fixing the receiver — but no further automatic attempts will happen.

Total automatic-retry window: ~55 hours.

  • 200 OK — what you should return.
  • 201, 202, 204, etc. — all treated as success.
  • Empty body, JSON body, anything — Topiic doesn’t read the body for routing.
  • Any non-2xx HTTP response (3xx included — we don’t follow redirects).
  • TCP/TLS errors (DNS failure, connection refused, certificate invalid).
  • Response not received within 10 seconds.
  • Connection closed mid-response.

Because the same event may be delivered up to 8 times, every webhook handler must be idempotent. The header to dedupe on is Topiic-Event-Id (mirrored as Topiic-Idempotency-Key).

The simplest pattern:

CREATE TABLE processed_webhooks (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

In your handler:

const begin = await db.beginTransaction();
try {
const inserted = await db.query(
'INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT DO NOTHING',
[eventId]
);
if (inserted.rowCount === 0) {
// Already processed - silent ack.
await begin.commit();
return res.status(200).end();
}
await applyEvent(event); // any side-effect, same transaction
await begin.commit();
res.status(200).end();
} catch (e) {
await begin.rollback();
res.status(500).end(); // let Topiic retry
}

The insert and the side-effect happen in the same transaction, so a crash between them rolls everything back and the next retry gets a clean shot.

If your webhook receiver is down for an hour, the first attempt fails, the 30-second retry fails, the 2-minute retry fails — and the 10-minute retry probably catches you on your way back up. You don’t have to do anything special; the backoff schedule absorbs short outages.

For longer outages (>24h on an event), you’ll see the delivery transition to Abandoned in the portal. After bringing your receiver back up:

  1. Filter the delivery log to Abandoned status.
  2. Click Replay on each one (or call the replay API in a loop).
  3. Each replay creates a fresh delivery chain — if it fails again, the backoff schedule starts over.

From the portal: Settings → API keys → <key> → Deliveries shows the delivery history with a per-row Replay button.

From the API:

POST /api/Webhooks/deliveries/{deliveryId}/replay?merchantId={merchantId}
Authorization: Bearer tpk_…

Returns 202 Accepted and enqueues a fresh Pending delivery for the same underlying event. The replay uses the current webhook URL configured on the API key, not the URL that was in effect when the original event was produced. So if you changed your URL, replays will go to the new one.

GET /api/Webhooks/deliveries?merchantId={merchantId}&apiKeyId={apiKeyId}&limit=100
Authorization: Bearer tpk_…

Returns the most-recent deliveries with attempt number, status, response code, and next-attempt time. Use this to build a “missed any?” reconciliation job — compare the events you’ve processed against the events Topiic delivered, and replay anything missing.

If you depend on a third-party that has its own rate limits, treat each webhook handler as the unit of work and queue any onward calls. Don’t make Topiic’s POST block on a slow downstream — return 200, ack the event, and let your own worker handle the downstream call with your own retries.