<!-- Source: https://blnkfinance.com/blog/how-to-handle-idempotency-in-your-financial-app-using-the-blnk-ledger -->

[Blog](https://blnkfinance.com/blog) [Guides](https://blnkfinance.com/blog/guides)

May 4, 2026 4 min read

# How to Handle Idempotency in Your Financial App Using the Blnk Ledger

-   Emmanuella Etop-Essien Developer Relations

![How to handle idempotency in your financial app using the Blnk Ledger](https://cdn.sanity.io/images/06pses5q/production/e5d0161c36664e7a15399131195ba09b533e2255-1792x1008.png?w=1600&fit=max&auto=format)

On this page

1.  [How Blnk handles idempotency](#how-blnk-handles-idempotency)
2.  [Discarded vs. rejected](#discarded-vs-rejected)
3.  [Best practices for idempotency in Blnk](#best-practices-for-idempotency-in-blnk)
4.  [Code sample: retries done right](#code-sample-retries-done-right)
5.  [Beyond single transactions](#beyond-single-transactions)

Alex funds his wallet with $200. Nothing happens for a few seconds, so he tries again, and this time the payment goes through. A minute later his balance reads $400 instead of $200.

What happened? Alex's first tap reached your API and was recorded, but the response timed out before it got back to him. When he tapped again, your API had no memory of the first call, so it wrote a second entry.

Two transactions, one payment, and your funding account is now missing $200.

That's the problem [idempotency](https://blnkfinance.com/glossary/idempotency) solves. A transaction request is idempotent when repeating it produces the same result, not a second entry. The first request records the transaction and updates the balance. Every repeat after that is ignored. It's what makes retries safe: when your app can't tell whether a request landed, it can send the same request again without worrying about doubling the transaction.

## \# How Blnk handles idempotency

Blnk handles idempotency through a required `reference` field on every [transaction](https://docs.blnkfinance.com/transactions/introduction). Only the first transaction with a given reference is recorded. Every duplicate after that is discarded automatically.

You don't need a separate header, an extra table, or a cache to invalidate. The `reference` you already send with every transaction *is* the [idempotency key](https://blnkfinance.com/glossary/reference).

Here's what a transaction request in Blnk looks like:

<!-- TypeScript -->
```
const response = await blnk.Transactions.create({
  amount: 200,
  reference: 'topup_alex_2026_04_17_001',
  currency: 'USD',
  precision: 100,
  source: '@WorldUSD',
  destination: 'bln_alex_wallet',
  description: 'Wallet top-up',
  allow_overdraft: true,
});
```

<!-- Go -->
```
transaction, resp, err := client.Transaction.Create(
  blnkgo.CreateTransactionRequest{
    ParentTransaction: blnkgo.ParentTransaction{
      Amount: 200,
      Reference: "topup_alex_2026_04_17_001",
      Currency: "USD",
      Precision: 100,
      Source: "@WorldUSD",
      Destination: "bln_alex_wallet",
      Description: "Wallet top-up",
    },
    AllowOverdraft: true,
  },
)
```

<!-- Python -->
```
response = blnk.transactions.create({
  "amount": 200,
  "reference": "topup_alex_2026_04_17_001",
  "currency": "USD",
  "precision": 100,
  "source": "@WorldUSD",
  "destination": "bln_alex_wallet",
  "description": "Wallet top-up",
  "allow_overdraft": True,
})
```

<!-- Java -->
```
ApiResponse<JsonNode> response = blnk.transactions().create(
  CreateTransactions.create()
    .amount(200)
    .reference("topup_alex_2026_04_17_001")
    .currency("USD")
    .precision(100)
    .source("@WorldUSD")
    .destination("bln_alex_wallet")
    .description("Wallet top-up")
    .allowOverdraft(true));
```

If the first request succeeds, Blnk records the transaction and returns it. If your app retries with the same `reference`, Blnk discards the duplicate silently.

Alex's balance only moves once, and your ledger stays correct.

## \# Discarded vs. rejected

It's worth being precise here, because this matters when you're debugging.

Blnk has two different outcomes for a transaction that doesn't get applied:

-   **Rejected** transactions are recorded in the ledger. These are transactions Blnk received, validated, and decided not to apply. For example, if Alex tries to send $500 from a wallet that only holds $300, Blnk saves it with `status: "REJECTED"` and a reason in `meta_data`, so you still have a trace of the attempt.
-   **Discarded** transactions, however, are not recorded. This is what happens when Blnk detects a duplicate reference in your transaction request.

So when a duplicate comes in, you won't see a second row piling up next to your original. You'll just see the one original transaction, exactly as it was recorded.

The ledger looks the same whether your app retried once, twice, or ten times.

## \# Best practices for idempotency in Blnk

Because the reference does the work of an idempotency key, how you generate it matters. Here are a few things to keep in mind:

1.  ‍**One reference per money movement.** If Alex retries his $200 top-up three times, all three requests should carry the same reference. The reference belongs to the operation, not to each attempt.**‍**
2.  **Create the reference before any retry logic runs.** Build it *before* you enter the retry loop. If you create it inside the loop, every attempt gets a fresh reference, and Blnk has no way to know they're the same operation.**‍**
3.  **Use meaningful identifiers when you can.** A random UUID works, but a business identifier like an `order_id` or `payout_id` connected to an external payment processor / source is better. It ties the ledger record back to the domain object that caused it.

### Code sample: retries done right

Let's go back to Alex's $200 top-up and walk through what can go wrong with retries, and how to fix it.

**The broken version:** your app generates a new reference on every attempt.

<!-- TypeScript -->
```
async function topUpWallet(user: {walletId: string}, amount: number) {
  const maxRetries = 3;
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const reference = crypto.randomUUID();
      const response = await blnk.Transactions.create({
        reference,
        amount,
        currency: 'USD',
        source: '@WorldUSD',
        destination: user.walletId,
      });
      return response;
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
    }
  }
}
```

<!-- Go -->
```
func topUpWallet(user User, amount int64) (*blnkgo.Transaction, error) {
  var lastErr error
  for attempt := 0; attempt < 3; attempt++ {
    transaction, resp, err := client.Transaction.Create(
      blnkgo.CreateTransactionRequest{
        ParentTransaction: blnkgo.ParentTransaction{
          Amount: amount,
          Currency: "USD",
          Source: "@WorldUSD",
          Destination: user.WalletID,
          Reference: reference,
        },
      },
    )
    if err == nil {
      return transaction, nil
    }
    lastErr = err
  }
  return nil, lastErr
}
```

<!-- Python -->
```
def top_up_wallet(user, amount):
  for attempt in range(3):
    try:
      reference = str(uuid.uuid4())
      blnk.transactions.create({
        "reference": reference,
        "amount": amount,
        "currency": "USD",
        "source": "@WorldUSD",
        "destination": user["wallet_id"],
      })
      return response
      except Exception:
        if attempt == 2:
          raise
```

<!-- Java -->
```
ApiResponse<JsonNode> topUpWallet(User user, long amount) {
  Exception lastError = null;
  for (int attempt = 0; attempt < 3; attempt++) {
    try {
      ApiResponse<JsonNode> response = blnk.transactions().create(
        CreateTransactions.create()
          .reference(reference)
          .amount(amount)
          .currency("USD")
          .source("@WorldUSD")
          .destination(user.walletId()));
        return response;
      } catch (Exception err) {
        lastError = err;
      }
    }
    throw new RuntimeException(lastError);
  }
```

Here's what happens:

-   Attempt 1 hits Blnk and gets recorded, but the response never makes it back to your app.
-   Your app assumes the request failed and retries.
-   Attempt 2 generates a brand new reference, so Blnk treats it as a completely different transaction.
-   Alex's wallet gets credited $200 twice. Your funding account is now short $200.

**The fixed version:** generate the reference *once*, before any attempt.

<!-- TypeScript -->
```
async function topUpWallet(user: {id: string; walletId: string}, order: {id: string}, amount: number) {
  const maxRetries = 3;
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const reference = `topup_${user.id}_${order.id}`;
      const response = await blnk.Transactions.create({
        reference,
        amount,
        currency: 'USD',
        source: '@WorldUSD',
        destination: user.walletId,
      });
      return response;
    } catch (err) {
      if (attempt === maxRetries - 1) throw err;
    }
  }
}
```

<!-- Go -->
```
func topUpWallet(user User, order Order, amount int64) (*blnkgo.Transaction, error) {
  var lastErr error
  for attempt := 0; attempt < 3; attempt++ {
    transaction, resp, err := client.Transaction.Create(
      blnkgo.CreateTransactionRequest{
        ParentTransaction: blnkgo.ParentTransaction{
          Amount: amount,
          Currency: "USD",
          Source: "@WorldUSD",
          Destination: user.WalletID,
          Reference: reference,
        },
      },
    )
    if err == nil {
      return transaction, nil
    }
    lastErr = err
  }
  return nil, lastErr
}
```

<!-- Python -->
```
def top_up_wallet(user, order, amount):
  for attempt in range(3):
    try:
      reference = f"topup_{user['id']}_{order['id']}"
      blnk.transactions.create({
        "reference": reference,
        "amount": amount,
        "currency": "USD",
        "source": "@WorldUSD",
        "destination": user["wallet_id"],
      })
      return response
      except Exception:
        if attempt == 2:
          raise
```

<!-- Java -->
```
ApiResponse<JsonNode> topUpWallet(User user, Order order, long amount) {
  Exception lastError = null;
  for (int attempt = 0; attempt < 3; attempt++) {
    try {
      ApiResponse<JsonNode> response = blnk.transactions().create(
        CreateTransactions.create()
          .reference(reference)
          .amount(amount)
          .currency("USD")
          .source("@WorldUSD")
          .destination(user.walletId()));
        return response;
      } catch (Exception err) {
        lastError = err;
      }
    }
    throw new RuntimeException(lastError);
  }
```

Now every retry carries the same reference. If Blnk already recorded the first attempt, the retry is discarded. If it didn't, the retry goes through.

Either way, Alex's wallet moves exactly once.

## \# Beyond single transactions

The same `reference` rule extends to more complex flows.

1.  ‍**Bulk transactions.** Each transaction in a [batch](https://docs.blnkfinance.com/transactions/bulk-transactions) needs its own unique reference. If you retry the whole batch after a timeout, already-recorded transactions are discarded and only the ones that didn't land are applied.[**‍**](https://docs.blnkfinance.com/transactions/introduction)
2.  [**Inflight**](https://docs.blnkfinance.com/transactions/inflight/creating-inflight) **transactions.** The reference protects you from duplicate holds. Committing or voiding is done by `transaction_id`, so those calls are naturally idempotent: committing an already-committed transaction doesn't move balances a second time.**‍**
3.  **[Refunds](https://docs.blnkfinance.com/transactions/refunds).** A refund is a new transaction, so it needs its own reference. A clean pattern: `refund_{original_reference}`. Unique, easy to reason about, and keeps the refund discoverable from the transaction it reverses.

In every case, the contract is the same: one reference, one recorded transaction. As long as your app generates references correctly and reuses them on retry, duplicates can't reach your ledger in Blnk.

Balances stay accurate, reconciliation stays honest, and Alex's $200 top-up stays a $200 top-up, no matter how many times the network hiccups.

## Related posts

-   [
    
    Sep 16, 2026Guides
    
    ### Beyond accounting: Why developers need a ledger
    
    Emmanuella Etop-Essien
    
    ](https://blnkfinance.com/blog/beyond-accounting-why-developers-need-a-ledger)
-   [
    
    Aug 31, 2026Guides
    
    ### Beyond fintech: What else can you build with a ledger?
    
    Praise Philemon
    
    ](https://blnkfinance.com/blog/beyond-fintech-what-else-can-you-build-with-a-ledger)
-   [
    
    Aug 21, 2026Guides
    
    ### Managing ledger integrity in the age of AI agents
    
    Praise Philemon
    
    ](https://blnkfinance.com/blog/managing-ledger-integrity-in-the-age-of-ai-agents)
