How Agatabo’s x-idempotency-key header works, when to use it, how to generate keys, and a complete TypeScript implementation with retry logic.
In financial software, the consequences of recording the same transaction twice can be severe — a member’s savings account shows double a deposit, a loan appears disbursed twice, or an expense is counted in two accounting periods. Agatabo prevents this class of problem through idempotency: a guarantee that submitting the same operation multiple times produces the same result as submitting it once.This page explains how idempotency works in Agatabo, which operations require it, how to generate keys correctly, and how to implement safe retry logic in your integration.
An operation is idempotent when executing it more than once has exactly the same effect as executing it once. For financial transactions, this matters most in scenarios where you cannot be certain whether a request was processed:Without idempotency protection:
User clicks "Record Deposit" → Network timeout — no response received → User clicks again, assuming the first attempt failed → Result: Deposit recorded TWICE ❌
With Agatabo’s idempotency:
User clicks "Record Deposit" (key: abc-123) → Network timeout → User clicks again (same key: abc-123) → Backend detects duplicate key → Result: Deposit recorded ONCE ✓
The x-idempotency-key header is Agatabo’s mechanism for this guarantee. You supply a unique string with every write request. If the same string is used again within your organization, Agatabo detects the duplicate and rejects the second request automatically.
Idempotency keys are scoped per organization. The same key string can be used by different organizations simultaneously without conflict. Within a single organization, however, every key must be unique — forever. A key used for a successful transaction can never be reused in that organization.
Your application is responsible for generating a unique key before sending each write request. The key must be unique within your organization — using the same key twice (for different transactions) will cause the second transaction to be rejected.Recommended approaches:
// Option 1: UUID v4 — the gold standard (cryptographically random, globally unique)import { v4 as uuidv4 } from 'uuid';const idempotencyKey = uuidv4();// Example: "550e8400-e29b-41d4-a716-446655440000"// Option 2: Timestamp + random suffix — good for debugging (human-readable timestamp)const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;// Example: "1686567890123-k3j4h5g6f"// Option 3: Operation-specific prefix — easiest to trace in logsconst idempotencyKey = `deposit-${userId}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;// Example: "deposit-user123-1686567890123-abc4567"
Rules for valid keys:
✅ Use UUID v4 — cryptographically random, zero collision probability
✅ Include the timestamp or operation type when useful for debugging
✅ Generate the key in your application before the request is sent
✅ Store the key in your component or request state while the request is in flight
✅ Reuse the same key if you are retrying the same failed request
✅ Generate a new key for every new, distinct operation
❌ Never use sequential integers or predictable patterns
❌ Never reuse a key from a previous successful operation for a different transaction
Keys are unique within a single organization. Two different organizations can independently use the key "abc123" without any conflict, because Agatabo’s uniqueness constraint includes the organization ID:
Organization A: idempotencyKey "abc123" → Deposit 10,000 RWF ✓Organization B: idempotencyKey "abc123" → Deposit 5,000 RWF ✓ (different org, no conflict)Organization A: idempotencyKey "abc123" → second request ❌ (duplicate within same org)
When Agatabo receives a request with an idempotency key it has already seen (for a successful transaction), it rejects the duplicate immediately:First request (success):
POST /savingsx-idempotency-key: abc-123HTTP/1.1 201 Created{ "message": "Savings recorded successfully", "data": { ... }}
Second request (duplicate — same key):
POST /savingsx-idempotency-key: abc-123HTTP/1.1 409 Conflict{ "message": "Duplicate transaction detected", "error": "A transaction with this idempotency key already exists"}
Importantly, if the first request failed due to a validation error (and was therefore never saved), the key is not consumed — you can retry with the same key after fixing the validation problem.
A deposit request is sent, the network times out before a response arrives, and the client does not know whether the server processed the request.Correct approach: Retry with the same idempotency key.
const idempotencyKey = crypto.randomUUID();try { const result = await createDeposit({ amount: 10000, idempotencyKey }); return result;} catch (error) { if (isNetworkTimeout(error)) { // The server may or may not have processed this. // Retrying with the same key is safe — if it was processed, the retry // is rejected as a duplicate. If it was not processed, it succeeds now. const result = await createDeposit({ amount: 10000, idempotencyKey }); return result; } throw error;}
A treasurer double-clicks the “Record Payment” button. Two requests race to the server.Correct approach: Disable the button during the request and use idempotency as a backstop.
let requestInProgress = false;const idempotencyKey = crypto.randomUUID();async function handleSubmit() { if (requestInProgress) return; // UI-level guard — prevents the second click requestInProgress = true; try { await recordPayment({ idempotencyKey }); } finally { requestInProgress = false; }}// Even if both requests reach the server, only the first succeeds.// The second is rejected with a 409 duplicate error.
A loan creation fails because the requested amount exceeds the allowed maximum. The user corrects the amount and resubmits.Correct approach: Retry with the same key — the original request failed and was never saved, so the key has not been consumed.
const idempotencyKey = crypto.randomUUID();try { await createLoan({ principalAmount: -5000, idempotencyKey }); // Invalid amount} catch (error) { if (error.status === 400) { // Validation error — loan was NOT created. // Safe to retry with the same key after fixing the input. await createLoan({ principalAmount: 5000, idempotencyKey }); // Fixed ✓ }}
The following example demonstrates a production-ready TransactionService class with idempotency key generation, duplicate detection, and exponential backoff retry logic.
import { v4 as uuidv4 } from 'uuid';interface DepositData { organizationUserId: string; amount: number; transactionDate: string; paymentMethod: string; description?: string;}interface ApiError { status: number; message: string;}class TransactionService { private organizationId: string; private baseUrl: string; private authToken: string; constructor(organizationId: string, baseUrl: string, authToken: string) { this.organizationId = organizationId; this.baseUrl = baseUrl; this.authToken = authToken; } /** * Record a deposit with full idempotency and retry protection. * Generates a fresh key for each NEW deposit call. * Pass an existing key explicitly when retrying the same operation. */ async createDeposit( data: DepositData, idempotencyKey: string = uuidv4() ): Promise<unknown> { return this.withRetry( () => this.postDeposit(data, idempotencyKey), idempotencyKey ); } private async postDeposit( data: DepositData, idempotencyKey: string ): Promise<unknown> { const response = await fetch(`${this.baseUrl}/savings`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.authToken}`, 'x-organization-id': this.organizationId, 'x-idempotency-key': idempotencyKey, }, body: JSON.stringify(data), }); const body = await response.json(); if (!response.ok) { const error = body as ApiError; error.status = response.status; throw error; } return body; } /** * Retry with exponential backoff for network errors and 5xx responses. * Reuses the same idempotency key on every retry attempt. */ private async withRetry( operation: () => Promise<unknown>, idempotencyKey: string, maxAttempts: number = 3 ): Promise<unknown> { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await operation(); } catch (error) { const apiError = error as ApiError; // Duplicate detected — check if the original succeeded if (this.isDuplicateError(apiError)) { console.warn( `Duplicate key detected for key ${idempotencyKey}. ` + `Checking if original transaction succeeded...` ); // In a real implementation, query the API to find the original transaction // and return it instead of throwing an error. throw new Error( 'Transaction already recorded. Check your transaction history.' ); } // Validation error — do not retry (the key is still valid, but the // caller must fix the data before retrying) if (apiError.status === 400 && !this.isDuplicateError(apiError)) { throw error; } // Network error or 5xx — retry with the same key if (this.isRetryable(apiError) && attempt < maxAttempts) { const delayMs = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s console.log( `Attempt ${attempt} failed. Retrying in ${delayMs}ms ` + `with key ${idempotencyKey}...` ); await this.delay(delayMs); continue; } throw error; } } throw new Error(`Operation failed after ${maxAttempts} attempts`); } private isDuplicateError(error: ApiError): boolean { return ( error.status === 409 || (error.status === 400 && (error.message?.toLowerCase().includes('duplicate') || error.message?.toLowerCase().includes('idempotency'))) ); } private isRetryable(error: ApiError): boolean { // Retry on network errors (no status) and server errors (5xx) return !error.status || error.status >= 500; } private delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }}// Usage exampleconst service = new TransactionService( 'org-abc123', 'https://api.agatabo.com', 'your-auth-token');// New deposit — key generated automaticallyawait service.createDeposit({ organizationUserId: 'orguser-123', amount: 10000, transactionDate: '2026-07-01', paymentMethod: 'CASH', description: 'Monthly contribution',});// Retrying a specific operation — pass the original key explicitlyconst existingKey = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';await service.createDeposit( { organizationUserId: 'orguser-123', amount: 10000, transactionDate: '2026-07-01', paymentMethod: 'CASH', }, existingKey // Reuse the original key — safe on retry);