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 the Workflow happens: Approval waits for a person, and Wait for event waits for a business event such as payment.received. Both call step.waitForEvent with a timeout, so a waiting order costs nothing while it waits and survives restarts. The order’s OrderAgent records every open wait and delivers to it.

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 agent:

  • adds the wait to the order’s live state, where the order page shows it;
  • inserts a row in the approvals table with the title, assignee, the order total and status pending, which the approvals inbox lists.

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 node’s output (nodes.<id>) is the decision payload: decision, by, note and outcome. The approvals row is updated with the outcome, decidedBy (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 Workflow never hears about a decision that was taken back. If you leave the page during the window, the pending decision is sent then.

The inbox filters by pending, approved, rejected, escalated 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 escalates and to which step. Keyboard: j/k (or the arrow keys) move between rows, a approves, r rejects.

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.io/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 shared APP_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.

From the order chat, the assistant can prepare an approval or rejection, but a person confirms it on the order page, with the same undo window. See AI nodes and the copilot.

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. The agent 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 has a Simulate <event> button (payload { "source": "dashboard", "amount": <order total> }).

  • Back office: lists every order waiting on an event, with a button to deliver it (payload { "source": "back-office" }).

  • REST API, with the admin password:

    curl -X POST https://orderloop.io/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 the admin password: POST /api/v1/webhooks/events with the body signed by WEBHOOK_SECRET.

POST /api/v1/webhooks/events
X-OrderLoop-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(WEBHOOK_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. signWebhook(secret, rawBody) in @orderloop/core produces the header; verifyWebhook checks it.

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? }
401 Missing or malformed header, stale timestamp, or wrong signature
503 WEBHOOK_SECRET isn’t set

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

Every wait and timeout is divided by TIME_SCALE (default 1440 in apps/engine/wrangler.jsonc: a day becomes a minute, a 2-day escalation about two minutes), and never less than one second. Set it to "1" for real time. The deadline the order page and the inbox show is the scaled one.

Cancel from the order page, the API (POST /api/v1/orders/:id/cancel with an optional reason), or the order chat (prepared, then confirmed by a person). Cancelling an order that has already ended does nothing. Otherwise the agent:

  1. terminates the Workflow instance;
  2. compensates the order: releases its inventory reservations, voids its payment authorizations and cancels its open shipments (compensateOrder in apps/engine/src/connectors.ts);
  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 rolls back inside the Workflow, which runs each compensable step’s rollback in reverse order (see OrderLoop Workflow).