Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 048 / 100

Async payment methods: a practical guide for developers

Payments that complete later.

Expona Admin9 min read

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:

typescript
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:

  1. Create a PaymentRecord with status pending.
  2. Call stripe.paymentIntents.create() with the customer's bank details.
  3. Receive status processing from Stripe.
  4. Update PaymentRecord.status to processing and set expectedConfirmationBy based on the payment method (e.g., current time + 3 days for SEPA).
  5. Hold the order in a pending_payment fulfillment state.
  6. Listen for webhooks from Stripe.

Here's how you might handle the payment intent creation:

typescript
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:

typescript
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.

typescript
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.

typescript
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles