Designing resends, ordering, and idempotency

Webhook Resend Idempotency Ordering Retry
Who this article is for
For developers implementing the Webhook receiving side. It explains how to build a receiving side that behaves correctly in situations like "the same event arrived multiple times" or "an old event arrived after a new one".

Webhooks are a convenient mechanism, but the network is unstable and servers do go down. ReceiptRoller has a mechanism that automatically resends when delivery "didn't get through", but as a result, the same event may arrive two or more times and the order may be swapped. Design the receiving side on that premise.

Resend policy

If the receiving side does not return 2xx, ReceiptRoller attempts resends with exponential backoff.

Attempt Send timing
1stImmediate
2ndAfter 1 minute
3rdAfter 5 minutes
4thAfter 30 minutes
5thAfter 2 hours
6thAfter 6 hours
7thAfter 24 hours

In total, it resends up to 7 times over about 32 hours. If it still does not succeed, it is recorded as a dead letter, which you can check in the developer portal's "Webhook delivery history".

Responses subject to resend

  • 5xx (server error)
  • 429 (rate limited)
  • Timeout (no response within 10 seconds)
  • Connection error (DNS / TLS / TCP)

Responses not subject to resend

  • 2xx: treated as success, not resent
  • 3xx: redirects are not followed automatically (treated as failure, but not resent)
  • 4xx (including 401/410): treated as a permanent rejection by the receiving side, not resent
Note: if the receiving side "failed to process but wants a retry", return 5xx. Returning 4xx means no resend.

Order is not guaranteed

ReceiptRoller Webhooks do not guarantee delivery order. For example, situations like the following can occur.

  • receipt.refunded arrives before receipt.issued
  • product.updated arrives before product.created
  • The first delivery is resent, so an old event arrives after a new one

For business logic that needs to handle ordering, look at occurred_at (the event occurrence time) in the payload and reorder on the receiving side, or design so that you re-fetch the latest state via the API. It is safer to treat a Webhook not as "a notification of the latest state" but as "a trigger that a change occurred".

Implementing idempotency

Ensuring that the same event arriving multiple times does not change the result is called idempotency. ReceiptRoller attaches a unique event_id to each event (and an X-RR-Event-Id header), so use it as the key to avoid duplicate processing.

Pattern 1: Record processed IDs

async function handleWebhook(event) {
  // If already processed, ignore
  const exists = await db.processedEvents.findOne({ event_id: event.event_id });
  if (exists) return;

  // Business processing
  await processEvent(event);

  // Record (ideally within the same transaction as the business processing)
  await db.processedEvents.insert({
    event_id: event.event_id,
    received_at: new Date(),
  });
}

Set a TTL on the processed-events table and auto-delete old records to prevent bloat (given the 32-hour resend window, retain for at least 7 days).

Pattern 2: Write with UPSERT

If the business processing "brings a record to its latest state", using UPSERT (INSERT or UPDATE) makes it naturally idempotent.

// Receive an inventory event and UPSERT keyed on the product ID
await db.inventory.upsert({
  where: { product_id: event.data.product_id },
  update: { quantity: event.data.quantity, updated_at: event.occurred_at },
  create: { product_id: event.data.product_id, quantity: event.data.quantity },
});

With this pattern, you need to add an occurred_at comparison so that an old event arriving later does not overwrite the latest value.

Pattern 3: Determine by a natural key

If a business natural key (e.g. receipt_id) is available, using it for duplicate detection is also simple. Combining it with the event_id pattern makes it robust.

Handling out-of-order delivery

Typical measures for when multiple events for the same entity arrive out of order.

  • Overwrite decision by occurred_at: do not apply an event older than the last-updated time you hold on the receiving side
  • Version number: if the payload has a version, adopt only the larger value
  • State-transition check: ignore transitions that are business-impossible, such as "refunded → issued"
  • Re-fetch via API: on receiving a notification, re-fetch the latest state via the API (removes the need to think about order)

Handling dead letters

Events that fail all 7 resends are saved as "dead letters" and can be checked in the developer portal → the relevant endpoint → the "Delivery history" tab. Each record includes the following.

  • Event ID, type, timestamp
  • Each attempt's response code and body (first 1KB)
  • A "Redeliver" button

After fixing the receiving side, you can manually resend with the "Redeliver" button. The window is 30 days after delivery.

Common anti-patterns

A tempting implementation The problem
Because processing is heavy, wait until it completes before returning 2xx instead of enqueuing first The 10-second timeout causes a resend loop
Return 200 when business processing fails It is not resent, so the event is lost
Increment/decrement stock without idempotency Duplicate delivery throws off the stock count
Overwrite the latest flag without considering out-of-order delivery An old event tramples the new state
Not monitoring dead letters Days pass before you notice the failure

The recommended receiving pattern

  1. Verify the signature and timestamp
  2. Idempotency check with event_id (if already processed, immediately 200)
  3. Enqueue the payload into an internal queue (SQS / Cloud Tasks, etc.)
  4. Immediately return 200 (the goal is within 1 second up to here)
  5. Run the business processing in a queue worker (retries handled internally)

With this pattern, the receiving endpoint can always respond quickly, and business-processing failures do not lead to a resend loop.

Related guides

Published: 2026-04-27 Updated: 2026-07-05
Tags
API (22) OAuth (15) Android (10) iOS (9) Webhook (6) Troubleshooting (5) api (5) App registration (4) POS Integration (4) Reference (4)