ZyloPaydocs

Delivery, retries and ordering

How ZyloPay delivers events, retries failures for 3 days, disables failing endpoints, and why your handler must be idempotent.

What counts as delivered

A delivery succeeds when your endpoint answers any 2xx status within 10 seconds. Anything else is a failure: another status (including 3xx redirects, which are not followed), a timeout, a TLS or connection error, or an address that fails ZyloPay's network checks. ZyloPay reads at most the first 64 KB of your response and keeps the first 1 KB in the delivery log.

Answer fast. Verify the signature, store the event, answer 200, and do the work in a queue.

Retries and backoff

A failed delivery is retried with exponential backoff: each wait is three times the previous one, starting at 1 minute and capped at 12 hours, with ±25 % random jitter.

AttemptNominal wait before it
1immediate
21 min
33 min
49 min
527 min
681 min
7about 4 h
8 and later12 h

Retries continue for 3 days after the event, about 12 attempts in total. After that the delivery is marked failed. Each attempt is signed again with a fresh timestamp and carries ZyloPay-Delivery-Attempt.

Resends from the dashboard or POST /v1/webhook_deliveries/{id}/retry, and test events, are a single attempt with no automatic retry.

Automatic disabling

An endpoint that has not succeeded for 3 days, with at least 10 failed attempts in a row, is disabled automatically:

  • its status becomes disabled with disabled_reason: "failing";
  • ZyloPay notifies your dashboard;
  • deliveries already queued stay queued.

Fix the endpoint, then re-enable it (Enable in the dashboard, or PATCH /v1/webhook_endpoints/{id} with {"disabled": false}). Queued deliveries still within their 3-day window are then sent. For anything older, catch up from the events API.

You can also disable an endpoint yourself ({"disabled": true}). Events keep queuing and are delivered if you re-enable it within 3 days.

failing_since and last_success_at on the endpoint show its health. Watch them.

Ordering and duplicates

Deliveries are at least once and not ordered.

  • The same event can arrive more than once, for example when your 200 was lost on the way back. Every copy has the same event id (also in ZyloPay-Event-Id).
  • Events can arrive out of order. payment.succeeded can arrive before payment_intent.succeeded, and a retry of an old event can arrive after a newer one.

Make your handler idempotent:

async function handleEvent(event: ZyloPayEvent) {
  // 1. Deduplicate on the event ID (a unique index does the work).
  const inserted = await db.query(
    'INSERT INTO zylopay_events (id, type, created) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING',
    [event.id, event.type, event.created],
  );
  if (inserted.rowCount === 0) return; // already processed

  // 2. Apply state by status, never by arrival order.
  if (event.type === 'payment_intent.succeeded') {
    await markOrderPaid(event.data.object.id); // no-op if already paid
  }
}
  • Base decisions on the status in the object, not on the event type alone or on the order of arrival. Never move an order backwards, for example from paid to pending.
  • When order matters, re-read the object from the API (GET /v1/payment_intents/{id}). The API always has the current state.
  • Use created on the event to discard updates older than what you already stored.

Catching up after downtime

Events are kept for 30 days and can be listed at any time, whatever happened to their deliveries:

for await (const event of zp.events.list({ created_gte: lastProcessedAt })) {
  await handleEvent(event); // same idempotent handler
}

Run this after an outage, or on a schedule as a safety net. With an idempotent handler, replaying events you already processed is harmless.

Delivery log

Every delivery and attempt is logged: status, HTTP code, duration, error and the start of your response. Read it in Developers → Webhooks → Deliveries, or with GET /v1/webhook_endpoints/{id}/deliveries and GET /v1/webhook_deliveries/{id} (with attempt_log).

On this page