Skip to content

Approvals and events

How orders wait for people and outside systems, how decisions and events reach them, and how cancelling undoes their work.

Two node types pause an order until something outside it happens: Approval waits for a person, and Wait for event waits for a business event such as payment.received. Both have a timeout. A waiting order is durable: it costs nothing while it waits and survives restarts, and the order page lists every open wait.

An Approval node has a title (a template, default Approve order {{order.id}}), an assignee (free text, default manager) and a timeout (default 2 days). When it starts, the order page shows the wait, and the approvals inbox lists a pending approval with the title, assignee and order total.

The node takes one of three branches:

Branch When
approved The decision is approved
rejected The decision is rejected
escalated Nobody decided before the timeout. The app shows this as Timed out

The node’s output (nodes.<id>) is the decision payload: decision, by, note and outcome. The inbox records the outcome, who decided (timeout for an escalation) and the note. Escalation only picks the branch; what happens next is up to the flow, for example a second approval for a more senior assignee.

From the order page or the approvals inbox (/approvals), each decision has a note field and a 5-second undo window. Nothing is sent until the window closes, so Undo is real: the order never hears about a decision that was taken back. If you leave the page during the window, the pending decision is sent then.

Decisions close two seconds before the approval times out, so the last one still arrives in time, and the countdown counts to that moment. Ten seconds before, it reads “Decisions close in …” (and screen readers hear it once); within five seconds the undo window shortens to fit. When decisions close, the buttons stay in place but disabled, any note is kept, and the card says where the order is going: “Decisions are closed; the order is going to Decline order”.

The inbox filters by pending, approved, rejected, escalated (shown as Timed out) or all. For a pending row it shows why a person is being asked (for a credit check: how far over the limit, the limit and the balance, all in USD, with a foreign-currency order shown in its own currency and in USD), and when the approval times out and to which step. Keyboard: j/k (or the arrow keys) move between rows, a approves, r rejects and z undoes.

Deciding needs the approver role or higher. by is the signed-in person’s email (operator for the shared workspace password); see Team and roles.

From the API:

curl -X POST https://orderloop.app/api/v1/orders/ord_…/approvals/approval \
-H "Authorization: Bearer $ORDERLOOP_TOKEN" -H 'content-type: application/json' \
-d '{ "decision": "approved", "note": "Paid last invoice" }'

$ORDERLOOP_TOKEN is an API token with the approver role or higher. The decision is recorded by the token (token:<name>) or the signed-in person; a body by is used only with the workspace password, where it defaults to api. A decision for a node that isn’t waiting for an approval fails with That node is not waiting for an approval. See the REST API.

The assistant can decide an approval for an approver, but only after the person confirms a card showing the order and where each decision leads. There is no undo window for a decision made there, and the decision is recorded by <email> via assistant.

A Wait for event node names an event (default payment.received) and a timeout (default 30 days). It takes received when the event arrives and timeout otherwise.

The event’s payload is stored in the run variables under the event name with every run of non-alphanumerics replaced by _: payment.received → vars.payment_received, return.requested → vars.return_requested. Later nodes read it from there; the storefront flow’s Refund node, for example, takes vars.return_requested.amount. OrderLoop adds event and at (epoch ms) to every delivered payload.

An event is delivered to whichever node is waiting for that event name. If none is, nothing happens and the caller is told delivered: false; delivering again later works once a node is waiting.

  • Order page: the open wait says what it waits for in plain words and has a button that records it by hand, such as Mark as collected for pickup.collected or Record payment for payment.received (payload { "source": "dashboard", "amount": <order total> }). The event name and the API endpoint are under For developers.

  • Back office: lists orders waiting on an event (up to 20), each with a Send <event> button (payload { "source": "back-office" }).

  • REST API, with an API token of the operator role:

    curl -X POST https://orderloop.app/api/v1/orders/ord_…/events \
    -H "Authorization: Bearer $ORDERLOOP_TOKEN" -H 'content-type: application/json' \
    -d '{ "event": "payment.received", "payload": { "amount": 3106.78 } }'

    The response is { "delivered": true, "nodeId": "…" } or { "delivered": false }.

  • Signed webhook, for external systems that shouldn’t hold an API token: POST /api/v1/webhooks/events with the body signed by an integration’s key.

Each external system gets its own integration: an operator creates it with POST /api/v1/integrations, which returns its id and a signing secret (whsec_…) shown once. The integration limits what the system may do: the events it may deliver (events, or ["*"] for any), and optionally one channel, in which case it reaches only orders on that channel. A payment provider’s key can deliver payment.received and nothing else.

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 } }

The timestamp is part of the signed message and must be within 5 minutes of the server’s clock, so a captured request can’t be replayed later. k is the integration’s id; it tells OrderLoop which secret to check. The signature of a delivered event is also recorded, so replaying it within the window returns 200 { "delivered": true, "duplicate": true } and delivers nothing; a 409 is not recorded and may be retried.

Status Meaning
200 { "delivered": true, "nodeId": "…" }
409 { "delivered": false }: no node is waiting for that event yet, so retry later
400 The body isn’t { orderId, event, payload? }, or payload is over 32 KiB of JSON
413 The body is over 1 MiB
401 Missing or malformed header, no k=, an unknown or revoked integration, stale timestamp, or wrong signature
403 The integration may not deliver that event, or is bound to a channel other than the order’s
404 No such order, or (for a channel-bound integration) not one it can see

Answering 409 lets the sender’s own retry policy deliver an event that arrives before the order reaches its wait.

Cancel from the order page, the API (POST /api/v1/orders/:id/cancel with an optional reason), or the assistant (after the person confirms). Cancelling an order that has already ended, or one that is already being cancelled, does nothing. Otherwise OrderLoop:

  1. stops the run;
  2. undoes the order’s work: releases its inventory reservations, voids its payment authorizations, cancels its open shipments and staged store pickups, restocks stock those shipments consumed once none is live, reverses its on-account invoice charges, revokes its issued digital codes, cancels its seller orders and reverses its scheduled payouts;
  3. marks pending approvals cancelled and clears the order’s waits;
  4. sets the status to cancelled, with the reason and the counts in the timeline;
  5. sends the order.cancelled outbound webhook.

Captured or charged money isn’t refunded by a cancel; refunds are a node in the flow.

Cancelling is different from an order that fails: a failure runs each completed step’s rollback in reverse order (see Ending a run) and ends rolled_back.