The situation
Say you are a lead mobile engineer on a fintech startup that just launched a React Native app supporting both fiat purchases (via Stripe) and crypto purchases (via a custodial blockchain API). The product roadmap promised “one‑tap buying” for either asset class. After the first week of production, the support inbox filled with complaints:
- “My crypto purchase shows as pending forever, but my card was charged.”
- “I was refunded twice for a fiat transaction.”
- “The dashboard shows a total balance that does not match the sum of my individual transactions.”
The team spent three days investigating, discovered that the backend was creating duplicate transaction records, and that some on‑chain confirmations never arrived before the retry logic gave up. The cost was lost revenue (refunds), increased support load, and a delay in the next sprint while the bug‑fix branch was merged.
The rest of this guide shows how to design a reliable reconciliation layer that survives production load, handles retries without double‑charging, and makes every state observable to the client.
Why it happens
1. Asymmetric latency
Fiat gateways are typically synchronous: a card‑present request returns a success or decline within seconds. Crypto APIs are asynchronous; a transaction is submitted to the mempool, then confirmed after an indeterminate number of blocks (often 1‑5 minutes). If the mobile client treats both responses as “completed” at the same point, the UI will display a success while the blockchain is still pending.
2. Lack of idempotency
Both Stripe and most custodial crypto services accept an idempotency_key header, but the key is often generated on the client. If the client retries after a network glitch and generates a new key, the backend creates a second charge. The database ends up with two rows that look identical except for the key.
3. Partial failure windows
Consider the following sequence:
- Mobile app sends a fiat charge request → Stripe returns
succeeded. - The same request also triggers a crypto purchase request → the custodial API returns
queued. - The backend writes two rows:
fiat_status = succeeded,crypto_status = pending. - The app crashes before receiving the webhook that updates
crypto_statustoconfirmed.
Now the system holds a mixed state: the user has been debited, but the crypto asset is not credited. Without a periodic reconciliation job, this state persists indefinitely.
4. Race conditions in status updates
Webhooks from Stripe and from the custodial provider may arrive out of order. If the fiat webhook marks the transaction completed and the crypto webhook later marks it failed, the final state may be incorrectly set to failed unless the system tracks the source of each status change.
5. Missing “unknown” bucket
Most documentation lists three statuses: pending, succeeded, failed. Production reveals a fourth reality: unknown – the system has not received any confirmation within the expected window (e.g., 15 minutes for crypto, 30 seconds for fiat). Treating “unknown” as “failed” forces premature refunds; treating it as “pending” leaves users staring at a stale UI.
The fix, step by step
Below is a production‑grade pattern that works with React Native 0.76, Node 18, PostgreSQL 15, Stripe 2023‑10‑16, and a custodial crypto API that follows the JSON‑RPC 2.0 spec.
Step 1 – Define a single source of truth for transaction state
Create a table that stores one logical transaction, with separate columns for each rail.
CREATE TABLE user_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
amount_cents BIGINT NOT NULL,
currency TEXT NOT NULL, -- e.g. 'USD' or 'USDC'
fiat_status TEXT NOT NULL DEFAULT 'none',-- none|pending|succeeded|failed|unknown
crypto_status TEXT NOT NULL DEFAULT 'none',
fiat_intent_id TEXT, -- Stripe PaymentIntent ID
crypto_tx_hash TEXT, -- on‑chain transaction hash
idempotency_key TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
Why: A single row guarantees that retries affect the same logical purchase, preventing duplicate charges. The none status distinguishes “no request sent yet” from “pending”.
Confirm: Run SELECT COUNT(*) FROM user_transactions; – it should return 0 on a fresh DB.
Step 2 – Generate a stable idempotency key on the server
Never let the client create the key. In the API endpoint that starts a purchase, generate a UUID and store it before any external call.
// src/routes/purchase.js (Node/Express)
import { v4 as uuidv4 } from 'uuid';
import { db } from '../db.js';
import stripe from 'stripe')(process.env.STRIPE_SECRET);
import { submitCryptoTx } from '../crypto.js';
router.post('/purchase', async (req, res) => {
const { userId, amountCents, currency, paymentMethodId } = req.body;
const idempotencyKey = uuidv4();
const tx = await db.one(
`INSERT INTO user_transactions
(user_id, amount_cents, currency, idempotency_key, fiat_status, crypto_status)
VALUES ($1,$2,$3,$4,'pending','pending')
RETURNING *`,
[userId, amountCents, currency, idempotencyKey]
);
// Fire‑and‑forget calls; errors are handled by retry workers.
stripe.paymentIntents.create(
{
amount: amountCents,
currency: currency.toLowerCase(),
payment_method: paymentMethodId,
confirm: true,
},
{ idempotencyKey }
).then(pi => {
db.none(
`UPDATE user_transactions SET fiat_intent_id=$1, fiat_status='succeeded'
WHERE id=$2 AND fiat_status='pending'`,
[pi.id, tx.id]
);
}).catch(err => {
db.none(
`UPDATE user_transactions SET fiat_status='failed'
WHERE id=$1 AND fiat_status='pending'`,
[tx.id]
);
});
submitCryptoTx({
amount: amountCents,
currency,
userId,
idempotencyKey,
}).then(hash => {
db.none(
`UPDATE user_transactions SET crypto_tx_hash=$1, crypto_status='pending'
WHERE id=$2 AND crypto_status='pending'`,
[hash, tx.id]
);
}).catch(err => {
db.none(
`UPDATE user_transactions SET crypto_status='failed'
WHERE id=$1 AND crypto_status='pending'`,
[tx.id]
);
});
res.json({ transactionId: tx.id });
});
Why: The same idempotency_key is reused for both Stripe and the crypto provider, guaranteeing that any retry hits the same logical operation.
Confirm: After a successful POST, query the row:
psql -c "SELECT fiat_status, crypto_status FROM user_transactions WHERE id='<returned-id>';"
Both columns should read pending.
Step 3 – Persist webhook events with ordering metadata
Both Stripe and the custodial provider send webhooks. Store each webhook payload in a separate webhook_events table with a received_at timestamp and a source field (stripe|crypto). Then process them in order of received_at.
CREATE TABLE webhook_events (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
processed BOOLEAN DEFAULT FALSE
);
Processing worker (Node):
import { db } from './db.js';
async function processEvents() {
const events = await db.any(
`SELECT * FROM webhook_events
WHERE processed = FALSE
ORDER BY received_at ASC
LIMIT 100`
);
for (const ev of events) {
if (ev.source === 'stripe') {
const pi = ev.payload.data.object;
await db.none(
`UPDATE user_transactions
SET fiat_status = CASE
WHEN pi.status = 'succeeded' THEN 'succeeded'
WHEN pi.status = 'requires_payment_method' THEN 'failed'
ELSE 'unknown'
END
WHERE fiat_intent_id = $1`,
[pi.id]
);
} else if (ev.source === 'crypto') {
const { tx_hash, status } = ev.payload;
await db.none(
`UPDATE user_transactions
SET crypto_status = CASE
WHEN status = 'confirmed' THEN 'succeeded'
WHEN status = 'reverted' THEN 'failed'
ELSE 'unknown'
END
WHERE crypto_tx_hash = $1`,
[tx_hash]
);
}
await db.none(`UPDATE webhook_events SET processed = TRUE WHERE id = $1`, [ev.id]);
}
}
Why: Ordering guarantees that a later “failed” event cannot overwrite an earlier “succeeded” event for the same rail. The unknown bucket captures any status that does not map cleanly.
Confirm: Insert a test webhook payload manually and run processEvents(). Verify that the corresponding transaction row updates as expected.
Step 4 – Add a periodic reconciliation job
Even with webhooks, network outages can drop events. Run a nightly job that scans for rows where either status is still pending after a timeout and queries the external API directly.
async function reconcile() {
const stale = await db.any(
`SELECT * FROM user_transactions
WHERE (fiat_status = 'pending' AND created_at < now() - interval '30 seconds')
OR (crypto_status = 'pending' AND created_at < now() - interval '10 minutes')`
);
for (const tx of stale) {
// Re‑query Stripe
if (tx.fiat_status === 'pending') {
const pi = await stripe.paymentIntents.retrieve(tx.fiat_intent_id);
await db.none(
`UPDATE user_transactions SET fiat_status = $1 WHERE id = $2`,
[pi.status === 'succeeded' ? 'succeeded' : 'failed', tx.id]
);
}
// Re‑query crypto provider
if (tx.crypto_status === 'pending') {
const status = await queryCryptoStatus(tx.crypto_tx_hash);
await db.none(
`UPDATE user_transactions SET crypto_status = $1 WHERE id = $2`,
[status, tx.id]
);
}
}
}
Timeout values: 30 seconds for fiat (Stripe’s typical response window) and 10 minutes for crypto (average block confirmation on Ethereum mainnet). Adjust based on the chain you use; for Solana you might use 30 seconds, for Bitcoin 20 minutes.
Confirm: Run the job manually after creating a pending transaction and verify the status changes to succeeded or failed within the expected window.
Step 5 – Implement exponential back‑off retries for external calls
Network glitches are inevitable. Wrap each external request in a retry helper that respects the idempotency key.
import retry from 'async-retry';
async function safeStripeIntent(params, idempotencyKey) {
return retry(async bail => {
try {
return await stripe.paymentIntents.create(params, { idempotencyKey });
} catch (err) {
if (err.type === 'card_error' || err.type === 'invalid_request_error') {
// Do not retry on client errors
bail(err);
return;
}
throw err; // retry
}
}, {
retries: 5,
minTimeout: 500, // 0.5 s
maxTimeout: 4000, // 4 s
factor: 2,
});
}
Why: The bail call prevents endless retries on unrecoverable errors (e.g., insufficient funds). The exponential back‑off caps total retry time at ~7 seconds per call, which is acceptable for a user‑initiated purchase.
Confirm: Simulate a transient network error (e.g., by blocking outbound traffic for 2 seconds) and observe that the request eventually succeeds without creating duplicate rows.
Step 6 – Surface a combined status to the mobile client
Expose an endpoint that returns a derived status: complete only when both rails are succeeded; partial_success when one succeeded and the other is failed; in_progress when any rail is pending or unknown.
router.get('/transaction/:id', async (req, res) => {
const tx = await db.oneOrNone(
`SELECT fiat_status, crypto_status FROM user_transactions WHERE id=$1`,
[req.params.id]
);
if (!tx) return res.status(404).send('Not found');
let overall;
if (tx.fiat_status === 'succeeded' && tx.crypto_status === 'succeeded')
overall = 'complete';
else if (tx.fiat_status === 'failed' && tx.crypto_status === 'failed')
overall = 'failed';
else if (tx.fiat_status === 'succeeded' || tx.crypto_status === 'succeeded')
overall = 'partial_success';
else
overall = 'in_progress';
res.json({ overall, fiat_status: tx.fiat_status, crypto_status: tx.crypto_status });
});
Confirm: Hit the endpoint with curl after each stage and verify the JSON reflects the expected overall value.
Step 7 – Guard against double refunds
When a transaction ends in failed on either rail, the refund logic must check the other rail’s status before issuing a second refund.
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)