Skip to main content

Overview

Idempotency ensures that performing the same operation multiple times has the same effect as performing it once. Agatabo uses idempotency keys to prevent duplicate financial transactions from network issues, accidental double-clicks, or system retries.
How it works: Every financial operation requires a unique x-idempotency-key header. If the same key is used twice, the second request is rejected as a duplicate.

Why Idempotency Matters

Without idempotency protection:
User clicks "Record Deposit"
  → Network timeout (no response)
  → User clicks again (thinking it didn't work)
  → Result: Deposit recorded TWICE ❌
With Agatabo’s idempotency:
User clicks "Record Deposit" (key: abc123)
  → Network timeout
  → User clicks again (same key: abc123)
  → Backend detects duplicate key
  → Result: Deposit recorded ONCE ✓

How It Works

Database Constraint

JournalEntry model has unique constraint:
@@unique([organizationId, idempotencyKey])
What this means:
  • Within an organization, each idempotencyKey can only be used once
  • Attempting to create a duplicate journal entry fails with database constraint error
  • Second request with same key is rejected automatically

API Requirement

All mutation operations require x-idempotency-key header:
POST /savings
Headers:
  x-organization-id: org-abc123
  x-idempotency-key: unique-key-here
Body:
  { ... }
Operations requiring idempotency key:
  • Recording deposits (POST /savings)
  • Recording withdrawals (DELETE /savings/{id})
  • Creating loans (POST /loans)
  • Recording loan payments (POST /loans/{id}/repay)
  • Recording expenses (POST /expenses)
  • Creating assets (POST /assets)
  • Reserve allocations (POST /reserve-allocations)
  • Reserve releases (POST /reserve-allocations/{id}/release)
  • Dividend distributions (POST /dividends/pools, POST /dividends/pools/{id}/distribute)
  • Period closing (POST /period-closing/close, POST /period-closing/undo)
Operations NOT requiring idempotency key:
  • GET requests (read-only)
  • Reports generation
  • Viewing data

Request Validation

Backend validates idempotency key:
if (!idempotencyKey?.trim()) {
  throw new BadRequestException('x-idempotency-key header is required');
}
Missing key = 400 Bad Request error

Generating Idempotency Keys

Frontend responsibility: Generate unique key for each operation. Recommended format:
// Option 1: UUID v4
const idempotencyKey = crypto.randomUUID();
// Example: "550e8400-e29b-41d4-a716-446655440000"

// Option 2: Timestamp + Random
const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Example: "1686567890123-k3j4h5g6f"

// Option 3: Operation-specific prefix
const idempotencyKey = `deposit-${userId}-${Date.now()}-${Math.random()}`;
// Example: "deposit-user123-1686567890123-0.123456789"
Best practices:
  • ✅ Use UUID v4 (cryptographically random)
  • ✅ Include timestamp for easier debugging
  • ✅ Store key in application state during request
  • ✅ Reuse same key for retries of the SAME request
  • ✅ Generate new key for new operations
  • ❌ Never reuse keys across different operations
  • ❌ Don’t use sequential numbers (predictable)

Idempotency Scope

Keys are scoped to organization:
Organization A:
  idempotencyKey: "abc123" → Deposit 10,000 RWF ✓

Organization B:
  idempotencyKey: "abc123" → Deposit 5,000 RWF ✓
Different organizations can use same key (unique constraint includes organizationId) Within same organization:
Organization A:
  idempotencyKey: "abc123" → Deposit 10,000 RWF ✓
  idempotencyKey: "abc123" → Deposit 20,000 RWF ❌ DUPLICATE

Duplicate Detection

When duplicate detected: Request 1 (first time):
POST /savings
Headers:
  x-idempotency-key: abc123

Response: 200 OK
{
  "message": "Savings recorded successfully",
  "data": { ... }
}
Request 2 (duplicate - same key):
POST /savings
Headers:
  x-idempotency-key: abc123

Response: 400 Bad Request or 409 Conflict
{
  "message": "Duplicate transaction detected",
  "error": "A transaction with this idempotency key already exists"
}
Database error (unique constraint violation) caught and returned as API error.

Common Scenarios

Scenario 1: Network Timeout

Situation:
  1. User submits deposit form
  2. Network timeout (30 seconds, no response)
  3. Frontend retries with same idempotency key
Result:
Request 1: idempotency-key = "xyz789"
  → Processing... (slow network)
  → Success! Deposit created

Request 2 (retry): idempotency-key = "xyz789"
  → Duplicate key detected
  → Rejected: "Transaction already exists"
  → Deposit recorded ONCE ✓
Frontend handling:
const idempotencyKey = generateKey();

try {
  await createDeposit({ idempotencyKey });
} catch (error) {
  if (error.status === 408 || isNetworkTimeout(error)) {
    // Retry with SAME key
    await createDeposit({ idempotencyKey });
  }
}

Scenario 2: Accidental Double-Click

Situation:
  1. User double-clicks “Record Payment” button
  2. Two requests sent rapidly
Frontend implementation:
let requestInProgress = false;
const idempotencyKey = generateKey();

async function handleSubmit() {
  if (requestInProgress) return; // Prevent second click

  requestInProgress = true;
  try {
    await recordPayment({ idempotencyKey });
  } finally {
    requestInProgress = false;
  }
}
Even if both requests reach backend:
Request 1: idempotency-key = "abc123"
  → Processing...
  → Success! Payment created

Request 2: idempotency-key = "abc123"
  → Duplicate key detected
  → Rejected immediately
  → Payment recorded ONCE ✓

Scenario 3: Failed Request Retry

Situation:
  1. Transaction fails due to validation error
  2. User fixes error and resubmits
Can retry with same key if transaction failed:
const idempotencyKey = generateKey();

// First attempt - validation error
try {
  await createLoan({
    amount: -5000, // Invalid (negative amount)
    idempotencyKey
  });
} catch (error) {
  if (error.status === 400) {
    // Validation error - transaction NOT created
    // Can retry with same key after fixing
    await createLoan({
      amount: 5000, // Fixed
      idempotencyKey // Same key OK - first attempt failed
    });
  }
}
Key point: Idempotency protection only blocks successful transactions.

Scenario 4: New Transaction

Situation:
  1. User successfully records deposit
  2. Wants to record another deposit
Must generate new key:
// First deposit
const key1 = generateKey(); // "abc123"
await createDeposit({ amount: 10000, idempotencyKey: key1 });

// Second deposit - MUST use new key
const key2 = generateKey(); // "def456"
await createDeposit({ amount: 20000, idempotencyKey: key2 });
Using same key would be rejected:
// Wrong - reusing key
await createDeposit({ amount: 10000, idempotencyKey: key1 }); // ✓
await createDeposit({ amount: 20000, idempotencyKey: key1 }); // ❌ DUPLICATE

Error Handling

Missing Idempotency Key

Request without key:
POST /savings
Headers:
  x-organization-id: org-abc123
  # Missing x-idempotency-key

Response: 400 Bad Request
{
  "message": "x-idempotency-key header is required"
}
Frontend must include key for all mutations.

Duplicate Key Error

Duplicate request detected:
POST /savings
Headers:
  x-idempotency-key: already-used-key

Response: 400 Bad Request or 409 Conflict
{
  "message": "Duplicate transaction detected",
  "error": "Unique constraint violation on idempotencyKey"
}
What to do:
  1. Check if original transaction succeeded
  2. If succeeded: Don’t retry, show success message
  3. If failed: Retry with same key
  4. If creating new transaction: Generate new key

Frontend Implementation Example

Complete idempotency handling:
import { v4 as uuidv4 } from 'uuid';

class TransactionService {
  private pendingRequests = new Map<string, Promise<any>>();

  async createDeposit(data: DepositData) {
    // Generate idempotency key
    const idempotencyKey = uuidv4();

    // Prevent duplicate in-flight requests
    if (this.pendingRequests.has(idempotencyKey)) {
      return this.pendingRequests.get(idempotencyKey);
    }

    // Create request
    const request = fetch('/savings', {
      method: 'POST',
      headers: {
        'x-organization-id': organizationId,
        'x-idempotency-key': idempotencyKey,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    })
    .then(async (response) => {
      if (!response.ok) {
        const error = await response.json();

        // Check if duplicate
        if (error.message?.includes('duplicate') ||
            error.message?.includes('idempotency')) {
          // Duplicate detected - check if original succeeded
          const original = await this.checkTransactionStatus(idempotencyKey);
          if (original) {
            return original; // Return original transaction
          }
        }

        throw error;
      }

      return response.json();
    })
    .finally(() => {
      this.pendingRequests.delete(idempotencyKey);
    });

    // Store pending request
    this.pendingRequests.set(idempotencyKey, request);

    return request;
  }

  async checkTransactionStatus(idempotencyKey: string) {
    // Query backend for transaction with this key
    // Implementation depends on your API
  }
}

Retry Logic

Safe retry strategy:
async function safeRetry(operation: () => Promise<any>, maxRetries = 3) {
  const idempotencyKey = generateKey();

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation(idempotencyKey);
    } catch (error) {
      // Network errors - retry with same key
      if (isNetworkError(error) && attempt < maxRetries) {
        await delay(1000 * attempt); // Exponential backoff
        continue;
      }

      // Validation errors - don't retry
      if (error.status === 400 && !isDuplicateError(error)) {
        throw error;
      }

      // Duplicate detected - check if original succeeded
      if (isDuplicateError(error)) {
        const original = await checkStatus(idempotencyKey);
        if (original) return original;
      }

      throw error;
    }
  }
}

// Usage
await safeRetry((key) =>
  createDeposit({ amount: 10000, idempotencyKey: key })
);

Best Practices

Idempotency implementation checklist:Key generation:
  • ✅ Use UUID v4 (crypto.randomUUID())
  • ✅ Generate key in frontend before request
  • ✅ Store key in component state during request
  • ✅ Include key in all mutation API calls
  • ✅ Log keys for debugging (don’t expose to users)
Retry handling:
  • ✅ Reuse same key for network timeouts
  • ✅ Reuse same key for 5xx server errors
  • ✅ Generate new key for new operations
  • ✅ Check transaction status before retrying duplicates
  • ✅ Implement exponential backoff for retries
Error handling:
  • ✅ Detect duplicate errors (400/409 with “idempotency” message)
  • ✅ Verify original transaction status before showing error
  • ✅ Show “Transaction already recorded” instead of generic error
  • ✅ Prevent user confusion (don’t say “failed” if it succeeded)
UI/UX:
  • ✅ Disable submit button during request (prevent double-click)
  • ✅ Show loading spinner while processing
  • ✅ Clear form only after confirmed success
  • ✅ Handle network timeouts gracefully (retry automatically)
  • ✅ Provide clear error messages
Testing:
  • ✅ Test duplicate key rejection
  • ✅ Test network timeout retry
  • ✅ Test concurrent requests with same key
  • ✅ Test key uniqueness across operations

Troubleshooting

Q: Getting “idempotency-key header is required” error A: Add x-idempotency-key header to request:
headers: {
  'x-idempotency-key': crypto.randomUUID()
}
All POST, PUT, DELETE requests for financial operations require this header.
Q: Getting “duplicate transaction” error immediately A: The idempotency key was already used. Either:
  1. Original transaction succeeded (check transaction history)
  2. Reusing key from previous operation (generate new key)
Solution:
// Don't reuse keys across operations
const key1 = generateKey();
await createDeposit({ idempotencyKey: key1 }); // ✓

const key2 = generateKey(); // New key!
await createDeposit({ idempotencyKey: key2 }); // ✓

Q: Need to record identical transactions A: Use different idempotency keys:
// Two identical deposits - different keys
await createDeposit({
  member: "Jane",
  amount: 10000,
  idempotencyKey: generateKey() // Unique key 1
});

await createDeposit({
  member: "Jane",
  amount: 10000,
  idempotencyKey: generateKey() // Unique key 2
});
Idempotency prevents duplicate requests, not duplicate transactions.
Q: Transaction failed but can’t resubmit A: If transaction failed (not created), you can retry with same key:
const key = generateKey();

try {
  await createLoan({ amount: 10000, idempotencyKey: key });
} catch (error) {
  if (isValidationError(error)) {
    // Fix validation issue, retry with same key
    await createLoan({ amount: 10000, idempotencyKey: key }); // OK
  }
}

Q: How long are keys valid? A: Idempotency keys are permanent. Once used successfully, they cannot be reused in that organization. Implication: Don’t use predictable keys (sequential numbers, dates alone).
Q: Can I use same key in different organizations? A: Yes. Unique constraint is (organizationId, idempotencyKey):
// Organization A
await createDeposit({
  organization: "org-A",
  idempotencyKey: "abc123" // ✓
});

// Organization B - same key OK
await createDeposit({
  organization: "org-B",
  idempotencyKey: "abc123" // ✓ Different organization
});

Recording Deposits

Deposit API with idempotency

Creating Loans

Loan API with idempotency

Period Closing

Period close with idempotency

Audit Trail

Track duplicate prevention