An idempotent transfer API lets a client retry a send request without moving the money twice. The client sends one unique key per send attempt. The server saves that key, the balance debit, the transfer, and the stored response in a single database transaction, then calls the payout provider after commit.
A user taps Send on a train. The request reaches your server, the balance is debited, and the tunnel swallows the response. The app shows a spinner, then a retry. Without protection, that one tap is now two transfers.
This is the first failure to design for in money transfer app development, because the network cannot tell the client whether a timed-out request succeeded. A timeout means "unknown", not "failed". Idempotency turns that unknown into a safe retry.
The example below uses Node.js, Express, and PostgreSQL 16. All of it ran against a real PostgreSQL instance, and the test results are further down.
What Should the Client Send?
The client creates one key per user intent and sends it in an Idempotency-Key header:
http
POST /v1/transfers
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json
{"recipientId": "r1", "amountMinor": 2500, "currency": "USD"}
Three rules matter here:
Create the key when the user confirms the send, and save it on the device before the first request. If the app is killed mid-request, the retry can reuse it.
Reuse the key for every retry of that send. Create a new key only when the user starts a new send.
Use a random UUID. Stripe's documentation also suggests V4 UUIDs and warns against using personal data as a key.
The header follows the IETF Idempotency-Key Internet-Draft. The latest version found (draft 07, October 2025) is a working draft rather than a final standard, but its status codes are a sensible default.
How Should the Database Be Set Up?
The composite primary key does most of the work. Scoping keys by client also stops one customer's key from colliding with another's.
sql
CREATE TABLE idempotency_keys (
client_id text NOT NULL,
key text NOT NULL,
request_hash char(64) NOT NULL,
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, key)
);
CREATE TABLE outbox (
transfer_id uuid PRIMARY KEY REFERENCES transfers(id),
attempts int NOT NULL DEFAULT 0,
sent_at timestamptz
);
The sample also has simple accounts and transfers tables. Amounts are stored as integers in minor units, so 2500 means $25.00 and floating-point rounding never touches a balance.
How Does the Transfer Endpoint Work?
The important choice is to keep everything in one transaction. There is no separate "in progress" status to clean up after a crash, because a crash rolls the key back along with the debit.
js
const hashRequest = ({ recipientId, amountMinor, currency }) =>
crypto.createHash('sha256')
.update(JSON.stringify([recipientId, amountMinor, currency]))
.digest('hex');
app.post('/v1/transfers', async (req, res) => {
const key = req.get('Idempotency-Key');
if (!key || key.length > 255) {
return res.status(400).json({ error: 'idempotency_key_required' });
}
const { recipientId, amountMinor, currency } = req.body;
if (!recipientId || !Number.isSafeInteger(amountMinor) || amountMinor <= 0 ||
!/^[A-Z]{3}$/.test(currency ?? '')) {
return res.status(400).json({ error: 'invalid_request' });
}
const clientId = req.get('X-Client-Id'); // use your verified auth context in production
const requestHash = hashRequest(req.body);
const db = await pool.connect();
try {
await db.query('BEGIN');
await db.query("SET LOCAL lock_timeout = '2s'");
const claim = await db.query(
`INSERT INTO idempotency_keys (client_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (client_id, key) DO NOTHING`,
[clientId, key, requestHash],
);
if (claim.rowCount === 0) {
const { rows: [saved] } = await db.query(
`SELECT request_hash, response_code, response_body
FROM idempotency_keys WHERE client_id = $1 AND key = $2`,
[clientId, key],
);
await db.query('COMMIT');
if (saved.request_hash !== requestHash) {
return res.status(422).json({ error: 'idempotency_key_reused' });
}
return res.set('Idempotent-Replayed', 'true')
.status(saved.response_code).json(saved.response_body);
}
const debit = await db.query(
`UPDATE accounts SET balance_minor = balance_minor - $1
WHERE id = $2 AND balance_minor >= $1`,
[amountMinor, clientId],
);
let code = 422;
let body = { error: 'insufficient_funds' };
if (debit.rowCount === 1) {
const { rows: [transfer] } = await db.query(
`INSERT INTO transfers (sender_id, recipient_id, amount_minor, currency)
VALUES ($1, $2, $3, $4) RETURNING id, status`,
[clientId, recipientId, amountMinor, currency],
);
await db.query('INSERT INTO outbox (transfer_id) VALUES ($1)', [transfer.id]);
code = 202;
body = { id: transfer.id, status: transfer.status };
}
await db.query(
`UPDATE idempotency_keys SET response_code = $1, response_body = $2
WHERE client_id = $3 AND key = $4`,
[code, body, clientId, key],
);
await db.query('COMMIT');
return res.status(code).json(body);
} catch (err) {
await db.query('ROLLBACK').catch(() => {});
if (err.code === '55P03') {
return res.set('Retry-After', '1').status(409)
.json({ error: 'request_in_progress' });
}
console.error(err);
return res.status(500).json({ error: 'internal_error' });
} finally {
db.release();
}
});
Here is what happens when two identical requests arrive together. The first INSERT takes the key. The second blocks on the same unique key until the first transaction commits, then inserts nothing and reads the saved response. If the first transaction rolls back, the second one claims the key and runs normally.
The lock_timeout caps that wait. After two seconds, PostgreSQL raises error 55P03, and the API returns 409 so the client can retry shortly.
What Should Each Retry Return?
Situation Response Client action
New key 202 with the transfer Show pending status
Same key, same body Saved response replayed Treat as the original result
Same key, different body 422 Fix the bug, do not retry
Key missing 400 Add a key
Original still running 409 with Retry-After Retry with the same key
Server error before commit 500, nothing saved Retry with the same key
The 400, 409, and 422 mapping follows the IETF draft. Insufficient funds is saved and replayed, so a retry cannot succeed later by surprise. If the user tops up and tries again, that is a new intent with a new key.
How Do You Call the Payout Provider Safely?
Never call a provider inside the request transaction. A slow provider would hold the row locks, and a commit failure after the call would leave money moving with no record of it.
Instead, the endpoint writes an outbox row in the same transaction as the debit, and a worker sends it after commit:
js
async function sendNextTransfer(provider) {
const db = await pool.connect();
try {
await db.query('BEGIN');
const { rows: [job] } = await db.query(
SELECT t.id, t.recipient_id, t.amount_minor, t.currency,
FROM outbox o JOIN transfers t ON t.id = o.transfer_id
WHERE o.sent_at IS NULL
LIMIT 1 FOR UPDATE OF o SKIP LOCKED
);
if (!job) { await db.query('COMMIT'); return false; }
try {
await provider.send({ reference: job.id, to: job.recipient_id,
amountMinor: job.amount_minor, currency: job.currency });
await db.query('UPDATE outbox SET sent_at = now() WHERE transfer_id = $1', [job.id]);
await db.query(`UPDATE transfers SET status = 'submitted' WHERE id = $1`, [job.id]);
} catch (err) {
await db.query('UPDATE outbox SET attempts = attempts + 1 WHERE transfer_id = $1', [job.id]);
}
await db.query('COMMIT');
return true;
} catch (err) {
await db.query('ROLLBACK').catch(() => {});
throw err;
} finally {
db.release();
}
}
The outbox gives you at-least-once delivery, so the worker can send the same transfer twice. That is why the stable reference matters: the transfer ID goes to the provider, and the provider deduplicates on it.
Two warnings before you ship this. First, confirm in writing that your provider deduplicates by reference. If it does not, query the provider's status endpoint before each resend. Second, a provider timeout is not a failure, so never refund the user until the provider confirms that the payout did not happen. In production, also add a request timeout and a backoff based on attempts, because this short worker keeps a database connection open during the provider call.
What Did the Failure Tests Show?
The test run used PostgreSQL 16 and a fake provider that processed a payout, then dropped the acknowledgement once:
50 concurrent requests with one key: one transfer, one debit, and the same transfer ID in all 50 responses.
Retry after success: the saved response was replayed, and the balance did not move again.
Same key with a new amount: 422, with no new transfer.
Failure before commit: the debit and key both rolled back, and the retry ran as a fresh request.
Duplicate during a stuck transaction: 409 after about two seconds, then a normal replay once the first request finished.
Lost provider acknowledgement: one extra provider call, and no extra payout.
These tests cover the logic above in one environment. They are not a load test, and a real system also needs tests against your provider's sandbox.
What Security and Compliance Checks Apply?
Idempotency stops duplicates, not fraud, so it sits beside your other controls:
Take the client ID from verified auth. The X-Client-Id header above is only a demo shortcut. Trusting it would let a caller debit someone else's account.
Keep keys long enough. Stripe says its API v1 keys can be removed once they are at least 24 hours old. Your retention must outlast your longest client retry window, because a retry after purging is a new transfer. Publish that window in your API docs, as the IETF draft recommends.
Keep an audit trail. The saved request hash, response, and transfer record show what happened to each send. Keep them for as long as your record-keeping rules require.
Check limits and screening inside the flow. KYC status, velocity limits, and AML screening should run before the debit, and a retry should never skip them on a fresh key.
Store no secrets in keys. Keys appear in logs, so they should stay random.
Licensing and record-keeping duties vary by country and corridor, so confirm them with qualified counsel. This section is general information, not legal advice.
Dev Technosys holds ISO 27001:2022 certification. Tarun Nagar, Founder and CEO of Dev Technosys, says: "A money transfer app earns trust in the moments users never see. The retry path deserves the same care as the send button."
Which Are the Top 5 Money Transfer App Development Companies?
If you would rather not build this layer alone, these firms are worth a look. Figures below come from each company's own website or public profile and are self-reported, so confirm them directly before signing anything.
- Dev Technosys
Dev Technosys is a money transfer app development company for founders who want sending, verification, and monitoring built as one product. Its builds cover P2P transfers, cross-border payments with real-time currency conversion, KYC, AML monitoring, and AI-based fraud detection, along with an admin panel for transaction monitoring and disputes. Operations staff can follow successful, failed, and pending transfers from that one panel daily. With 250+ in-house professionals and an 89% project success rate, the company wins most of its new business through client referrals. Projects start from $10,000.
- DataArt
Founded in New York City in 1997, DataArt describes 6,000+ engineers on Clutch and claims 200+ payments specialists on its own site. Its payments page names real-time rails such as FedNow, SEPA Instant, and Faster Payments, along with cross-border FX engines and ISO 20022 modernization. Clutch shows 26 reviews averaging 4.9, but the ten visible there do not mention payments work, and the minimum project is $100,000. DataArt suits established payment firms that need new rail integrations and platform modernization at enterprise scale, with budgets to match.
- Coherent Solutions
A Clutch reviewer from a CFD trading platform says Coherent Solutions handles its client funding and withdrawal integrations, and that the app no longer breaks completely when one third-party element goes down. The Minneapolis firm was founded in 1995 and describes over 2,000 engineers. The same reviewer reported staff turnover and scored cost at 3.0, while financial services makes up 15% of its listed focus and projects start at $50,000. Coherent Solutions suits funded fintech teams that need sturdier provider integrations around a product already in the market.
- Zymr
Payments is a named service line at Zymr, covering P2P transfer apps, domestic and cross-border transfers, gateway integrations, and Stripe, PayPal, and Adyen APIs, with an anonymized remittance case study on its site. Clutch gives 2012 as its founding year, with six reviews averaging 4.5 and a $25,000 minimum. Proof is the gap: that profile shows both Silicon Valley and Ahmedabad as headquarters, its staff figures conflict, and no review comes from a financial services client. Zymr suits startups wanting payment gateway work at $25 to $49 hourly.
- Exadel
For teams whose hardest problem is compliance tooling, Exadel offers a relevant reference: its Clutch profile describes SILO, an AML system that tracks client financial activity, and names UBS among key clients. The firm lists Walnut Creek, California as its headquarters and says it has a thousand employees, although the directory shows 250 to 999. No client review covers payments or money transfers, and only seven reviews appear there. Exadel suits financial firms that need custom AML and monitoring software built beside an existing money transfer product.
FAQs
What is an idempotent transfer API? It is an API where repeating the same request with the same key has the same effect as sending it once. The client can retry after a timeout without moving the money twice.
Should a retry use the same idempotency key? Yes. Every retry of one send must reuse its key. A new key tells the server that this is a new transfer.
Is a unique constraint on the transfers table enough? It helps, but it cannot return the original response or detect a key reused with a different amount. A dedicated key table handles both.
Can Redis store idempotency keys instead? It can, but the key and the debit then live in different systems, so a crash between them can leave them out of step. Keeping both in one database transaction avoids that gap.
Does idempotency give exactly-once delivery to the provider? No. The worker delivers at least once. You get a single payout only when the provider also deduplicates on your reference, or when you check its status before resending.
Top comments (0)