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.
Approvals
Section titled “Approvals”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.
Deciding
Section titled “Deciding”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.
Waiting for events
Section titled “Waiting for events”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.
Delivering an event
Section titled “Delivering an event”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.collectedor Record payment forpayment.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
operatorrole: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/eventswith the body signed by an integration’s key.
Signed events
Section titled “Signed events”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/eventsX-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.
Cancelling
Section titled “Cancelling”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:
- stops the run;
- 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;
- marks pending approvals
cancelledand clears the order’s waits; - sets the status to
cancelled, with the reason and the counts in the timeline; - sends the
order.cancelledoutbound 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.