Stripe interview questions: patterns, pitfalls and fixes
Payment engineering roles demand deep knowledge of transaction mechanics, state management, and reconciliation workflows. Interviewers at Stripe and payment-forward companies ask probing questions about authorization versus capture, idempotency, webhook reliability, and reconciliation—not to trip you up, but because these patterns determine whether your payment system processes millions reliably or hemorrhages money through duplicates, race conditions, and lost transactions.
We've worked through enough production incidents to know which answers reveal experience and which reveal gaps. This guide covers the core patterns, real mistakes we've seen in the field, and fixes that work.
Authorization vs capture: hold funds first, take them later
The most fundamental question in payment engineering separates novices from practitioners: what's the difference between authorization and capture?
Authorization reserves funds on the customer's card without moving money. The issuing bank checks whether the account has sufficient balance and fraud signals look acceptable. If approved, the bank holds that amount for a limited window (typically 7–10 days). The customer sees a pending charge; money hasn't moved yet.
Capture triggers settlement. You tell Stripe to settle an authorized charge, and funds flow from the customer's account to your merchant account, minus processing fees. Capture must happen within the authorization window, or the hold expires and money is released.
Why separate them? Real-world flexibility:
- E-commerce fulfillment: Authorize at checkout, capture after order ships. If the item goes out of stock before fulfillment, you release the hold without charging.
- Rental and travel: Authorize a deposit, capture the final amount after use (e.g., after the rental period ends and you calculate damage).
- Fraud prevention: Authorize, run risk checks, then decide whether to capture or void.
Here's a typical flow in code:
// Step 1: Create payment intent, authorize only
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000, // $100.00
currency: 'usd',
payment_method: paymentMethodId,
confirm: true,
capture_method: 'manual', // Hold funds, don't settle yet
});
// paymentIntent.status === 'requires_capture'
// Step 2: Later, after order ships
await stripe.paymentIntents.capture(paymentIntent.id);
// paymentIntent.status === 'succeeded'
A common mistake: capturing immediately. If you always capture at payment intent creation, you lose the ability to void if fulfillment fails. Build authorization-then-capture workflows from the start, even if you don't use the flexibility immediately. Your fraud team and operations team will thank you.
How do you make payments idempotent? Idempotency keys and unique constraints
Idempotency is non-negotiable in payments. The network is unreliable. Your server might crash between creating a charge and storing the result. Your client might retry. A webhook might arrive twice. You must guarantee that the same logical payment action, retried multiple times, results in exactly one charge.
Stripe's idempotency key is the first line of defense. Every API request to Stripe can include an Idempotency-Key header—a unique string (typically a UUID). If Stripe receives the same key twice, it returns the cached response from the first request without re-processing.
Your database's unique constraint is the second. You must store payment metadata (Stripe charge ID, amount, customer ID) in your database with a unique constraint on a logical key. This prevents application-level retries from creating duplicates even if Stripe's cache expires.
Practical example:
// Database schema
CREATE TABLE charges (
id SERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
stripe_charge_id VARCHAR(255),
amount_cents INT NOT NULL,
status VARCHAR(50) NOT NULL,
created_at TIMESTAMP,
UNIQUE(order_id, customer_id) -- Logical key
);
// Application code
const idempotencyKey = `order-${orderId}-${Date.now()}`;
try {
const charge = await stripe.paymentIntents.create(
{
amount: 5000,
currency: 'usd',
payment_method: methodId,
confirm: true,
},
{ idempotencyKey } // Stripe-level idempotency
);
// Insert into DB with unique constraint
await db.query(
'INSERT INTO charges (order_id, customer_id, stripe_charge_id, amount_cents, status) VALUES ($1, $2, $3, $4, $5)',
[orderId, customerId, charge.id, 5000, charge.status]
);
} catch (error) {
if (error.code === 'UNIQUE_VIOLATION') {
// We've already processed this payment
const existing = await db.query(
'SELECT * FROM charges WHERE order_id = $1 AND customer_id = $2',
[orderId, customerId]
);
return existing.rows[0];
}
throw error;
}
Pitfall: generating idempotency keys with random UUIDs each retry. Use deterministic keys derived from order ID and payment attempt number so retries use the same key. Pitfall two: trusting only Stripe's cache. Cache expires; your database doesn't. Always query your database first before attempting a charge.
How do you handle webhooks? Verify, enqueue, process idempotently, tolerate out-of-order
Webhooks are Stripe's way of telling you that something happened. A payment succeeded. A refund was issued. A dispute was filed. Webhooks are asynchronous, unreliable, and can arrive out of order or duplicated. You must build systems that absorb this chaos.
The pattern has four steps:
- Verify the signature. Stripe signs each webhook with an HMAC. Verify it using your webhook signing secret. Unsigned or tampered webhooks are rejected immediately.
- Enqueue the event. Don't process the webhook synchronously in the HTTP request. Queue it (Redis, Kafka, SQS). Return 200 OK to Stripe immediately so it stops retrying.
- Process idempotently. Your webhook handler must be idempotent. If the same event is processed twice, the second run should be a no-op. Store webhook event IDs in your database and check before processing.
- Tolerate out-of-order delivery. Event B might arrive before Event A even though A happened first. Don't assume order. Query Stripe or your payment state table to determine the current truth.
Here's production code:
// Express route
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Enqueue immediately
await queue.enqueue('stripe_webhook', { eventId: event.id, eventType: event.type, data: event.data });
res.json({ received: true });
});
// Worker process
async function processStripeWebhook(job) {
const { eventId, eventType, data } = job.data;
// Check if already processed
const existing = await db.query(
'SELECT * FROM webhook_events WHERE stripe_event_id = $1',
[eventId]
);
if (existing.rows.length > 0) {
return; // Already processed, idempotent
}
try {
if (eventType === 'payment_intent.succeeded') {
const paymentIntentId = data.object.id;
await handlePaymentSuccess(paymentIntentId);
} else if (eventType === 'charge.refunded') {
const chargeId = data.object.id;
await handleRefund(chargeId);
}
// Record successful processing
await db.query(
'INSERT INTO webhook_events (stripe_event_id, event_type, processed_at) VALUES ($1, $2, NOW())',
[eventId, eventType]
);
} catch (error) {
console.error(`Failed to process webhook ${eventId}:`, error);
throw error; // Retry by queue
}
}
Critical mistake: processing webhooks synchronously in the HTTP handler. You'll hit timeouts and Stripe will retry endlessly. Mistake two: trusting event order. Always query the current state from Stripe or your database before updating based on a webhook event. Mistake three: not storing processed event IDs. You'll process refunds twice, settle disputes multiple times, and confuse your accounting team.
How do you reconcile? Match payouts, balance transactions, and orders
Reconciliation is the art of proving that money is where it should be. Orders in your system should match transactions in Stripe, which should match payouts to your bank account.
The typical flow:
- Orders: Your system's source of truth for what customers purchased and should have paid.
- Balance transactions: Stripe's record of every charge, refund, fee, and reversal. The ledger.
- Payouts: Money actually deposited to your bank account, grouped by date.
Reconciliation surfaces mismatches:
- A customer paid but we didn't fulfill (order stuck in pending).
- We refunded an order but Stripe's refund failed (payout mismatch).
- A chargeback arrived and reduced payout but we didn't see the dispute webhook (out-of-order processing).
Build a daily reconciliation job:
async function dailyReconciliation() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
// Fetch payout from Stripe
const payouts = await stripe.payouts.list({
created: {
gte: Math.floor(yesterday.getTime() / 1000),
lt: Math.floor(new Date().getTime() / 1000),
},
status: 'paid',
});
for (const payout of payouts.data) {
// Fetch all balance transactions in this payout
const transactions = await stripe.balanceTransactions.list({
payout: payout.id,
});
let expectedAmount = 0;
const chargeIds = [];
for (const txn of transactions.data) {
expectedAmount += txn.net;
if (txn.type === 'charge') {
chargeIds.push(txn.source);
}
}
// Match against our orders
const ourOrders = await db.query(
'SELECT SUM((amount_cents - fee_cents) / 100.0) as total FROM orders WHERE stripe_charge_id = ANY($1)',
[chargeIds]
);
const ourTotal = ourOrders.rows[0].total || 0;
const stripeTotal = expectedAmount / 100; // Convert cents
if (Math.abs(ourTotal - stripeTotal) > 0.01) {
// Log discrepancy
await db.query(
'INSERT INTO reconciliation_gaps (payout_id, our_total, stripe_total, gap_amount, found_at) VALUES ($1, $2, $3, $4, NOW())',
[payout.id, ourTotal, stripeTotal, ourTotal - stripeTotal]
);
console.warn(`Reconciliation gap for payout ${payout.id}: $${(ourTotal - stripeTotal).toFixed(2)}`);
}
}
}
Run this daily. Keep a reconciliation log. When gaps appear (and they will), investigate whether a refund failed, a chargeback wasn't processed, or an order was created outside normal channels. Reconciliation is tedious but it catches fraud, system bugs, and money that simply vanished.
Common mistakes
- Not separating authorization from capture: Capture immediately and lose the flexibility to void or adjust.
- Random idempotency keys: Generate new keys on retry and create duplicate charges despite Stripe's cache.
- Synchronous webhook processing: Block the HTTP response on payment processing, timeout, and trigger infinite Stripe retries.
- Ignoring webhook ordering: Assume event B came after event A and corrupt state with out-of-order updates.
- No reconciliation discipline: Never compare Stripe's ledger to your orders. Money disappears quietly.
- Trusting only your database: Always verify against Stripe's API as the source of truth during investigations.
Key takeaways
- Authorization holds funds; capture settles them. Separate them to enable reversals, fraud checks, and dynamic amounts.
- Idempotency requires both Stripe's idempotency key and your database's unique constraint on the logical payment key.
- Webhooks are unreliable and arrive out of order. Verify signatures, enqueue asynchronously, process idempotently, and query state rather than trust event ordering.
- Reconciliation is the safety net. Match orders, balance transactions, and payouts daily to catch duplicates, reversals, and chargebacks.
- These patterns aren't fancy. They're boring, well-tested, and used in every production payment system at scale.






Comments
Loading comments…