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

# Asynchronous and Manual Fulfillment

> Deliver paid order line items later through a dynamic webhook, the dashboard, or the Developer API

## Overview

Payment and delivery are separate in Shoppex. An order can be paid with `status: "COMPLETED"` while one of its line items is still waiting for delivery.

Use asynchronous fulfillment when your system needs more than 15 seconds to generate content. Use manual fulfillment when a merchant needs to prepare an account, link, code, or file after payment.

<Info>
  Fulfillment is tracked per line item. The invoice payment status does not change when a line item moves between delivery states.
</Info>

## Delivery States

| State                  | Meaning                                                                            |
| ---------------------- | ---------------------------------------------------------------------------------- |
| `PENDING`              | The invoice is not paid yet, or automated dynamic delivery is currently in flight. |
| `AWAITING_FULFILLMENT` | Shoppex expects the vendor or merchant to provide content later.                   |
| `DELIVERED`            | Delivery content was stored. `delivered_at` contains the delivery time.            |
| `FAILED`               | Automated dynamic delivery failed. Merchant action is required.                    |

A line item enters `AWAITING_FULFILLMENT` in either of these cases:

* its dynamic delivery callback returns an explicit `{"status":"pending"}` acknowledgement
* it belongs to a `DYNAMIC` product that has no `dynamic_webhook` URL configured

The second case is the manual fulfillment lane. After payment, the merchant can deliver the line item from the dashboard or through the Developer API.

## Read Fulfillment State

Order read responses expose these fields on every item. The example below shows the relevant response fields:

```json theme={"system"}
{
  "data": {
    "id": "order_db_123",
    "uniqid": "order_123",
    "status": "COMPLETED",
    "items": [
      {
        "id": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
        "delivery_status": "AWAITING_FULFILLMENT",
        "delivered_at": null
      }
    ]
  }
}
```

`id` is the line item ID used as `{item_id}` in the fulfillment endpoints. The order path accepts the same order identifier used by the existing order endpoints.

## Fulfill a Line Item

```http theme={"system"}
POST /dev/v1/orders/{id}/items/{item_id}/fulfill
```

The order must be paid with `status: "COMPLETED"`, and the line item must not already be delivered. Provide at least one of `message`, `codes`, `links`, or `files`.

```bash cURL theme={"system"}
curl https://api.shoppex.io/dev/v1/orders/order_123/items/018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f/fulfill \
  -X POST \
  -H "Authorization: Bearer shx_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: fulfill-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1" \
  -d '{
    "message": "Your account is ready.",
    "codes": ["KEY-1", "KEY-2"],
    "links": [
      {
        "label": "Open your account",
        "url": "https://vendor.example.com/access/abc123"
      }
    ],
    "files": [
      {
        "attachment_id": "018f6f2e-7cb1-7a04-92f9-1cc8d7c8f413"
      }
    ],
    "notify_customer": true
  }'
```

A successful response returns the delivered line item content:

```json theme={"system"}
{
  "data": {
    "line_item_id": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
    "delivery_status": "DELIVERED",
    "delivered_at": "2026-08-04T09:15:00.000Z",
    "delivered_items": {
      "delivery_type": "MANUAL",
      "deliveryType": "MANUAL",
      "service_text": "Your account is ready.",
      "serviceText": "Your account is ready.",
      "license_keys": [
        { "key": "KEY-1" },
        { "key": "KEY-2" }
      ],
      "licenseKeys": [
        { "key": "KEY-1" },
        { "key": "KEY-2" }
      ],
      "external_urls": [
        {
          "label": "Open your account",
          "url": "https://vendor.example.com/access/abc123"
        }
      ],
      "externalUrls": [
        {
          "label": "Open your account",
          "url": "https://vendor.example.com/access/abc123"
        }
      ],
      "downloads": [
        {
          "id": "018f6f2e-7cb1-7a04-92f9-1cc8d7c8f413",
          "attachmentId": "018f6f2e-7cb1-7a04-92f9-1cc8d7c8f413",
          "title": "setup-guide.pdf",
          "filename": "setup-guide.pdf",
          "fileName": "setup-guide.pdf",
          "extension": "pdf",
          "size": 184320
        }
      ],
      "count": 5,
      "fulfilled_by": "API",
      "fulfilled_at": "2026-08-04T09:15:00.000Z"
    }
  }
}
```

<Note>
  Each `files[].attachment_id` must refer to an existing attachment owned by the same shop. Upload attachments in the dashboard before calling this endpoint. If any attachment is missing or belongs to another shop, the entire request fails and no partial delivery is stored.
</Note>

### Customer Email

`notify_customer` controls the buyer's “product ready” email:

* omit it or set it to `true` to request the customer email after the content is stored
* set it to `false` to store the content without sending that email

The shop's normal customer-email settings and notification preferences still apply.

Shoppex also emits `order:item.delivered` after a successful fulfillment.

### Error Responses

Both endpoints reject invalid requests with the standard error envelope. The `message` distinguishes the cause:

| HTTP                                                             | Cause                                                                                       | Retryable?                                                |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `404 NOT_FOUND`                                                  | Order or line item does not exist for this shop (also returned for malformed line-item ids) | No — check the ids from the order response                |
| `422 VALIDATION_ERROR` — "Order is not in a fulfillable state"   | The order is not `COMPLETED` yet (unpaid, refunded, voided, or stock-held)                  | Yes — once the order is paid                              |
| `422 VALIDATION_ERROR` — "already has delivered content"         | The line item was already delivered (possibly by a concurrent delivery)                     | No                                                        |
| `422 VALIDATION_ERROR` — content message                         | Invalid body: empty content, malformed link URL, oversized fields                           | No — fix the request body and use a new `Idempotency-Key` |
| `422 VALIDATION_ERROR` — "Attachment … does not exist"           | `files[].attachment_id` missing or owned by another shop                                    | No — upload the attachment first                          |
| `422 VALIDATION_ERROR` — "Only failed deliveries can be retried" | Retry called on a line item that is not `FAILED`                                            | No                                                        |
| `422 VALIDATION_ERROR` — "No failed automated delivery exists"   | Retry called where no dynamic-delivery attempt failed                                       | No — fulfill the item manually instead                    |

## Idempotency

Both fulfillment endpoints support the standard `Idempotency-Key` header. Reuse the same key when retrying the same request after a network interruption. Shoppex scopes the key to the authenticated API key and route and replays the stored response during the 24-hour idempotency window.

For example, every retry of the delivery above should keep `Idempotency-Key: fulfill-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1`.

<Warning>
  Rejected responses (4xx) are replayed too: after correcting a rejected request body — for example a wrong `attachment_id` — send the corrected request with a **new** `Idempotency-Key`. Reusing the old key returns either the stored error or a key-conflict error.
</Warning>

<Warning>
  Idempotency does not allow a line item to be delivered twice. Once `delivered_at` is set, another fulfillment attempt is rejected even if it uses a different key.
</Warning>

## Retry Failed Dynamic Delivery

```http theme={"system"}
POST /dev/v1/orders/{id}/items/{item_id}/retry-delivery
```

This endpoint re-queues a failed dynamic delivery and moves the line item from `FAILED` to `PENDING`.
It does not take a request body.

```bash cURL theme={"system"}
curl https://api.shoppex.io/dev/v1/orders/order_123/items/018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f/retry-delivery \
  -X POST \
  -H "Authorization: Bearer shx_your_api_key" \
  -H "Idempotency-Key: retry-order_123-018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f-v1"
```

```json theme={"system"}
{
  "data": {
    "line_item_id": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
    "delivery_status": "PENDING",
    "retried": true
  }
}
```

<Warning>
  Retrying can cause a double delivery. A timeout or network error is ambiguous: the vendor may have provisioned the product before Shoppex received the failure. Check the vendor system or ensure it deduplicates by `X-Shoppex-Idempotency-Key` before retrying.
</Warning>

Only a `FAILED` dynamic line item with a failed delivery event can be retried. If no retryable event exists, fulfill the line item manually instead.

## Fulfillment Webhook Events

These are normal merchant webhook events. Subscribe to them in **Settings → Webhooks** or through the Developer API. They use the normal event-webhook signature, not the product's dynamic delivery signing secret.

### `order:item.delivered`

Emitted after late content is stored through manual or API fulfillment.

```json theme={"system"}
{
  "event": "order:item.delivered",
  "data": {
    "invoiceId": "order_123",
    "lineItemId": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
    "productId": "product_db_123",
    "fulfilledBy": "API"
  },
  "created_at": 1785834900
}
```

### `order:item.delivery_failed`

Emitted when the single dynamic delivery attempt ends in a terminal failure. Shoppex also marks the line item `FAILED` and notifies the merchant.

```json theme={"system"}
{
  "event": "order:item.delivery_failed",
  "data": {
    "invoiceId": "order_123",
    "lineItemId": "018f6f2e-7c3a-7b21-9d4e-5a1b2c3d4e5f",
    "productId": "product_db_123",
    "error": "Dynamic delivery request timed out after 15000ms"
  },
  "created_at": 1785834000
}
```

<Note>
  These events report fulfillment state. They do not change or replace payment events such as `order:paid`.
</Note>

## Next Steps

<CardGroup cols={2}>
  <Card title="Dynamic Product Delivery" icon="bolt" href="/api-reference/webhooks/dynamic-delivery">
    Implement the synchronous and asynchronous callback contract.
  </Card>

  <Card title="Webhook Events" icon="webhook" href="/api-reference/webhooks/events">
    Verify normal merchant webhook signatures and review event payloads.
  </Card>
</CardGroup>
