Skip to main content
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.

What Idempotency Is and Why It Matters

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.

Which Operations Require the Header

Operations That Require x-idempotency-key

Include the header on every request that creates or modifies financial data:
OperationEndpoint
Record a depositPOST /savings
Record a withdrawalWithdrawal operation
Create and disburse a loanPOST /loans
Record a loan paymentPOST /loans/{loanId}/repay
Record an expensePOST /expenses
Create a fixed assetPOST /assets
Top up a reservePOST /reserve-allocations
Release a reservePOST /reserve-allocations/{id}/release
Create a dividend poolPOST /dividends/pools
Distribute a dividend poolPOST /dividends/pools/{id}/distribute
Close an accounting periodPOST /period-closing/close
Undo a period closePOST /period-closing/undo

Operations That Do NOT Require x-idempotency-key

  • All GET requests (read-only; safe to repeat by definition)
  • Report generation
  • Viewing member profiles, loan schedules, or transaction history

Generating Idempotency Keys

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 logs
const 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

Idempotency Scope

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)

Duplicate Detection Behavior

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 /savings
x-idempotency-key: abc-123

HTTP/1.1 201 Created
{
  "message": "Savings recorded successfully",
  "data": { ... }
}
Second request (duplicate — same key):
POST /savings
x-idempotency-key: abc-123

HTTP/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.

Common Scenarios

Scenario 1: Network Timeout

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;
}

Scenario 2: Accidental Double-Click

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.

Scenario 3: Retrying After a Validation 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 ✓
  }
}

Error Handling

Missing Key Error

POST /savings
# x-idempotency-key header omitted

HTTP/1.1 400 Bad Request
{
  "message": "x-idempotency-key header is required"
}
Fix: Add x-idempotency-key: <uuid> to your request headers. This is required for every mutation endpoint.

Duplicate Key Error

POST /savings
x-idempotency-key: already-used-key

HTTP/1.1 409 Conflict
{
  "message": "Duplicate transaction detected",
  "error": "Unique constraint violation on idempotencyKey"
}
Decision tree:
  1. Check whether the original transaction was recorded (query the relevant resource).
  2. If it was recorded: Do not retry. Show the user a success confirmation.
  3. If it was not recorded (original failed with a validation error): Retry with the same key after fixing the issue.
  4. If you want a new, separate transaction: Generate a new key.

Complete TypeScript Implementation

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 example
const service = new TransactionService(
  'org-abc123',
  'https://api.agatabo.com',
  'your-auth-token'
);

// New deposit — key generated automatically
await service.createDeposit({
  organizationUserId: 'orguser-123',
  amount: 10000,
  transactionDate: '2026-07-01',
  paymentMethod: 'CASH',
  description: 'Monthly contribution',
});

// Retrying a specific operation — pass the original key explicitly
const 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
);

Best Practices Checklist

Key generation:
  • ✅ Use crypto.randomUUID() (UUID v4) as your default key generator
  • ✅ Generate the key in your application before sending the request
  • ✅ Store the key in component or request state while the request is in flight
  • ✅ Include the key in all mutation API calls — no exceptions
  • ✅ Log keys at debug level for tracing (do not display them to end users)
Retry handling:
  • ✅ Reuse the same key when retrying after a network timeout
  • ✅ Reuse the same key when retrying after a 5xx server error
  • ✅ Reuse the same key when retrying after fixing a validation error (400)
  • ✅ Generate a new key for every genuinely new operation
  • ✅ Implement exponential backoff (e.g., 2s, 4s, 8s) before each retry
  • ✅ Set a maximum retry count (3 attempts is a sensible default)
Duplicate error handling:
  • ✅ Detect duplicate errors by status code (409) or message keyword ("idempotency", "duplicate")
  • ✅ Query the transaction history to confirm whether the original succeeded before showing an error
  • ✅ Show "Transaction already recorded" rather than a generic error message
  • ✅ Never tell the user a transaction “failed” if it may have succeeded
UI/UX:
  • ✅ Disable the submit button immediately when a request is initiated
  • ✅ Display a loading indicator while the request is in flight
  • ✅ Clear and reset the form only after receiving a confirmed success response
  • ✅ Handle network timeouts gracefully — show a “checking status…” message before retrying

Common Errors

Troubleshoot idempotency key errors in context

Loan Disbursement Workflow

See how idempotency keys are used in the loan creation call

Monthly Closing Checklist

Idempotency keys are required for the period close API call too

New Member Onboarding

Use idempotency keys when recording the initial deposit