Skip to content

REST API

The /api/v1 routes for flows, orders, events, approvals and webhook subscriptions, with request and response shapes.

The REST API is served at https://orderloop.app/api/v1. Its OpenAPI 3.1 document is public at GET /api/v1/openapi.json (cacheable for five minutes), and the OpenAPI explorer renders it. Its request bodies are generated from the schemas the routes validate with, so they match what the server accepts. Response bodies are described here.

Send Authorization: Bearer ol_live_…, an API token from the Team page (a signed-in dashboard session cookie also works, and so does the workspace password, a break-glass operator sign-in set by your administrator). Each route needs a role: reads need viewer, approval decisions approver, and everything else that changes state (starting, cancelling, events, webhook subscriptions, integrations) operator; a token or person without it gets 403. See Team and roles. Four routes are exceptions: GET /api/v1/openapi.json is public, POST /api/v1/webhooks/events and POST /api/v1/webhooks/orders are authenticated by an HMAC signature instead (see signed events and signed orders) with an integration’s key, and so is POST /api/v1/channels/shopify/orders (see Channel adapters).

Errors are JSON: { "error": "…" }, plus issues (one entry per invalid field, with its path and message) on a 400 for an invalid body. 401 means a missing, wrong, revoked or expired token (an expired token’s error says when it expired), 403 a role that can’t use the route (or, with a session cookie, a person whose role must use two-factor sign-in and hasn’t set it up, or a cookie request from another origin), 429 too many wrong bearer tokens from one address (more than 20 bearers that aren’t ol_live_ tokens in 15 minutes, which only guesses at the workspace password produce; see Retry-After), 404 an unknown route or order, and 413 a request body over its cap (1 MiB; 5 MiB for EDI), and 500 an unexpected error, which answers { "error": "Internal error" } without its details.

Method Path Body Response
GET /api/v1/flows { flows }
POST /api/v1/orders { flowId, version?, input, vars? } 201 { orderId }, or 200 { orderId, replayed: true }
GET /api/v1/orders?model=&channel=&status=&q=&limit=&cursor= { orders, nextCursor }
GET /api/v1/orders/:id The order’s live state
POST /api/v1/orders/:id/events { event, payload? } { delivered, nodeId? }
POST /api/v1/orders/:id/approvals/:nodeId { decision, by?, note? } { ok: true }
POST /api/v1/orders/:id/cancel { reason? } { ok: true }
GET /api/v1/audit-events?type=&before=&limit= { events } (operator)
GET /api/v1/integrations { integrations } (operator)
POST /api/v1/integrations { name, events?, startOrders?, flows?, channel? } 201 { integration, secret } (operator)
POST /api/v1/integrations/:id/rotate { integration, secret } (operator)
DELETE /api/v1/integrations/:id { ok: true } (operator)
GET /api/v1/webhook-subscriptions { subscriptions }
POST /api/v1/webhook-subscriptions { url, events? } 201 { subscription, secret }
DELETE /api/v1/webhook-subscriptions/:id { ok: true }
GET /api/v1/webhook-subscriptions/:id/deliveries { deliveries }
POST /api/v1/webhooks/events { orderId, event, payload? } 200 { delivered: true, nodeId }, 404 or 409
POST /api/v1/webhooks/orders { model, channel, externalId, lines, … } 201 { orderId, flowId }, or 200 on a replay
POST /api/v1/channels/shopify/orders A Shopify orders/create payload 201 { orderId, flowId }, 200 on a replay or { ignored: true, topic }
POST /api/v1/channels/edi/850 Raw X12 (application/edi-x12 or text/plain) 200 { sender, results } (operator)

GET /api/v1/flows lists every flow, most recently updated first, with id, name, description, model, channels, publishedVersion (null until the first publish), updatedAt and nodeCount. Flows are edited and published in the dashboard’s editor; the API starts orders on them.

POST /api/v1/orders
Idempotency-Key: po-NW-7781
{
"flowId": "tpl-b2b-order",
"input": {
"model": "b2b",
"channel": "api",
"customerId": "cus_northwind",
"currency": "USD",
"lines": [{ "sku": "WID-100", "qty": 50 }],
"meta": {}
}
}
  • flowId names a flow with a published version. version (a positive integer) pins one; it defaults to the latest.
  • input.model is required: one of b2b, b2c, d2c, marketplace, subscription, procurement, replenishment. input.channel is one of web, mobile, pos, marketplace, email, edi, api, manual, schedule and defaults to api. See Commerce models.
  • input.title (up to 140 characters) and input.customerId are optional. input.externalId (up to 200) is the order’s id in its channel; input.contact is { email, name?, phone? } for a buyer without a customer account; input.fulfillment is { method, locationId? }.
  • input.currency defaults to USD and must have an exchange rate in the workspace; another code returns 400. See Currency.
  • input.lines are { sku, name?, qty, unitPrice?, sellerId?, locationId? } (locationId pins the store a line ships from; route fulfillment normally sets it). Pricing nodes fill in prices, in the order’s currency.
  • input.documentText (up to 50,000 characters) is an inbound email or PO for the Extract order node; it is stored with the order’s documents before the run starts.
  • input.meta is free-form and is the run context’s input. Some keys mean something to nodes: shipTo (the no_po_box policy rule) and simulate (forces a failure path in the built-in commerce steps; see Built-in flows).
  • vars seeds the run’s variables.

Idempotency. With an Idempotency-Key header (at most 255 characters; longer returns 400), a repeat of the key returns 200 and the order the first request created, even when two requests race. If the first request failed after recording the order, the repeat finishes starting it. The key is bound to a SHA-256 fingerprint of the body (flow, version, input and vars, ignoring property order): a repeat with a different body returns 422 and starts nothing.

GET /api/v1/orders returns a page of orders, newest first (by created_at, then id). limit is 1 to 200 (default 50); nextCursor is an opaque string to pass back as cursor for the next page, and is null on the last page. A cursor the API did not issue is 400. Filter with model, channel, status (one of queued, running, waiting, completed, declined, cancelled, failed, rolled_back) and q, which matches the order id, title, customer id, contact email or external id. Each row has id, model, channel, externalId, contactEmail, status, title, flowId, flowVersion, instanceId, generation, customerId, currency, total, waitingOn (the open waits’ titles), waitingAt (the labels of the steps holding them), waitingSince, createdAt and updatedAt (epoch milliseconds).

GET /api/v1/orders/:id returns the order’s live state, the same state the order page renders:

Field Meaning
orderId, status, reason Identity, status, and the reason when cancelled or failed
order The order with computed subtotal, tax and total
flow The pinned flow version, including its graph
nodes Per node: status, runs, summary, output, error, branches, startedAt, endedAt
edges Per edge: pending, active or inactive
waits Open waits: nodeId, kind (approval or event), title, event, assignee, amount, deadline, since
vars Run variables
timeline Entries with at, kind, message, nodeId, data
instanceId, generation The id of the run driving the order, and its generation (it goes up by one on each continue as new)
rollback { outcome, error } once a rollback has run
updatedAt When the state last changed (epoch milliseconds)

Node statuses are pending, running, waiting, succeeded, failed, skipped, rolled_back.

POST /api/v1/orders/ord_…/events
{ "event": "payment.received", "payload": { "amount": 3106.78 } }

The event goes to the node waiting for it, and its payload lands in vars under the event name with every run of non-alphanumerics replaced by _ (payment.received → vars.payment_received). The response is { delivered: true, nodeId }, or { delivered: false } when no node is waiting for that event yet. An unknown order is 404. See Approvals and events.

POST /api/v1/orders/ord_…/approvals/approval
{ "decision": "approved", "by": "dana@example.com", "note": "Paid down last week" }

decision is approved or rejected. A person or an API token decides as themselves (by is ignored); only the workspace password may name by, which defaults to api. The decision takes effect at once; the dashboard’s undo window does not apply to the API. An unknown order is 404, and a node that isn’t waiting for an approval (already decided, or never reached) is 409.

POST /api/v1/orders/:id/cancel stops the order’s run, releases reserved stock, voids payment authorizations, cancels shipments and staged pickups, restocks shipped stock, reverses on-account invoice charges, revokes issued digital codes, cancels seller orders, reverses scheduled payouts and sends order.cancelled. reason defaults to Cancelled via API. Cancelling an order that has already finished changes nothing and returns 409 { error: "The order has already finished" }; an unknown order is 404.

An integration is one external system’s signing key and what it may do. Create one per system, so a leaked key is revoked on its own and a payment provider can’t start orders. All four routes need the operator role.

  • POST /api/v1/integrations takes name (1 to 100 characters) and the scope: events (event names it may deliver to /api/v1/webhooks/events, or ["*"]; default none), startOrders (may post to /api/v1/webhooks/orders; default false), flows (flow ids an order body may name in flowId, or ["*"]; default none) and channel (only orders on this channel, both those it starts and those it sends events to; default null, any). It returns 201 { integration, secret }. The secret (whsec_…) is shown once; integration.id goes in the signature as k=.
  • GET /api/v1/integrations lists integrations, newest first, without secrets. Each has id, name, the scope, createdAt, createdBy, lastUsedAt, revokedAt and previousExpiresAt.
  • POST /api/v1/integrations/:id/rotate returns a new secret. The old secret keeps working for 24 hours (previousExpiresAt shows when it stops), so the sender can switch without downtime. Rotating again replaces the overlap. 404 for an unknown or revoked integration.
  • DELETE /api/v1/integrations/:id revokes it at once, including any secret still in its overlap. Requests signed with it get 401. 404 if it doesn’t exist or is already revoked.

Creating, rotating and revoking are recorded in the audit log.

External systems resume orders without a token by signing the raw request body with their integration’s secret:

POST /api/v1/webhooks/events
X-OrderLoop-Signature: t=<unix seconds>,k=<integration id>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
{ "orderId": "ord_…", "event": "payment.received", "payload": { "amount": 3106.78 } }

Inbound orders shows how to build the header in a shell and in JavaScript. Signatures more than five minutes from now are rejected. A delivered event’s signature is recorded, so the same signed request sent again inside that window is not delivered twice. A 409 is not recorded and may be retried.

The order routes bound their bodies the same way: POST /api/v1/orders/{id}/events takes event (1 to 100 characters) and an optional payload of at most 32 KiB of JSON, POST …/approvals/{nodeId} takes decision, by (at most 200 characters) and note (at most 2000), and POST …/cancel takes reason (at most 500). Anything else is a 400 with issues. The order page enforces the same bounds.

Status Meaning
200 Delivered: { delivered: true, nodeId }
404 No such order
409 No node is waiting for that event yet: { delivered: false }. Retry later
200 A replay: { delivered: true, duplicate: true }, delivering nothing
400 Body is not { orderId, event, payload? } (event is 1 to 100 characters, payload at most 32 KiB of JSON)
413 Body over 1 MiB
401 Missing, malformed, stale or wrong signature; no k=; an unknown or revoked integration
403 The integration may not deliver that event, or is bound to a channel other than the order’s

A channel-bound integration gets 404 for an order that doesn’t exist, so it can’t probe other channels’ ids.

POST /api/v1/webhooks/orders starts an order from a storefront, marketplace, POS or other channel, signed the same way as events. The body is input plus a required channel and externalId and optional flowId, source, shipTo and vars; it needs at least one line or documentText. The flow is flowId, else the workspace’s order routes (set by your administrator), else the model’s default. channel and externalId are the idempotency key. Statuses: 201 { orderId, flowId } started, 200 { orderId, replayed: true } replay, 400 invalid body or unsupported currency, 401 bad signature or unknown or revoked integration, 403 the integration may not start orders, is bound to another channel, or may not choose that flowId, 409 flow not published, 422 same order with different contents. See Inbound orders.

POST /api/v1/channels/shopify/orders takes a Shopify orders/create webhook, verified by the X-Shopify-Hmac-Sha256 header (base64 HMAC-SHA256 of the raw body with your Shopify app’s client secret, which your administrator adds to the workspace) rather than a token. Statuses: 201 { orderId, flowId } started, 200 { orderId, replayed: true } replay, 200 { ignored: true, topic } for any other X-Shopify-Topic, 400 not JSON, unmappable (a line item without a SKU) or invalid, or an unsupported currency, 401 bad signature, 409 flow not published, 422 same order with different contents, 503 the workspace hasn’t enabled Shopify.

POST /api/v1/channels/edi/850 takes a raw X12 interchange and needs the operator role. The sender (ISA06) must be in the workspace’s EDI partner list. It answers 200 { sender, results } with one { control, poNumber?, status, orderId?, flowId?, replayed?, error? } per transaction; 400 is not valid X12, 403 an unlisted sender or a caller below operator. Both start orders once per order:<channel>:<externalId>. See Channel adapters.

GET /api/v1/audit-events returns the security audit log, newest first, for feeding a log system; it needs the operator role. type filters to auth, team, token, api, oauth or integration; limit is 1 to 500 (default 100); before (milliseconds since the epoch) pages back: pass the oldest at you have. Each event has id, at, action, actor, actorId, actorKind, target, detail (an object or null) and ip. See Audit log for the actions.

See Outbound webhooks for the events, payloads and signatures.

  • POST /api/v1/webhook-subscriptions takes url (https, or plain http for localhost, 127.0.0.1 and [::1]) and events: any of order.paid, order.invoiced, order.shipped, order.ready_for_pickup, order.delivered, order.refunded, order.payout_scheduled, order.completed, order.declined, order.cancelled, order.failed, or ["*"] (the default). It returns the subscription and its secret, once.
  • GET lists subscriptions without their secrets.
  • DELETE /api/v1/webhook-subscriptions/:id deletes one (404 if it doesn’t exist).
  • GET /api/v1/webhook-subscriptions/:id/deliveries returns the latest 50 deliveries, newest first: id, event, orderId, payload, status (pending, delivered or failed), attempts, responseStatus, lastError, createdAt and deliveredAt.

These aren’t part of /api/v1.

  • GET /health answers { ok: true, service: "orderloop-web" }, with no authentication.
  • GET /health/deep also checks the database, document storage and the engine, with no authentication. It answers { ok, service, checks }, where each check is "ok" or "fail", and 503 when any check fails.
  • /mcp, /a2a, /oauth/* (except the /oauth/authorize consent page) and /.well-known/oauth-* are the MCP server, the A2A endpoint and their OAuth endpoints.
  • GET /.well-known/agent-card.json is the A2A Agent Card, public and cached for five minutes.
  • /agents/order-agent/:id and /agents/assistant-agent/:name are the dashboard’s live sockets for a signed-in person. They are not a public API; the assistant route always reaches the caller’s own assistant.
  • GET /docs/orders/… serves an order’s stored documents (invoices, POs, inbound documents) to a signed-in person.