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
| Endpoint | Idempotent? | Notes |
|---|---|---|
POST /v1/payments | ✓ | Dedupes on Idempotency-Key; without one, dedupes on (merchant_id, reference) |
POST /v1/payments/{id}/refunds | ✓ | Send a key per refund slice. Without one, you can create multiple partial refunds — that's the Stripe semantic |
POST /v1/payouts | ✓ | Same shape as payments |
POST /v1/webhooks | ✓ | Idempotent on Idempotency-Key; without one, a duplicate URL is allowed |
POST /v1/webhooks/{id}/rotate-secret | ✗ | Each rotation overwrites the previous next-secret. Send only when you mean it |
POST /v1/webhooks/{id}/deliveries/{did}/replay | ✓ | Replay 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 withIdempotent-Replayed: trueon 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.
