TeronaPay

Idempotency

Networks fail. Clients retry. The same logical operation can hit our API multiple times. Idempotency is the contract that says: same request, same result.

How it works

Send an Idempotency-Key header on any mutating request:

curl -X POST https://api.teronapay.com/v1/payments \
  -u "$KEY:$SECRET" \
  -H "Idempotency-Key: order-1234-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

We store the response keyed by (merchant_id, idempotency_key) for 24 hours. A retry with the same key returns the original response — same status code, same body. We add the Idempotent-Replayed: true response header so you know it was a replay.

What's safe to retry

EndpointIdempotent?Notes
POST /v1/paymentsDedupes on Idempotency-Key; without one, dedupes on (merchant_id, reference)
POST /v1/payments/{id}/refundsSend a key per refund slice. Without one, you can create multiple partial refunds — that's the Stripe semantic
POST /v1/payoutsSame shape as payments
POST /v1/webhooksIdempotent on Idempotency-Key; without one, a duplicate URL is allowed
POST /v1/webhooks/{id}/rotate-secretEach rotation overwrites the previous next-secret. Send only when you mean it
POST /v1/webhooks/{id}/deliveries/{did}/replayReplay is naturally idempotent — same event_id

Generating a good key

Use a value that's unique per logical operation but stable across retries:

  • order-1234
  • subscription-456-cycle-2026-05
  • ✓ A UUID generated client-side and stored alongside the order
  • time.time() (changes per retry)
  • uuid.uuid4() regenerated per retry (same problem)

If your client crashes after sending the request but before getting the response, you should be able to safely retry by re-sending the same key. The simplest pattern: persist the key alongside the order record before sending the request.

What if I forget the key?

Most endpoints have a DB-layer guard too:

  • Payments: UNIQUE (merchant_id, reference). A retry with the same reference returns the existing payment with Idempotent-Replayed: true on the body.
  • Refunds: no DB guard — sending two refund POSTs without a key creates two slices. Use partial refunds intentionally.
  • Webhooks: no DB guard on duplicate URLs.

Retry storms

Our default rate limit is 600 requests/minute per merchant. If your retry loop hits it, you'll get 429 rate_limited with a Retry-After header. Honor the header.

For high-volume merchants, contact sales for a custom limit.

Webhook idempotency (on your side)

Webhook delivery is at-least-once. Your handler must be idempotent on the event_id field in the delivered body. Pattern:

async function handleWebhook(event: NowpesaEvent) {
  const existing = await db.processedEvents.findUnique({ where: { id: event.id } });
  if (existing) return; // already handled
  await db.processedEvents.create({ data: { id: event.id, processedAt: new Date() } });
  // ...do the work
}

We replay failed deliveries up to 6 times. After Promote (or admin replay), the same event_id may be delivered again. Be ready.

Idempotency · TeronaPay docs