> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nelkap.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Order fulfillment

> Ship each order paid through Stripe Checkout, email the tracking link, and ask for a review two weeks later.

When a customer pays, a lot has to happen, and exactly once: book the shipment, email the tracking link, and ask for a review once the parcel has had time to arrive. Stripe tells your app about each payment with a webhook, but it may send the same webhook twice, and your app may restart in the middle of an order.

With Nelkap, each paid order gets its own run. The run ships the order once, even when Stripe sends the payment twice, and picks up where it stopped if your app restarts. Then it sleeps for two weeks before asking for the review.

![Two fulfillOrder runs for the same order: the first ships it and sleeps before the review email, the second stops after the claim.](https://mintcdn.com/holotus/-VkqCXx08dglIr0L/images/stripe-orders-light.png?fit=max\&auto=format\&n=-VkqCXx08dglIr0L\&q=85\&s=e976586f50a7dda63ff3e6ce3a7747aa)

## Write the workflow

```ts nelkap/workflows/fulfill-order.ts theme={"theme":{"light":"light-plus","dark":"dark-plus"}}
import { sendEmail } from '../../lib/email';
import { claimOrder, createShipment } from '../../lib/orders';
import { nelkap } from '../client';

export async function fulfillOrder(order: {
  orderId: string;
  email: string;
}) {
  const claimId = await nelkap.step.run('claim id', () =>
    crypto.randomUUID(),
  );

  const isClaimed = await nelkap.step.run('claim', () =>
    claimOrder(order.orderId, claimId),
  );

  if (!isClaimed) {
    return;
  }

  const shipment = await nelkap.step.run('shipment', () =>
    createShipment(order.orderId),
  );

  await nelkap.step.run('shipped email', () =>
    sendEmail(
      order.email,
      'order-shipped',
      { trackingUrl: shipment.trackingUrl },
      `shipped_${order.orderId}`,
    ),
  );

  await nelkap.step.sleep('14 days');

  await nelkap.step.run('review email', () =>
    sendEmail(
      order.email,
      'review',
      {},
      `review_${order.orderId}`,
    ),
  );
}
```

The workflow takes the paid order as its payload:

1. It draws a random claim ID. The ID is saved, so every call of this run has the same one.
2. It claims the order with that ID. If another run already claimed it, this run stops here: the order is in good hands.
3. It books the shipment, and saves the tracking link.
4. It emails the tracking link to the customer.
5. It sleeps for 14 days, then asks for a review.

Replace the functions from `lib` with your own. `claimOrder` is the one that keeps an order from shipping twice. It writes the claim ID on the order only if the order has none yet, and returns whether the order now carries this ID:

```sql theme={"theme":{"light":"light-plus","dark":"dark-plus"}}
UPDATE orders SET claim_id = $2
WHERE id = $1 AND (claim_id IS NULL OR claim_id = $2)
RETURNING id;
```

The first run to claim the order gets a row back, and so does the same run if it claims again after a restart. A second run for the same order gets nothing. The emails pass an idempotency key to your email provider, so they go out once even if a crash cuts a step off before it's saved.

## Receive Stripe's webhook

Register the workflow with the event that starts it:

```ts nelkap/index.ts theme={"theme":{"light":"light-plus","dark":"dark-plus"}}
import { nelkap } from './client';
import { fulfillOrder } from './workflows/fulfill-order';

nelkap.registerWorkflows([
  {
    name: 'fulfillOrder',
    handler: fulfillOrder,
    events: ['order.paid'],
  },
]);

export { nelkap };
```

When you create the Checkout Session, pass your order's ID as `client_reference_id`. Then add a route for Stripe's webhook. It checks Stripe's signature, and emits `order.paid` for each completed checkout:

```ts app/api/stripe/route.ts theme={"theme":{"light":"light-plus","dark":"dark-plus"}}
import Stripe from 'stripe';

import { nelkap } from '@/nelkap';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      await request.text(),
      request.headers.get('stripe-signature') ?? '',
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;

    await nelkap.emit({
      event: 'order.paid',
      payload: {
        orderId: session.client_reference_id,
        email: session.customer_details?.email,
      },
    });
  }

  return new Response(null, { status: 200 });
}
```

In your Stripe dashboard, add a webhook endpoint with this route's URL and the `checkout.session.completed` event, and set its signing secret as `STRIPE_WEBHOOK_SECRET`.

The route answers Stripe once the event is emitted. If `emit` throws, the route answers with an error, and Stripe sends the webhook again later. See [Stripe's guide to webhooks](https://docs.stripe.com/webhooks).

## View the runs

Each paid order shows in your project's runs. Open one to see the claim, the shipment with its tracking link, and the email, then the sleep. Two weeks later, the review email goes out, and the run completes.

If Stripe sent the same payment twice, you'll see a second run for it that stopped after the claim, without shipping anything.

## Going further

* Tag each run with the order's ID, to find an order's runs in one search.
* Wait for your carrier's delivery event with [`step.wait`](https://docs.nelkap.com/guides/wait), and ask for the review a few days after delivery instead.
* Listen for `order.paid` in another workflow, for example to update your stock, without touching this one.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.