Async payment methods: a practical guide for developers
Most payment flows assume instant confirmation: you charge a card, the gateway responds in milliseconds, and you ship immediately. But in the real world, many payment methods don't work that way. Bank debits, wire transfers, and certain regional payment schemes complete asynchronously—meaning the funds arrive (or fail to arrive) hours or days later. Understanding how to handle async payment methods: a practical guide for developers is essential when building payment systems that don't lose money or frustrate customers through premature fulfillment.
Why async payment methods exist
Asynchronous payments aren't a bug; they're a feature of how financial infrastructure actually works. When a customer pays via ACH (Automated Clearing House), SEPA Direct Debit, or bank transfer, the payment doesn't settle instantly. Instead, the initiating bank queues the transaction, the clearing network processes it overnight or over several days, and the receiving bank finally credits your account. During that window, you don't know if the payment will succeed or fail.
Stripe, PayPal, and other major processors support these methods because they unlock entire customer segments—especially in Europe, Asia, and emerging markets where bank debit is the preferred payment method. But this asynchronicity creates a fundamental challenge: how do you fulfill an order when you won't know the payment status for 24–72 hours?
Bank debits can take days
Let's make this concrete. A customer in Germany buys a SaaS subscription for €99 via SEPA Direct Debit. The payment is initiated immediately. Stripe returns a status of processing. At this moment:
- The funds haven't left the customer's bank.
- Your merchant account hasn't been credited.
- You don't know if the customer has sufficient funds or if they'll dispute the charge.
Over the next 1–3 business days, the clearing network processes the debit. The customer's bank either approves or rejects it. Only then does Stripe webhook you with a final status of succeeded or failed.
ACH payments in the US follow a similar pattern. A customer initiates a payment on Monday evening. The ACH window closes, and the clearing happens overnight. Wednesday morning, the funds arrive. But you won't know for certain until that webhook comes through.
For e-commerce, this is manageable: hold the order, wait for confirmation, then ship. But for digital goods (SaaS, downloads, API access), the waiting period is awkward. Many teams solve this by granting immediate provisional access, then revoking it if the payment ultimately fails—or they simply don't accept bank debits for immediate digital delivery.
Status processing until confirmed
Your payment database needs to track more than just "paid" and "unpaid". You need intermediate states. Here's a practical schema:
interface PaymentRecord {
id: string;
orderId: string;
customerId: string;
amount: number;
currency: string;
// Payment method type
paymentMethod: 'card' | 'bank_debit' | 'bank_transfer' | 'wallet';
// Stripe payment intent ID
stripePaymentIntentId: string;
// Status progression
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled';
// Async-specific fields
processingStartedAt?: Date;
expectedConfirmationBy?: Date;
confirmedAt?: Date;
failureReason?: string;
// Idempotency
idempotencyKey: string;
createdAt: Date;
updatedAt: Date;
}
When a customer initiates an async payment, your flow looks like this:
- Create a
PaymentRecordwith statuspending. - Call
stripe.paymentIntents.create()with the customer's bank details. - Receive status
processingfrom Stripe. - Update
PaymentRecord.statustoprocessingand setexpectedConfirmationBybased on the payment method (e.g., current time + 3 days for SEPA). - Hold the order in a
pending_paymentfulfillment state. - Listen for webhooks from Stripe.
Here's how you might handle the payment intent creation:
async function initiateAsyncPayment(
customerId: string,
amount: number,
paymentMethodId: string,
orderId: string
): Promise<PaymentRecord> {
const idempotencyKey = `${orderId}-${Date.now()}`;
// Create Stripe payment intent
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency: 'eur',
payment_method: paymentMethodId,
confirm: true,
customer: customerId,
metadata: {
orderId,
idempotencyKey,
},
},
{ idempotencyKey } // Idempotency at the API level too
);
// Determine expected confirmation window
let expectedConfirmationDays = 3;
if (paymentIntent.payment_method_types?.includes('sepa_debit')) {
expectedConfirmationDays = 3;
} else if (paymentIntent.payment_method_types?.includes('us_bank_account')) {
expectedConfirmationDays = 2;
}
const expectedConfirmationBy = new Date();
expectedConfirmationBy.setDate(expectedConfirmationBy.getDate() + expectedConfirmationDays);
// Insert into database
const payment = await db.payments.create({
orderId,
customerId,
amount,
currency: paymentIntent.currency,
stripePaymentIntentId: paymentIntent.id,
status: 'processing',
paymentMethod: 'bank_debit',
processingStartedAt: new Date(),
expectedConfirmationBy,
idempotencyKey,
});
return payment;
}
This approach ensures you handle network failures gracefully. If your API crashes after calling Stripe but before inserting the database record, the next retry with the same idempotency key will find the existing payment intent and return its current status.
Fulfill only on succeeded
This is where discipline matters. Many teams accidentally fulfill orders when a payment is merely processing, then scramble when it ultimately fails. The rule is simple: only transition an order to fulfilled when you receive a webhook confirming the payment succeeded.
Set up a webhook handler that processes Stripe's payment_intent.succeeded event:
async function handlePaymentIntentSucceeded(
paymentIntentId: string
): Promise<void> {
// Fetch the payment record
const payment = await db.payments.findOne({
stripePaymentIntentId: paymentIntentId,
});
if (!payment) {
console.error(`Payment record not found for intent ${paymentIntentId}`);
return;
}
// Verify the amount matches (prevent tampering)
const stripePayment = await stripe.paymentIntents.retrieve(paymentIntentId);
if (stripePayment.amount !== payment.amount) {
await db.payments.update(payment.id, {
status: 'failed',
failureReason: 'Amount mismatch',
});
return;
}
// Update payment record
await db.payments.update(payment.id, {
status: 'succeeded',
confirmedAt: new Date(),
});
// Fetch the order
const order = await db.orders.findById(payment.orderId);
// Fulfill the order
await fulfillOrder(order);
// Send confirmation email
await sendPaymentConfirmedEmail(order.customerId);
}
During the interim period—while payment is processing—communicate clearly with the customer. Send them an email like: "Your order is confirmed, and we're waiting for your bank to process the payment. You'll receive shipping updates once payment clears, typically within 2–3 business days."
For SaaS products, you might grant limited access during the processing window—read-only mode, reduced API rate limits, or a feature trial—then unlock full functionality once payment succeeds. This balances user experience with financial risk.
Notify customers of failures
Payments fail. Banks reject debits for insufficient funds, account closed, or customer dispute. When Stripe sends you a payment_intent.payment_failed webhook, you need to act quickly.
async function handlePaymentIntentFailed(
paymentIntentId: string,
lastPaymentError: Stripe.PaymentIntent.LastPaymentError | null
): Promise<void> {
const payment = await db.payments.findOne({
stripePaymentIntentId: paymentIntentId,
});
if (!payment) {
return;
}
// Update payment record
await db.payments.update(payment.id, {
status: 'failed',
failureReason: lastPaymentError?.message || 'Unknown error',
});
// Fetch order and customer
const order = await db.orders.findById(payment.orderId);
const customer = await db.customers.findById(payment.customerId);
// Cancel the order
await db.orders.update(order.id, {
status: 'payment_failed',
});
// Revoke access if granted during processing
if (order.product.type === 'saas') {
await revokeSaasAccess(customer.id);
}
// Send clear notification
await sendPaymentFailedEmail({
to: customer.email,
orderId: order.id,
reason: lastPaymentError?.message,
retryUrl: generateRetryPaymentLink(payment.id),
});
}
The email should be clear, actionable, and sympathetic. Provide the failure reason (e.g., "Insufficient funds") and give the customer an easy way to retry with a different payment method or update their bank details.
Also set up a cleanup process. If a payment hasn't been confirmed within the expected window (e.g., 5 days for SEPA), assume it failed and notify the customer. Stripe will eventually send you a webhook, but don't rely on it reaching you—treat webhooks as a notification mechanism, not the source of truth.
async function cleanupStaleAsyncPayments(): Promise<void> {
const now = new Date();
const stalePayments = await db.payments.find({
status: 'processing',
expectedConfirmationBy: { $lt: now },
});
for (const payment of stalePayments) {
// Double-check Stripe's current state
const stripeIntent = await stripe.paymentIntents.retrieve(
payment.stripePaymentIntentId
);
// Only update if Stripe also shows it's still processing or failed
if (
stripeIntent.status !== 'processing' &&
stripeIntent.status !== 'succeeded'
) {
await db.payments.update(payment.id, {
status: 'failed',
failureReason: 'Confirmation period expired',
});
await notifyPaymentFailure(payment);
}
}
}
Common mistakes
Mistake 1: Fulfilling on processing status. Even if the bank usually confirms within a day, that day isn't guaranteed. Some debits reverse weeks later. Wait for succeeded.
Mistake 2: Ignoring webhook delivery failures. Webhooks can be delayed or lost. Don't treat them as the only source of truth. Poll Stripe periodically for orders still in processing to catch failures your webhooks might have missed.
Mistake 3: Not setting customer expectations. Customers who expect instant gratification will panic when their digital goods don't arrive immediately. Communicate the timeline upfront, both in the UI and in confirmation emails.
Mistake 4: Poor error messaging. "Payment failed" is useless. "Your bank rejected the payment due to insufficient funds" is actionable. Include the reason from Stripe's lastPaymentError.message.
Mistake 5: Forgetting idempotency. If you retry payment creation without an idempotency key, you'll end up with duplicate payment intents and duplicate charges. Always use idempotency keys.
Key takeaways
- Async payment methods (bank debits, wire transfers) settle over days, not milliseconds. You won't know if they succeeded until a webhook arrives.
- Design your database schema with intermediate states:
pending,processing,succeeded,failed. Track timestamps and expected confirmation windows. - Never fulfill an order until you receive confirmation that payment actually succeeded. Provisional access during processing is fine; permanent fulfillment is not.
- Handle payment failures gracefully: update order status, revoke access if needed, and send clear, actionable emails to customers with a retry path.
- Use idempotency keys to prevent duplicate charges if your system retries payment creation.
- Don't rely solely on webhooks. Periodically query Stripe for stale payments and clean them up if confirmation windows have passed.
- Communicate timelines to customers upfront. Set expectations about when they'll receive their goods or access.






Comments
Loading comments…