Skip to content
Maple

Orders

Build the order loop: connect a location, subscribe to webhooks, verify deliveries, and drive each order with accept, deny, ready, and complete calls.

This is the core of a Maple integration. When a customer orders at a location you’re connected to, Maple pushes the order to your webhook; you decide whether to fulfill it and report progress as it moves. None of it involves payments — Maple owns that.

A working ordering integration is two pieces of work:

# What you build Typical effort
1 Receive orders — one webhook endpoint with signature verification ~1–2 days
2 Decide orders — accept / deny / status calls back to Maple ~1–2 days

If you haven’t yet, skim How Maple works for the object model, and run the Quickstart to get a key and connect a test location.

Step 1 — Connect a location

Orders route to you only once your app is a location’s connected receiver:

Terminal window
curl -X POST $MAPLE_BASE/locations/{locationId}/connection \
-H "Authorization: Bearer $MAPLE_KEY"

Connections are exclusive per environment — if another app already holds the location you get a 409. GET and DELETE on the same path inspect and remove your connection.

Step 2 — Subscribe to webhooks

Register an HTTPS endpoint and the event types you want. The full catalog is at GET /v1/webhook_event_types; for the order loop you’ll typically want these:

Terminal window
curl -X POST $MAPLE_BASE/webhook_subscriptions \
-H "Authorization: Bearer $MAPLE_KEY" \
-H "Content-Type: application/json" \
-d '{
"notification_url": "https://your-app.example.com/maple/webhooks",
"event_types": ["order.notification", "order.validation_requested", "order.cancelled"]
}'

The response carries a one-time signing secret (mwhsec_…). Store it; it is never shown again. The URL must be public HTTPS — private and internal addresses are rejected.

Step 3 — Verify every delivery

Each delivery is a Stripe-style envelope:

{
"object": "event",
"id": "evt_...",
"type": "order.notification",
"created": 1765432100,
"data": { "order_id": "ord_...", "location_id": "str_...", "...": "the order content — see Step 4" }
}

Deliveries carry two headers: maple-webhook-id (the event id) and maple-webhook-signature in the form t=<unix_seconds>,v1=<hex_hmac>. The signed string is {timestamp}.{subscription_id}.{notification_url}.{raw_body}. Verify it against the raw, unparsed body:

import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyMapleWebhook(params: {
signatureHeader: string; // maple-webhook-signature
subscriptionId: string; // your subscription id (dws_…)
notificationUrl: string; // the exact URL you registered
rawBody: string; // the unparsed request body
signingSecret: string; // mwhsec_…
toleranceSeconds?: number; // default 300
}): boolean {
const parts = Object.fromEntries(params.signatureHeader.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
const provided = parts.v1 ?? '';
// Reject stale deliveries to prevent replay.
const skew = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (!Number.isFinite(timestamp) || skew > (params.toleranceSeconds ?? 300)) return false;
const signed = `${timestamp}.${params.subscriptionId}.${params.notificationUrl}.${params.rawBody}`;
const expected = createHmac('sha256', params.signingSecret).update(signed).digest('hex');
return expected.length === provided.length && timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}

The check above rejects stale timestamps (older than ~5 minutes) to prevent replay. Beyond that, dedupe on the envelope id — delivery is at-least-once. The full reliability contract, including the retry schedule and the ledger, is in Webhooks.

Catching up after downtime

When your receiver was unavailable — the local software was off, or the network was down — reconcile like this:

  1. Call GET /v1/orders?since=<timestamp>&limit=100 with your last successful checkpoint (exclusive, ISO 8601 UTC). With since, results are oldest first and has_more indicates another page.
  2. While has_more is true, repeat the request with starting_after set to the last order id from the previous page. Continue until the backlog is drained, then advance your checkpoint.
  3. Call POST /v1/orders/{orderId}/resend when you need Maple to publish a fresh order.notification for one live order that missed its webhook. It requires the webhooks:write scope, creates a new signed, retried delivery, and does not change the order’s state. Terminal orders (REJECTED, CUSTOMER_CANCELLED, or STORE_CANCELLED) cannot be resent or revived and return 400; reconcile them with GET /v1/orders?since=... and GET /v1/orders/{orderId}.
  4. Use the event ledger instead of resend when preserving the original event identity matters.

Waiting it out also works for a short outage: the retry schedule redelivers a failed event over roughly 10 hours before it counts as fully failed.

Step 4 — Read the order

The order.notification payload’s data is the order’s content — its IDs, customer, line items, and totals — so you can start fulfilling straight from the webhook without another call. It uses the same field names and integer-cent amounts as the order resource, but it is a smaller object: order_id (not id), location_id, fulfillment_type, scheduled_for, delivery_address, customer, line_items, and totals. It has no object, id, livemode, created, status, payment_status, external_id, or payment, and line items carry no per-item tax. order.validation_requested carries the same data plus validation_id:

{
"order_id": "ord_...",
"location_id": "str_...",
"fulfillment_type": "delivery",
"scheduled_for": null,
"delivery_address": {
"street": "123 Main Street",
"unit": "Apt 4B",
"city": "New York",
"state": "NY",
"zip": "10001",
"notes": "Leave at the side door"
},
"customer": { "name": "Alex", "phone": "+14155551234", "phone_last_four": "1234" },
"line_items": [
{
"name": "Latte",
"menu_entity_id": "itm-latte",
"quantity": 1,
"base_price": 450,
"modifiers": [{ "name": "Oat milk", "menu_entity_id": "mod-oat", "quantity": 1, "price": 75 }]
}
],
"totals": { "currency": "USD", "subtotal": 525, "tax": 42, "surcharge": 0, "tip": 0, "delivery_fee": 599, "delivery_tip": 0, "total": 1166 }
}

GET /v1/orders/{orderId} returns the full order resource instead, adding object, id, livemode, created, status, payment_status, external_id, payment, and per-item tax. Fetch it any time for the authoritative current state:

{
"object": "order",
"id": "ord_...",
"livemode": false,
"created": 1765432100,
"status": "PENDING",
"payment_status": "unpaid",
"fulfillment_type": "delivery",
"scheduled_for": null,
"location_id": "str_...",
"delivery_address": {
"street": "123 Main Street",
"unit": "Apt 4B",
"city": "New York",
"state": "NY",
"zip": "10001",
"notes": "Leave at the side door"
},
"customer": { "name": "Alex", "phone": "+14155551234", "phone_last_four": "1234" },
"line_items": [
{
"name": "Latte",
"menu_entity_id": "itm-latte",
"quantity": 1,
"base_price": 450,
"tax": 36,
"modifiers": [{ "name": "Oat milk", "menu_entity_id": "mod-oat", "quantity": 1, "price": 75 }]
}
],
"totals": { "currency": "USD", "subtotal": 525, "tax": 42, "surcharge": 0, "tip": 0, "delivery_fee": 599, "delivery_tip": 0, "total": 1166 },
"external_id": null,
"payment": { "provider": "stripe", "status": "unpaid", "payment_link_url": "https://..." }
}

A few things to internalize:

  • Money is integer USD cents. 450 is $4.50. Never parse it as a float. Every amount in totals and on line items follows this convention.
  • Totals are precomputed. totals.total is authoritative. You do not re-price anything.
  • Line items reference your menu by menu_entity_id, whose value is the externalId you published for that item. Publish a menu first and these line up with your own catalog. (Menu payloads are camelCase; order and webhook payloads are snake_case — see Conventions.)
  • Modifiers are the directly-selected, first-level options. Deeper nested modifier selections aren’t expanded into the order resource in v1.
  • Customer data is intentionally minimal. You get the customer’s name and phone number (phone in E.164 format, plus phone_last_four for display). Delivery orders also include delivery_address with the street, unit, city, state, ZIP code, and delivery instructions; it is null for pickup orders or when Maple has no stored address. All six address keys are present whenever the block is populated.
  • Check scheduled_for before firing the ticket. It’s null for ASAP orders. When set (ISO 8601, UTC), the customer chose that pickup/delivery time — you receive the order immediately, so schedule preparation for scheduled_for, not on receipt.

Step 5 — Decide and report progress

Respond through the decision endpoints as the order moves. None take a body unless noted:

Call When
POST /v1/orders/{orderId}/accept You can fulfill it
POST /v1/orders/{orderId}/deny You can’t — optional { "reason": "…" }
POST /v1/orders/{orderId}/ready Ready for pickup or courier handoff
POST /v1/orders/{orderId}/complete Fulfilled
POST /v1/orders/{orderId}/cancel You must cancel after accepting — optional { "reason": "…" }
POST /v1/orders/{orderId}/status Granular transition (see below)
Terminal window
curl -X POST $MAPLE_BASE/orders/{orderId}/accept \
-H "Authorization: Bearer $MAPLE_KEY"
{ "object": "order_decision", "order_id": "ord_...", "decision": "accept", "status": "received" }

If you prefer one endpoint, POST /v1/orders/{orderId}/status takes an explicit transition:

{ "status": "ACCEPTED" }

Valid values: ACCEPTED, READY, IN_DELIVERY, FULFILLED, REJECTED, STORE_CANCELLED. For what each status means and the legal transitions between them, see the Order lifecycle.

Optional — pre-validate orders

Pre-validation is opt-in, and you turn it on by subscribing to order.validation_requested:

  • If you don’t subscribe, there’s no validation step. Maple sends order.notification directly, and your accept/deny is the only gate.
  • If you subscribe, Maple asks you to confirm each order is fulfillable (item availability, pricing feasibility, POS injectability) before it sends the notification, and waits for your answer. Opting in is therefore a commitment: an order you don’t validate in time is rejected (see below).

That’s the whole mechanism — the subscription is the switch.

When subscribed, respond to each order.validation_requested with a result. Echo the validation_id from that event’s data so the result is bound to that attempt; a result for an expired or older attempt is rejected with 404:

Terminal window
curl -X POST $MAPLE_BASE/orders/{orderId}/validation_result \
-H "Authorization: Bearer $MAPLE_KEY" \
-H "Content-Type: application/json" \
-d '{ "validation_id": "<validation_id from the event>", "status": "valid", "partner_reference": "pos_quote_123" }'

To block the order instead:

{
"validation_id": "<validation_id from the event>",
"status": "invalid",
"reason": { "code": "ITEM_AVAILABILITY", "explanation": "86'd", "item_external_ids": ["itm-latte"] }
}

A valid result lets the order proceed to notification; invalid blocks it before the customer is charged. You have about 5 minutes to respond before a request expires (the resource carries expires_at, ~300 seconds out). If it expires, Maple requests validation once more; if that second request also goes unanswered, the order is rejected without a notification — so only subscribe once your handler reliably answers in time. It’s the same availability check you’d run at accept time, only earlier, so customers don’t pay for something you can’t make.

What “done” looks like

You build You get
One webhook handler that verifies, dedupes, and routes Real-time orders pushed with precomputed totals
Accept / deny / ready / complete / status calls Customers see live order progress
(Optional) validation responses Bad orders blocked before the customer is charged

One webhook and a handful of decision calls. Payments and pricing stay on our side, and the Maple team is available throughout your build.

Next