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

# Run a durable order workflow end to end

> Onboard a brand on the Temporal Engine, mint a key, connect a store, start an order-fulfillment workflow, watch its event stream, approve it at the review gate, and see what retries, cancellation and replay do.

This guide takes one order — `ORD-10042` for a brand called `acme-outdoors` — through the
Temporal Engine's `order-fulfillment` workflow: **reserve inventory → NSR gate → (human review) →
dispatch**. You create the brand and its API key, connect a connector, check readiness, stream
the workflow's history as server-sent events, and signal the review decision. The last section
deliberately breaks things so you can see what durability buys you.

Base URL `https://api.workstream.stateset.com`; auth `x-api-key: <key>` on every request; the
workflow id is always `order-fulfillment-{brand_uuid}-{order_id}`.

<Note>
  Steps 1–2 need a **tenant-scoped or global** key (creating a brand checks tenant access, and a
  minted key is never broader than its minter). From step 3 on, the brand key from step 2 is enough.
  The [overview](/api-reference/temporal/overview) explains the two scopes.
</Note>

<Steps>
  <Step title="Create the brand">
    A brand is the unit of configuration — connectors, policies and workflow bindings hang off it.
    `tenant_id`, `slug` and `display_name` are required; `status` defaults to `draft` and
    `routing_mode` to `legacy`. Send an `Idempotency-Key` header so a retried create returns the
    same brand (`idempotent_replay: true`) instead of a slug conflict.

    ```bash theme={null}
    curl --request POST https://api.workstream.stateset.com/v1/brands \
      --header "x-api-key: $STATESET_TENANT_KEY" \
      --header "content-type: application/json" \
      --header "idempotency-key: onboard-acme-outdoors-1" \
      --data '{
        "tenant_id": "0b7e6c2d-1f44-4a8e-9c3b-5d2e7f8a9b10",
        "slug": "acme-outdoors",
        "display_name": "Acme Outdoors"
      }'
    ```

    ```json theme={null}
    { "brand": { "id": "6f1c2a3e-9b7d-4e21-a5c8-0d2f4b6e8a10", "tenant_id": "0b7e6c2d-1f44-4a8e-9c3b-5d2e7f8a9b10",
                 "slug": "acme-outdoors", "display_name": "Acme Outdoors", "status": "draft", "routing_mode": "legacy",
                 "config_version": 1, "activated_at": null, "workflow_bindings": [] },
      "idempotent_replay": false }
    ```

    Keep `brand.id` — it is `$BRAND_ID` below. The reference for
    [`POST /v1/brands`](/api-reference/temporal/brands/v1-brands-create) lists the optional
    config fields (`region`, `default_locale`, `policy_set_key`, `quotas`, …);
    [Control plane](/next-temporal/control-plane#brand-lifecycle) covers draft → active.
  </Step>

  <Step title="Mint a brand-scoped API key">
    The body is optional — `label` defaults to `"<slug> key"`.

    ```bash theme={null}
    curl --request POST https://api.workstream.stateset.com/v1/brands/$BRAND_ID/api-keys \
      --header "x-api-key: $STATESET_TENANT_KEY" \
      --header "content-type: application/json" \
      --data '{ "label": "acme-outdoors fulfillment worker" }'
    ```

    ```json theme={null}
    { "token": "sk_live_Q3hX8m…",
      "key": { "id": "9a41d6b2-3c7e-4f10-8b2d-1e5f6a7c8d90", "tenant_id": "0b7e6c2d-1f44-4a8e-9c3b-5d2e7f8a9b10",
               "is_global": false, "brand_slugs": ["acme-outdoors"], "label": "acme-outdoors fulfillment worker",
               "subject": "apikey:acme-outdoors", "token_prefix": "sk_live_Q3hX8m", "created_by": "apikey:tenant-admin",
               "created_at": "2026-08-31T13:58:40Z", "last_used_at": null, "revoked_at": null } }
    ```

    <Warning>
      `token` is returned **once** — only its hash is stored, and
      [`GET …/api-keys`](/api-reference/temporal/brands/v1-brands-by-api-keys-get) never returns
      it. Export it as `$STATESET_API_KEY` now. A lost key is
      [revoked](/api-reference/temporal/brands/v1-brands-by-api-keys-by-revoke-create) and re-minted.
    </Warning>
  </Step>

  <Step title="Connect a connector and check readiness">
    `/connect` stores the credentials encrypted, re-points the brand's connectors, and with
    `activate: true` attempts activation in the same call. It accepts `gorgias`, `shopify` and
    `recharge` blocks; the same body sent to
    [`/onboarding/{brand_id}/test`](/api-reference/temporal/onboarding/v1-onboarding-by-test-create)
    first is a read-only probe that stores nothing and returns `{ "<type>": { "healthy", "error", "latency_ms" } }`.

    ```bash theme={null}
    curl --request POST https://api.workstream.stateset.com/v1/onboarding/$BRAND_ID/connect \
      --header "x-api-key: $STATESET_API_KEY" \
      --header "content-type: application/json" \
      --data '{
        "shopify": { "domain": "acme-outdoors.myshopify.com", "access_token": "shpat_…", "webhook_secret": "whsec_…" },
        "activate": true
      }'
    # → { "ok": true, "connected": ["shopify"], "activated": true,
    #     "readiness": { "brand": { "status": "active", … }, "validation": { "valid": true, "errors": [], "warnings": [], "checks": [] } } }
    ```

    If activation is blocked, `activated` is `false` and `readiness` is `{ "blocked": "<error>" }`;
    the credentials are still stored. The go-live gate live-probes every connector and is `ready`
    only when all are healthy **and there is at least one**:

    ```bash theme={null}
    curl https://api.workstream.stateset.com/v1/brands/$BRAND_ID/connectors/readiness \
      --header "x-api-key: $STATESET_API_KEY"
    ```

    ```json theme={null}
    { "ready": true, "healthy": 1, "total": 1,
      "connectors": [ { "connector_id": "c2e8f1a0-7b3d-4c9e-a1f2-3d4e5f6a7b8c", "connector_key": "shopify", "connector_type": "shopify",
                        "healthy": true, "latency_ms": 198, "error": null, "checked_at": "2026-08-31T14:01:07Z" } ] }
    ```
  </Step>

  <Step title="Start the order-fulfillment workflow">
    `fulfillment_tool` is the sync-server tool that dispatches to your 3PL (it varies per connector);
    `reservation_params` and `fulfillment_params` pass through to `create_inventory_reservation` and
    to `fulfillment_tool`. `review_cap_cents` overrides the default autonomy cap of \*\*50,000 cents
    ($500)** — totals at or over the cap always stop at the review gate, as this $689 order will.

    ```bash theme={null}
    curl --request POST https://api.workstream.stateset.com/v1/workflows/order-fulfillment/start \
      --header "x-api-key: $STATESET_API_KEY" \
      --header "content-type: application/json" \
      --data '{
        "brand_id": "6f1c2a3e-9b7d-4e21-a5c8-0d2f4b6e8a10",
        "order_id": "ORD-10042",
        "order_total_cents": 68900,
        "currency": "USD",
        "line_items": [ { "sku": "TENT-4P-GRN", "quantity": 1 }, { "sku": "STAKE-SET-12", "quantity": 2 } ],
        "reservation_params": { "order_id": "ORD-10042", "warehouse": "reno-01" },
        "fulfillment_tool": "create_order",
        "fulfillment_params": { "order_id": "ORD-10042", "ship_method": "ground" }
      }'
    ```

    ```json theme={null}
    { "workflow_id": "order-fulfillment-6f1c2a3e9b7d4e21a5c80d2f4b6e8a10-ORD-10042", "run_id": "7d3c9e2a-5b1f-4a8c-9e6d-2f1b0c3a4d5e" }
    ```

    The workflow id is deterministic per `(brand, order_id)`. POST the **same payload** again
    while the run is open and you get the same `workflow_id` and the existing `run_id` back — a
    duplicate checkout webhook cannot start a second fulfillment. POST a **different** payload
    under the same order id and the engine answers `409 IDEMPOTENCY_CONFLICT`
    (`workflow_id already exists with a different start payload`): either the order changed
    after checkout (cancel and restart) or two producers disagree about it.
  </Step>

  <Step title="Watch the status and the review queue">
    The status route runs the workflow's `current_status` query and returns a JSON **string**
    `"<status>:<phase>"`. Phases are `init`, `reserve_inventory`, `nsr_gate`, `review_gate`,
    `dispatch`; status stays `running` until a terminal one lands.

    ```bash theme={null}
    WF=order-fulfillment-6f1c2a3e9b7d4e21a5c80d2f4b6e8a10-ORD-10042
    curl https://api.workstream.stateset.com/v1/workflows/order-fulfillment/$WF/status \
      --header "x-api-key: $STATESET_API_KEY"
    # → "running:review_gate"

    curl https://api.workstream.stateset.com/v1/brands/$BRAND_ID/reviews/pending \
      --header "x-api-key: $STATESET_API_KEY"
    ```

    ```json theme={null}
    { "reviews": [ { "workflow_id": "order-fulfillment-6f1c2a3e9b7d4e21a5c80d2f4b6e8a10-ORD-10042", "workflow_type": "OrderFulfillmentWorkflow",
                     "phase": "review_gate", "status": "running", "started_at": "2026-08-31T14:02:11+00:00" } ],
      "truncated": false }
    ```

    Poll the review queue rather than remembered workflow ids — it lists everything parked at
    `running:review_gate` for the brand.
  </Step>

  <Step title="Stream the event history">
    `GET /v1/workflows/{workflow_id}/events` tails Temporal history as server-sent events. `event:`
    is the kind, `id:` the history event id, `data:` a JSON `WorkflowEvent` (`event_id`, `kind`,
    `ts`, plus `activity`, `attempt`, `signal`, `preview` or `error` when relevant). A keepalive
    arrives every 15 seconds and the stream closes after a terminal kind.

    ```bash theme={null}
    curl --no-buffer https://api.workstream.stateset.com/v1/workflows/$WF/events \
      --header "x-api-key: $STATESET_GLOBAL_KEY" \
      --header "accept: text/event-stream"
    ```

    ```text theme={null}
    id: 7
    event: activity_completed
    data: {"event_id":7,"kind":"activity_completed","ts":"2026-08-31T14:02:12.088Z","activity":"sync_agent_execute","preview":"{\"reservationId\":\"rsv_8f2e…\"}"}

    id: 15
    event: activity_completed
    data: {"event_id":15,"kind":"activity_completed","ts":"2026-08-31T14:02:13.902Z","activity":"nsr_decide","preview":"{\"decision_id\":\"dec_01J…\",\"outcome\":\"execute\"}"}
    ```

    Event 1 was `workflow_started`; after 15 comes silence: the NSR verdict was `execute`, but
    `68900 >= 50000` promoted it to an escalation and the workflow is sleeping on a durable timer.
    If the connection drops, reconnect with `Last-Event-ID: 15` and the engine replays from 16.

    <Warning>
      The streaming route authorises by workflow-id prefix, and `order-fulfillment-` is **not** in
      its brand-scoped allowlist (`rav2-`, `cp-response-automation-v2-`, `connector-`, `snooze-`,
      `sandbox-agent-`, `active-horizon-agent-`), so a brand key gets `400 workflow_id prefix does
                not grant streaming access to this principal`. Stream with a global key; status, review and
      cancel all work with the brand key.
    </Warning>
  </Step>

  <Step title="Approve at the review gate">
    The body is a `ReviewDecision`: `approved` is required; `reason`, `feedback` and
    `edited_text` are optional. Signal routes answer `202 Accepted` with an **empty body** — the
    decision is delivered to the workflow, not applied synchronously.

    ```bash theme={null}
    curl --request POST https://api.workstream.stateset.com/v1/workflows/order-fulfillment/$WF/review \
      --header "x-api-key: $STATESET_API_KEY" \
      --header "content-type: application/json" \
      --data '{ "approved": true, "reason": "High-value order, address verified with customer" }'
    # → HTTP/1.1 202 Accepted
    ```

    On the stream the signal lands as `signal_received` with `"signal":"set_review_decision"`,
    dispatch runs, and the workflow completes with the `OrderFulfillmentResult` in `preview`:

    ```text theme={null}
    id: 26
    event: workflow_completed
    data: {"event_id":26,"kind":"workflow_completed","ts":"2026-08-31T14:09:48.391Z","preview":"{\"order_id\":\"ORD-10042\",\"status\":\"fulfilled\",\"decision_id\":\"dec_01J…\",\"reservation\":{…},\"fulfillment\":{…},\"detail\":null}"}
    ```

    `GET …/status` now returns `"fulfilled:dispatch"` and the order has left the review queue;
    `decision_id` is the NSR decision that authorised the dispatch ([policy engine](/next-temporal/policy-engine)).
  </Step>
</Steps>

## Failure, cancellation and replay

**A connector blips mid-run.** Every activity runs under the engine's API retry policy: up to
**8 attempts**, exponential backoff from 1 s capped at 30 s (roughly a 90-second window). On the
stream that is `activity_failed` with the upstream `error`, then `activity_started` with
`attempt: 2`, `3`, … — the workflow's own state is untouched between attempts. Non-retryable
errors (4xx from the tool) fail fast and the run ends with `workflow_failed` and `error` set.

**Someone cancels while it waits for review.**

```bash theme={null}
curl --request POST https://api.workstream.stateset.com/v1/workflows/order-fulfillment/$WF/cancel \
  --header "x-api-key: $STATESET_API_KEY"
# → HTTP/1.1 202 Accepted
```

The gate wakes on the `cancel` signal, status becomes `"cancelled:review_gate"`, and the result
carries the compensation:

```json theme={null}
{ "order_id": "ORD-10042", "status": "cancelled", "decision_id": "dec_01J…",
  "reservation": { "reservationId": "rsv_8f2e…" }, "fulfillment": null,
  "detail": { "reason": "order total 68900 USD is at/over the autonomy cap",
              "compensation": { "released": true, "release": { "reservationId": "rsv_8f2e…" } } } }
```

Every terminal status that never dispatched — `blocked` (NSR said no, or the reviewer denied),
`escalated_expired` (nobody answered within the **7-day** review timeout), `cancelled` — goes
through the same chokepoint and releases the reservation; if the release itself fails the run
keeps its original status and reports `{ "released": false, "error": … }` in
`detail.compensation`. The whole run has a **14-day** execution timeout.

**The worker restarts.** Temporal rebuilds the workflow's state by replaying its history:
completed activities are not re-executed, and a run parked at the review gate is still parked
afterwards. The activities that *could* run twice carry deterministic idempotency keys
(`{brand}-{order}-reserve`, `-dispatch`, `-release`) so the sync server deduplicates them — the
same contract the [Order Operations](/guides/order-operations) guide asks of your own integrations.

## What you built

| Piece                 | What it is                                                         | Where to go deeper                                                                                                            |
| --------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| Brand `acme-outdoors` | The tenant unit that owns connectors, config and workflow bindings | [`POST /v1/brands`](/api-reference/temporal/brands/v1-brands-create)                                                          |
| Brand API key         | A key scoped to one brand; token shown once                        | [`POST …/api-keys`](/api-reference/temporal/brands/v1-brands-by-api-keys-create)                                              |
| Shopify connector     | Stored encrypted, live-probed by the readiness gate                | [`GET …/connectors/readiness`](/api-reference/temporal/brands/v1-brands-by-connectors-readiness-get)                          |
| One durable run       | `order-fulfillment-{brand}-ORD-10042`, gated, approved, dispatched | [`POST /v1/workflows/order-fulfillment/start`](/api-reference/temporal/workflows/v1-workflows-order-fulfillment-start-create) |
| An event tail         | SSE over Temporal history, resumable with `Last-Event-ID`          | [`GET /v1/workflows/{id}/events`](/api-reference/temporal/workflows/v1-workflows-by-events-get)                               |
| A review queue        | Everything parked at a gate, per brand                             | [`GET …/reviews/pending`](/api-reference/temporal/brands/v1-brands-by-reviews-pending-get)                                    |

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Unauthorized, or 403 creating the brand or key">
    `x-api-key` is missing or revoked — keys are hashed at rest, so mint a new one. `POST /v1/brands`
    needs tenant access for the body's `tenant_id`, and a brand key can only mint keys for its own
    brand: use the tenant or global credential for steps 1–2.
  </Accordion>

  <Accordion title="400 'workflow_id prefix does not grant streaming access' on /events">
    Expected with a brand key on an `order-fulfillment-` id — see the warning in step 6. Use a
    global key for the stream; status, review and cancel work with the brand key.
  </Accordion>

  <Accordion title="Readiness says ready: false">
    Read `connectors[].error` — each entry is a live probe, and a brand with **zero** connectors is
    never ready. Re-probe with `/onboarding/{brand_id}/test` until `healthy: true`, then `/connect`.
  </Accordion>

  <Accordion title="Workflow is stuck at running:review_gate, or 404 WORKFLOW_NOT_FOUND">
    Stuck at the gate is the design: it waits up to 7 days for a `review` signal and never times
    out into dispatching, so alert on the length of `GET …/reviews/pending` rather than on
    failures. Smaller orders escalate only when the NSR gate says so or is unreachable (it fails
    closed). A 404 means no run with that id exists — check the brand uuid is the compact 32-hex
    form and `order_id` matches; `/start` again is safe because the id is deterministic.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Workflow anatomy" icon="diagram-project" href="/next-temporal/workflow">
    The primary `response-automation-v2` workflow — phases, signals, and the review gate pattern this guide reused.
  </Card>

  <Card title="Return workflow" icon="rotate-left" href="/api-reference/temporal/workflows/v1-workflows-return-start-create">
    The same start / status / review / cancel shape for returns, plus receipt and inspection signals.
  </Card>

  <Card title="Control plane" icon="sliders" href="/next-temporal/control-plane">
    Multi-tenancy, the outbox and DLQ, and how brand config is versioned and activated.
  </Card>

  <Card title="Order to cash" icon="file-invoice-dollar" href="/guides/commerce/order-to-cash">
    Where fulfillment sits in the wider commerce flow from order to settlement.
  </Card>
</CardGroup>
