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

Order fulfillment after payment: lessons from production

Ship only when money is safe.

Expona Admin10 min read

We've all seen the developer horror story: customer payment succeeds, browser redirects, but the order never ships. Six months later, support finds the fulfillment job crashed silently. Order fulfillment after payment: lessons from production means treating the webhook as your source of truth, not the client-side redirect. We'll walk through how to keep orders moving safely, even when things break.

Webhook triggers fulfillment — Not the browser redirect

Your payment form completes, the browser shows a success page, and the customer closes the tab feeling confident. Meanwhile, your backend is still deciding whether to fulfill the order. This is backwards. The moment you rely on the redirect to trigger fulfillment, you've created a race condition and lost your audit trail.

In production systems, the webhook is your contract with Stripe. When a payment succeeds, Stripe sends you a signed event. That event is what the business rules respond to. The browser redirect is cosmetic—it tells the user "something happened," but it doesn't trigger the critical path.

Here's what we typically see in mature e-commerce platforms:

  1. Customer submits payment form
  2. Your backend creates a PaymentIntent and returns the client ID
  3. Browser confirms the payment with Stripe
  4. Browser redirects to a status page that queries your order state (not a fulfillment trigger)
  5. Stripe sends payment_intent.succeeded webhook
  6. Webhook handler validates the event, checks idempotency, and queues fulfillment
  7. Fulfillment worker processes the queue asynchronously

Step 7 is where the order actually ships. Everything before it is setup. Here's a minimal webhook handler:

typescript

async function handlePaymentIntentSucceeded(event: Stripe.Event) {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  const { metadata } = paymentIntent;

  if (!metadata?.order_id) {
    logger.warn('No order_id in metadata', { paymentIntent });
    return { statusCode: 400 };
  }

  const orderId = metadata.order_id;

  try {
    // Check if fulfillment was already triggered for this payment
    const existingFulfillment = await db.fulfillment.findUnique({
      where: { paymentIntentId: paymentIntent.id }
    });

    if (existingFulfillment) {
      logger.info('Fulfillment already queued', { orderId, paymentIntentId: paymentIntent.id });
      return { statusCode: 200 };
    }

    // Queue the fulfillment job
    await queue.enqueue({
      type: 'fulfillment',
      orderId,
      paymentIntentId: paymentIntent.id,
      amount: paymentIntent.amount_received,
      currency: paymentIntent.currency
    });

    logger.info('Fulfillment queued', { orderId, paymentIntentId: paymentIntent.id });
    return { statusCode: 202 };
  } catch (error) {
    logger.error('Failed to queue fulfillment', { orderId, error });
    throw error; // Stripe will retry the webhook
  }
}

Notice we're not fulfilling immediately. We're enqueueing a job and letting Stripe's retry logic give us confidence the message arrived. The browser doesn't get a say in whether the order ships.

Exactly once — Idempotent fulfillment by order id

Webhooks can arrive out of order, duplicate, or arrive after a retry. Your fulfillment logic must survive these scenarios without shipping the same order twice or charging the customer twice for processing.

The pattern is simple: make every fulfillment operation idempotent by checking state before acting. Use the order ID as your idempotency key, not the payment ID, because one order might have multiple payments (failed card, retry, etc.).

In production, we store a fulfillment record before we do anything irreversible:

typescript

async function processFulfillmentJob(job: FulfillmentJob) {
  const { orderId, paymentIntentId } = job;

  // Begin transaction
  const tx = await db.$transaction(async (tx) => {
    // Check if fulfillment already exists
    const existingFulfillment = await tx.fulfillment.findUnique({
      where: { orderId }
    });

    if (existingFulfillment) {
      logger.info('Fulfillment already processed', { orderId, status: existingFulfillment.status });
      return existingFulfillment;
    }

    // Fetch the order
    const order = await tx.order.findUnique({
      where: { id: orderId },
      include: { items: true, customer: true }
    });

    if (!order) {
      throw new Error(`Order not found: ${orderId}`);
    }

    if (order.status !== 'pending_fulfillment') {
      throw new Error(`Order in invalid state: ${order.status}`);
    }

    // Create fulfillment record BEFORE calling external services
    const fulfillment = await tx.fulfillment.create({
      data: {
        orderId,
        paymentIntentId,
        status: 'in_progress',
        startedAt: new Date()
      }
    });

    return { fulfillment, order };
  });

  const { fulfillment, order } = tx;

  try {
    // Now call external fulfillment provider (warehouse, print-on-demand, etc.)
    const shipment = await warehouseClient.createShipment({
      orderId: order.id,
      items: order.items,
      shippingAddress: order.shippingAddress,
      customerEmail: order.customer.email
    });

    // Update fulfillment with result
    await db.fulfillment.update({
      where: { id: fulfillment.id },
      data: {
        status: 'completed',
        externalShipmentId: shipment.id,
        completedAt: new Date()
      }
    });

    // Update order status
    await db.order.update({
      where: { id: orderId },
      data: { status: 'shipped' }
    });

    logger.info('Fulfillment completed', { orderId, shipmentId: shipment.id });
  } catch (error) {
    // Mark as failed but keep the record for retry
    await db.fulfillment.update({
      where: { id: fulfillment.id },
      data: {
        status: 'failed',
        error: error.message,
        failedAt: new Date()
      }
    });

    logger.error('Fulfillment failed', { orderId, error });
    throw error; // Re-throw so job queue can retry
  }
}

The key insight: we write the fulfillment record to the database before we call the warehouse API. If the warehouse call fails, we have evidence the order was attempted. If the webhook retries, we check that record and skip the warehouse call entirely. The warehouse gets one request per order, guaranteed.

Partial failures — Retry fulfillment jobs, not the payment

Sometimes the warehouse is down. Sometimes the shipping address is malformed. Sometimes there's a transient network error. You need a retry strategy that doesn't re-process the payment.

The pattern: separate the payment confirmation from fulfillment. Use an exponential backoff queue that retries fulfillment jobs independently. If a job fails after max retries, escalate to a human (support team) rather than trying to "fix" the payment.

Here's how we model the retry logic:

typescript

interface FulfillmentJob {
  id: string;
  orderId: string;
  paymentIntentId: string;
  attemptCount: number;
  maxAttempts: number;
  nextRetryAt: Date | null;
  lastError?: string;
  createdAt: Date;
}

async function enqueueFulfillmentWithRetry(
  orderId: string,
  paymentIntentId: string
) {
  const job: FulfillmentJob = {
    id: crypto.randomUUID(),
    orderId,
    paymentIntentId,
    attemptCount: 0,
    maxAttempts: 5,
    nextRetryAt: new Date(),
    createdAt: new Date()
  };

  await db.fulfillmentJob.create({ data: job });
  await queue.enqueue(job);
}

async function retryFulfillmentJob(job: FulfillmentJob, error: Error) {
  job.attemptCount += 1;
  job.lastError = error.message;

  if (job.attemptCount >= job.maxAttempts) {
    // Max retries exceeded — escalate to human
    await db.fulfillmentJob.update({
      where: { id: job.id },
      data: {
        status: 'escalated',
        escalatedAt: new Date()
      }
    });

    await notifySupport({
      orderId: job.orderId,
      error: error.message,
      attempts: job.attemptCount
    });

    logger.error('Fulfillment job escalated after max retries', {
      orderId: job.orderId,
      attempts: job.attemptCount
    });

    return;
  }

  // Exponential backoff: 1min, 2min, 4min, 8min, 16min
  const delaySeconds = Math.pow(2, job.attemptCount - 1) * 60;
  job.nextRetryAt = new Date(Date.now() + delaySeconds * 1000);

  await db.fulfillmentJob.update({
    where: { id: job.id },
    data: {
      attemptCount: job.attemptCount,
      nextRetryAt: job.nextRetryAt,
      lastError: job.lastError
    }
  });

  await queue.enqueueAt(job, job.nextRetryAt);
  logger.info('Fulfillment job scheduled for retry', {
    orderId: job.orderId,
    attempt: job.attemptCount,
    nextRetryAt: job.nextRetryAt
  });
}

This approach keeps fulfillment separate from payment. A payment is final—it succeeded or it failed. But fulfillment is a process that can recover from transient failures. By the time you escalate to support, you've given the system a real chance to succeed.

Notify — Emails and status updates for customers

While fulfillment jobs queue and retry behind the scenes, your customer is in the dark. Send transactional emails at key moments: payment confirmed, order processing, order shipped, tracking available. These emails also serve as proof to support that the system worked.

Couple notifications to fulfillment state changes, not payment events:

typescript

async function notifyCustomerOfFulfillmentStateChange(
  orderId: string,
  oldStatus: string,
  newStatus: string
) {
  const order = await db.order.findUnique({
    where: { id: orderId },
    include: { customer: true, fulfillment: true }
  });

  if (!order) return;

  const fulfillment = order.fulfillment;
  const templateData = {
    orderId: order.id,
    customerName: order.customer.firstName,
    estimatedDelivery: fulfillment?.estimatedDeliveryDate,
    trackingUrl: fulfillment?.trackingUrl
  };

  let templateId: string;
  let sendAt = new Date();

  switch (newStatus) {
    case 'in_progress':
      templateId = 'order_processing';
      break;
    case 'completed':
      templateId = 'order_shipped';
      // Stagger the shipping notification by 1 hour to let systems sync
      sendAt = new Date(Date.now() + 3600 * 1000);
      break;
    case 'failed':
      templateId = 'order_fulfillment_failed';
      // Send immediately and flag for manual review
      await flagOrderForManualReview(orderId, 'Fulfillment failed');
      break;
    default:
      return;
  }

  await emailQueue.enqueueAt(
    {
      customerId: order.customerId,
      orderId,
      templateId,
      data: templateData,
      to: order.customer.email
    },
    sendAt
  );

  logger.info('Customer notification queued', { orderId, newStatus, templateId });
}

Don't send email from the fulfillment handler directly. Queue the email job, just like you queue the fulfillment job. This decouples notification from fulfillment logic and lets you retry emails independently if your email service is slow.

Also expose order status to the customer via a dashboard or order detail page. Let them see that fulfillment is in progress, even if the webhook is still retrying. This reduces support tickets dramatically.

Common mistakes

  • Fulfilling on browser redirect: The browser is not a reliable trigger. If it crashes or closes, you lose the signal. Always use webhooks.
  • Non-idempotent fulfillment: Calling the warehouse API without checking if you already did will create duplicate shipments. Always check state first.
  • Retrying payments instead of fulfillment: Once a payment is charged, it's charged. Retry the fulfillment job, not the payment. Charging twice is worse than not shipping.
  • Silent failures: If a fulfillment job fails and you don't log it or escalate it, the order will sit indefinitely. Use status fields and escalation thresholds.
  • Forgetting to verify webhooks: Always verify the webhook signature using Stripe's public key. An unverified webhook is a security vulnerability.
  • Coupling fulfillment and notifications: If the fulfillment succeeds but the email service is down, you're stuck. Queue both independently.

Key takeaways

  • Webhooks are your source of truth for payment events, not browser redirects or client callbacks.
  • Make fulfillment idempotent by checking order state before acting; use the order ID as your idempotency key.
  • Separate payment (final) from fulfillment (retriable); use exponential backoff to retry failed fulfillment jobs.
  • Track fulfillment state in the database; escalate to humans after max retries rather than silently failing.
  • Queue notifications independently of fulfillment; use transactional emails as proof and reduce customer confusion.
  • Always verify webhook signatures; always log state transitions; always give yourself a way to inspect failed orders in production.
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