Developers
Marketplace API Documentation
Everything you need to build an order source or delivery provider integration on the iResto Marketplace Partner API — V1.
How Marketplace Integration Works
Every integration involves four actors. Keep them straight and the rest of this page will make a lot more sense:
| Actor | Role |
|---|---|
| Integration developer / provider | You — the team building a delivery-provider or order-source integration. |
| iResto Marketplace | The platform registry of apps and contract versions. Today, creating and publishing an app is an iResto Platform Admin action — see the note below. |
| Restaurant / tenant | The iResto merchant who installs your app, consents to its contract, and connects it from their Store Dashboard. |
| Partner backend | Your own server — the thing that actually calls the Partner API and receives webhooks. |
The lifecycle
Warning
ContractVersion defined with your requested auth type/scopes/events/webhook URL, and published), contact iResto directly. Once published, a restaurant can discover, install, and connect your app from their own Store Dashboard without any further admin involvement — only the one-time registration step requires iResto. Install and consent are the same step, not two. A restaurant's "Install" call names the exact Published app + contract version it is accepting — there is no separate consent screen beyond that. Connect is the step that actually mints your credentials (see Credentials Explained below) and, for an OAuth2-authenticated app, additionally runs the authorization-code exchange against your own OAuth server (see Authentication Models).
Choose Your Type
Almost every integration is one of these two shapes. Pick one before requesting scopes/events for your app's contract.
Build an Order Source
Catalog.Read → Orders.Create → source-owned GET/polling → relevant webhooks.
Required scope
Orders.Create — creates orders, and also grants source-owned GET/cancel on orders you created.
Recommended scope
Catalog.Read — lets you browse real branch/product/variant ids rather than hardcoding them.
Relevant event
orders.cancelled — targeted to you when an order you created is cancelled, so you don't have to discover it only by polling.
Build a Delivery Provider
delivery.assigned → GET order → Accept → Driver → ETA → Tracking → InTransit → Delivered.
Required scopes
Delivery.Accept, Delivery.Reject, Delivery.Cancel, Delivery.UpdateStatus, Delivery.UpdateDriver, Delivery.UpdateEta, Delivery.UpdateTracking, Orders.Read.
Relevant events
delivery.assigned (required — how you learn about new work), delivery.cancelled, delivery.superseded.
Authentication Models
There are two separate authentication questions here — do not conflate them. Partner API authentication (how you call iResto) is always the Authorization: ApiKey {KeyId}.{secret} header from Authentication below — this never changes, regardless of your contract's connection type. Marketplace connection authentication is a separate, per-app choice that only affects how iResto authenticates itself to you when connecting/delivering — ApiKey or OAuth2, declared on your app's ContractVersion.
ApiKey connection
The simpler, more common choice. At Connect time, iResto mints your Partner API key pair and (if you subscribe to webhooks) a webhook signing secret. No further handshake — webhooks are authenticated by the HMAC signature scheme in Webhooks, nothing else.
OAuth2 connection
Used when your own backend requires iResto to authenticate itself against your OAuth2 authorization server. This is registered once per app by iResto Platform Admin — not something a restaurant or your Partner API caller ever configures.
Warning
The flow
The redirect-back target is iResto's own stable callback (/api/marketplace/oauth/callback) — pre-registered with your authorization server ahead of time by iResto Platform Admin, the same one-time setup as the endpoint/client values above. The token exchange is server-to-server: your ClientSecret is never exposed to the restaurant's browser.
Note
ApiKey header on Partner API requests. Credentials Explained
Three distinct credential pairs can exist for one installation — never confuse one for another:
| Credential | Created when | Who stores it | Used for |
|---|---|---|---|
| Partner OAuth Client ID / Client Secret | Once, by iResto Platform Admin, when registering your app's OAuth connection | Issued by you (it's your own OAuth server's credential) and stored encrypted by iResto | iResto authenticating itself to your authorization server during the Connect flow's token exchange — never used on Partner API calls. |
| Partner API Key ID / API Secret | At Connect (and again on reconnect/rotate) — every installation gets one, regardless of connection type | You — shown once, never retrievable again | The Authorization: ApiKey {KeyId}.{secret} header on every Partner API call you make. |
| Webhook Signing Secret | At Connect, only if your app subscribes to events | You — shown once, never retrievable again | Verifying X-IResto-Signature on every webhook you receive. |
Warning
Your first integration checklist
Answer these before you write any code:
- Which integration type am I building? — Order Source or Delivery Provider — see Choose Your Integration Type above. Most apps are exactly one, not both.
- Which scopes do I request? — Match your integration type's required/recommended scopes exactly — request only what you actually call.
- Which events do I subscribe to? — Only the ones with a real emission site (see Events below) that your integration type actually needs.
- What webhook URL do I provide? — A publicly reachable HTTPS endpoint on your own backend that responds 2xx fast and verifies X-IResto-Signature before trusting the body.
- Do I need API-key or OAuth connection? — ApiKey unless your own backend specifically requires iResto to authenticate against your own OAuth2 authorization server — see Authentication Models.
- How does a restaurant install/connect my app? — From their own Store Dashboard, once your app is Published — Install (names + consents to your contract version), then Connect (mints credentials).
- Where do Partner API credentials come from? — Minted automatically at Connect, shown once — see Credentials Explained. You never choose or generate them yourself.
- How do I verify my first webhook? — HMAC-SHA256 over "{timestamp}.{raw body bytes}" with your webhook signing secret — see the working examples under Webhooks.
- What is my first API call? — GET /api/partner/v1/catalog/branches with your Partner API key — see Quick Start below.
Quick Start
The real integration sequence, end to end:
- A tenant installs your app from the Marketplace and connects it from their Store Dashboard.
- You receive a Partner API key (
KeyId+secret) and, if you subscribe to webhooks, a webhook signing secret — shown once. - You authenticate every request with
Authorization: ApiKey {KeyId}.{secret}. - You call the Partner API (read the catalog, create orders, or act on an assigned delivery).
- If subscribed, you receive signed webhook events and verify them before acting.
Note
KeyId/secret/webhook signing secret are issued by iResto when a tenant connects your app — you never generate or choose them. Your first successful call
A minimal, working Catalog.Read call:
curl "https://api.resto.isoft4is.net/api/partner/v1/catalog/branches" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>"Response:
[
{ "id": "<BRANCH_ID>", "name": "Downtown", "isActive": true, "externalLocationId": null }
]Authentication
Every request must carry:
Authorization: ApiKey <KEY_ID>.<API_SECRET>KeyId and secret are issued together, once, when a tenant connects (or reconnects, or rotates) your app. Treat the full {KeyId}.{secret} string as a single secret.
Warning
A missing, malformed, unknown, or revoked credential returns 401 with a generic message.
curl "https://api.resto.isoft4is.net/api/partner/v1/catalog/branches" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>"Scopes
Each connected installation is granted a fixed set of scopes at connect time, based on the Marketplace app version the tenant consented to. A request to an endpoint whose required scope your installation lacks returns 403.
Currently active scopes
| Scope | Grants |
|---|---|
Orders.Create | POST /orders — also lets you read and cancel an order you created. |
Orders.Read | GET /orders/{orderId} for an order whose live delivery assignment is yours. |
Delivery.Accept | POST /deliveries/{orderId}/accept |
Delivery.Reject | POST /deliveries/{orderId}/reject |
Delivery.Cancel | POST /deliveries/{orderId}/cancel |
Delivery.UpdateStatus | PATCH /deliveries/{orderId}/status |
Delivery.UpdateDriver | PATCH /deliveries/{orderId}/driver |
Delivery.UpdateEta | PATCH /deliveries/{orderId}/eta |
Delivery.UpdateTracking | PATCH /deliveries/{orderId}/tracking |
Catalog.Read | GET /catalog/branches and the two product routes. |
Catalog.Read, the Delivery.* scopes, and Orders.Create/Orders.Read are each independent — holding one never implies another.
Warning
Delivery.Read is a registered scope code, but no endpoint currently checks it — do not build against it. Payments.Read is reserved for future use and currently grants no capability at all. Treat both as not-yet-active. Catalog API
Read-only, full-snapshot, pull-based — there is no pagination and no change feed. Poll on your own schedule.
Branches
/api/partner/v1/catalog/branches Catalog.ReadReturns every active branch belonging to the tenant that installed your app.
[
{
"id": "<BRANCH_ID>",
"name": "Downtown",
"isActive": true,
"externalLocationId": "STORE-4821"
}
]Products (summary)
/api/partner/v1/catalog/branches/{branchId}/products Catalog.ReadThe branch's full effective catalog — categories, each with product summaries. No modifier detail on this route.
{
"categories": [
{
"id": "<CATEGORY_ID>",
"name": "Burgers",
"products": [
{
"id": "<PRODUCT_ID>",
"sku": "BRG-001",
"name": "Classic Cheeseburger",
"imageUrl": "https://cdn.example.com/products/classic-cheeseburger.jpg",
"taxCategory": { "rate": 15.0, "isInclusive": true },
"availability": 0,
"startingPrice": 8.50,
"variants": [
{ "id": "<VARIANT_ID>", "sku": "BRG-001-REG", "name": "Regular", "price": 8.50, "compareAtPrice": null, "isDefault": true, "availability": 0 },
{ "id": "<VARIANT_ID_2>", "sku": "BRG-001-LRG", "name": "Large", "price": 10.50, "compareAtPrice": null, "isDefault": false, "availability": 0 }
]
}
]
}
],
"resolvedLanguage": null
}Product detail (with modifiers)
/api/partner/v1/catalog/branches/{branchId}/products/{productId} Catalog.ReadOne product's full detail, including its complete, recursively-nested modifier hierarchy.
{
"id": "<PRODUCT_ID>",
"sku": "BRG-001",
"name": "Classic Cheeseburger",
"imageUrl": "https://cdn.example.com/products/classic-cheeseburger.jpg",
"taxCategory": { "rate": 15.0, "isInclusive": true },
"availability": 0,
"resolvedLanguage": null,
"variants": [
{
"id": "<VARIANT_ID>",
"sku": "BRG-001-REG",
"name": "Regular",
"price": 8.50,
"compareAtPrice": null,
"isDefault": true,
"availability": 0,
"modifierGroups": [
{
"id": "<MODIFIER_GROUP_ID>",
"name": "Extras",
"minSelections": 0,
"maxSelections": 3,
"isRequired": false,
"sortOrder": 0,
"options": [
{
"id": "<MODIFIER_OPTION_ID>",
"name": "Extra Cheese",
"price": 0.75,
"isDefault": false,
"availability": 0,
"childGroups": []
}
]
}
]
}
]
}Language
Both catalog routes accept an optional ?language= query parameter, resolved deterministically from the parameter alone — never from Accept-Language.
curl "https://api.resto.isoft4is.net/api/partner/v1/catalog/branches/<BRANCH_ID>/products?language=ar" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>"Availability
A three-state, wire-integer value — never a boolean, and typically filtered to absence rather than returned explicitly:
| Value | Meaning |
|---|---|
0 | Available |
1 | SoldOut at this branch |
2 | Unavailable at this branch |
Note
POST /orders independently revalidates every identifier and reprices the whole order server-side — a cached catalog response gives you no standing claim on stale state. Creating Orders
/api/partner/v1/orders Orders.CreateYou describe customer intent. iResto alone determines validity and financial truth.
Every identifier you submit is revalidated fresh, server-side, on every call. Every price, tax amount, discount, fee, and total is computed by iResto's own pricing engine — nothing you submit can influence the result except as a reconciliation assertion (see expectedTotal below).
Pickup example
curl -X POST "https://api.resto.isoft4is.net/api/partner/v1/orders" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>" \
-d '{
"branchId": "<BRANCH_ID>",
"orderMode": 2,
"customerName": "Jane Doe",
"customerPhone": "+15550009999",
"paymentMethod": 1,
"lines": [
{ "productId": "<PRODUCT_ID>", "variantId": "<VARIANT_ID>", "quantity": 1, "modifierSelections": [] }
],
"externalOrderId": "your-own-order-id-123"
}'Delivery example
deliveryZoneId and deliveryAddress are required together for Delivery mode; absent for Pickup.
curl -X POST "https://api.resto.isoft4is.net/api/partner/v1/orders" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>" \
-d '{
"branchId": "<BRANCH_ID>",
"orderMode": 1,
"customerName": "Jane Doe",
"customerPhone": "+15550009999",
"paymentMethod": 2,
"lines": [
{ "productId": "<PRODUCT_ID>", "variantId": "<VARIANT_ID>", "quantity": 1, "modifierSelections": [] }
],
"externalOrderId": "your-own-order-id-124",
"deliveryZoneId": "<DELIVERY_ZONE_ID>",
"deliveryAddress": {
"addressLine": "42 Example Rd",
"latitude": 31.930,
"longitude": 35.925
},
"expectedTotal": 13.00
}'Response
The complete, authoritative result — never an echo of anything you submitted.
{
"orderId": "<ORDER_ID>",
"orderNumber": "ORD-20260101-123456789",
"status": 1,
"paymentStatus": 1,
"currencyCode": "USD",
"subtotal": 10.00,
"discountTotal": 0,
"taxTotal": 1.50,
"deliveryFee": 3.00,
"serviceFee": 0,
"grandTotal": 14.50,
"externalOrderId": "your-own-order-id-124"
}orderMode / paymentMethod
| Field | Supported values | Rejected (400) |
|---|---|---|
orderMode | 1 Delivery, 2 Pickup | DineIn — V1 has no table/in-person-service concept. |
paymentMethod | 1 Cash, 2 CardOnDelivery | CardOnline — V1 has no Partner-initiated online payment and no "paid externally" field. |
Idempotency-Key ≠ ExternalOrderId
| <code>externalOrderId</code> | <code>Idempotency-Key</code> | |
|---|---|---|
| What it identifies | Your own durable business record for this order | This specific HTTP request attempt |
| Lifespan | Permanent — stays on the order forever | Disposable — only matters for retry/replay detection |
| Reuse | Never reused for a genuinely different order | A fresh key per logical attempt; the same key only to retry the identical attempt |
A different Idempotency-Key with the same externalOrderId (for your installation) is rejected with 409 (tenancy.partner_api.external_order_id_conflict):
{
"status": 409,
"title": "Conflict",
"detail": "An order with this externalOrderId already exists for your installation.",
"code": "tenancy.partner_api.external_order_id_conflict"
}expectedTotal
Optional. Compared, after iResto computes the real grandTotal, against your expectation. A mismatch rejects with 409 (tenancy.order.price_mismatch) and creates nothing — your externalOrderId and Idempotency-Key both remain available for a corrected retry.
Delivery-zone / coordinate validation
For a Delivery order, the submitted zone must exist, belong to the submitted branch, be active, and the submitted coordinates must fall inside its polygon — the exact same geometry enforcement the first-party checkout uses. Any failure is rejected before persistence.
What V1 does not support
- DineIn orders, or any other order mode besides Pickup/Delivery.
- CardOnline, or any "paid via iResto's own checkout" / "already paid" payment assertion.
- Submitting an arbitrary order status,
orderNumber, orpaymentStatus. - Submitting any financial value as authoritative — only
expectedTotal, as a non-authoritative guard.
Reading Orders
/api/partner/v1/orders/{orderId} Orders.Create or Orders.Readcurl "https://api.resto.isoft4is.net/api/partner/v1/orders/<ORDER_ID>" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>"Granted by either of two independent ownership relationships — never by holding a scope alone:
| Ownership model | Requirement | Extra fields in the response |
|---|---|---|
| Source-owned | Orders.Create + your installation created this order via POST /orders | paymentStatus, externalOrderId, subtotal, discountTotal, taxTotal, deliveryFee, serviceFee |
| Delivery-assignment-owned | Orders.Read + you hold the order's live delivery assignment | The fields above are null — never exposed to a delivery-owned read |
Every failure (wrong installation, no matching ownership, or a nonexistent order) returns the same generic 404 — "doesn't exist" and "exists but isn't yours" are deliberately indistinguishable.
Source-owned response (includes reconciliation fields)
{
"orderId": "<ORDER_ID>",
"orderNumber": "ORD-20260101-123456789",
"status": 1,
"orderMode": 2,
"tableNumber": null,
"customerName": "Jane Doe",
"customerPhone": "+15550009999",
"grandTotal": 11.50,
"currencyCode": "USD",
"items": [
{ "productName": "Classic Cheeseburger", "variantName": "Regular", "quantity": 1, "lineTotal": 8.50, "modifiers": [] }
],
"deliveryAddress": null,
"branch": { "name": "Downtown", "address": "1 Main St", "latitude": 31.93, "longitude": 35.92, "externalLocationId": "STORE-4821" },
"paymentStatus": 1,
"externalOrderId": "your-own-order-id-123",
"subtotal": 10.00,
"discountTotal": 0,
"taxTotal": 1.50,
"deliveryFee": 0,
"serviceFee": 0
}Delivery-owned response (financial/reconciliation fields are null)
{
"orderId": "<ORDER_ID>",
"orderNumber": "ORD-20260101-123456789",
"status": 2,
"orderMode": 1,
"tableNumber": null,
"customerName": "Jane Doe",
"customerPhone": "+15550009999",
"grandTotal": 14.50,
"currencyCode": "USD",
"items": [
{ "productName": "Classic Cheeseburger", "variantName": "Regular", "quantity": 1, "lineTotal": 8.50, "modifiers": [] }
],
"deliveryAddress": { "label": null, "addressLine": "42 Example Rd", "latitude": 31.93, "longitude": 35.925, "instructions": null },
"branch": { "name": "Downtown", "address": "1 Main St", "latitude": 31.93, "longitude": 35.92, "externalLocationId": "STORE-4821" },
"paymentStatus": null,
"externalOrderId": null,
"subtotal": null,
"discountTotal": null,
"taxTotal": null,
"deliveryFee": null,
"serviceFee": null
}Cancelling an order you created
/api/partner/v1/orders/{orderId}/cancel Orders.Create A source may withdraw its own order, but only while it is still Pending — once the tenant has acted on it (accepted, etc.) it can no longer be cancelled this way. No request body; the order id and an Idempotency-Key header are the whole request. Reuses Orders.Create as both the authorization gate and the ownership proof — there is no separate cancel scope.
curl -X POST "https://api.resto.isoft4is.net/api/partner/v1/orders/<ORDER_ID>/cancel" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>"Response:
{
"orderId": "<ORDER_ID>",
"orderNumber": "ORD-20260101-123456789",
"status": 8,
"externalOrderId": "your-own-order-id-123"
}If the order has already moved past Pending:
{
"status": 409,
"title": "Conflict",
"detail": "Order '<ORDER_ID>' can no longer be cancelled by its source; its status is 'Accepted'.",
"code": "tenancy.partner_api.order_not_cancellable",
"status_detail": { "status": "Accepted" }
}Note
delivery.cancelled. If your own installation is also the source, you separately receive orders.cancelled (see Events). Delivery Provider API
Lifecycle: Assigned → Accepted → InTransit → Delivered, plus Reject (before accepting) and Cancel (after accepting). Every action resolves your installation server-side and returns the assignment's current state.
Accept
/api/partner/v1/deliveries/{orderId}/accept Delivery.Acceptcurl -X POST "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/accept" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>"Response (shared shape for every delivery mutation below):
{
"assignmentId": "<ASSIGNMENT_ID>",
"orderId": "<ORDER_ID>",
"appInstallationId": "<APP_INSTALLATION_ID>",
"status": 2,
"assignedAtUtc": "2026-10-04T12:00:00Z",
"respondedAtUtc": "2026-10-04T12:00:05Z",
"driverName": null,
"driverPhone": null,
"etaUtc": null,
"trackingUrl": null
}Reject — only while Assigned
/api/partner/v1/deliveries/{orderId}/reject Delivery.Rejectcurl -X POST "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/reject" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>"Cancel — only while Accepted or InTransit
/api/partner/v1/deliveries/{orderId}/cancel Delivery.Cancelcurl -X POST "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/cancel" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>"Warning
cancel on an Assigned delivery, or reject on an already-accepted one, returns 409 — pick the one matching the delivery's actual state. Neither triggers automatic reassignment or touches the order's own status/payment. Update driver
/api/partner/v1/deliveries/{orderId}/driver Delivery.UpdateDrivercurl -X PATCH "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/driver" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>" \
-d '{ "driverName": "Omar K.", "driverPhone": "+15550001234" }'Update ETA
/api/partner/v1/deliveries/{orderId}/eta Delivery.UpdateEtacurl -X PATCH "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/eta" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>" \
-d '{ "etaUtc": "2026-10-04T12:35:00Z" }'Update tracking
/api/partner/v1/deliveries/{orderId}/tracking Delivery.UpdateTrackingMust be an absolute https:// URL.
curl -X PATCH "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/tracking" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>" \
-d '{ "trackingUrl": "https://track.example.com/<ORDER_ID>" }'Update status
/api/partner/v1/deliveries/{orderId}/status Delivery.UpdateStatusLegal targets via this route are InTransit (4) and Delivered (5).
curl -X PATCH "https://api.resto.isoft4is.net/api/partner/v1/deliveries/<ORDER_ID>/status" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: <IDEMPOTENCY_KEY>" \
-d '{ "targetStatus": 4 }'Branch Mapping
iResto Branch ↔ your own externalLocationId — an opaque string (up to 256 characters) the tenant configures in their Store Dashboard to identify a branch in your system. It is null when unmapped.
Note
externalLocationId is snapshotted at assignment time — it does not silently change during an active delivery. If the tenant changes the mapping later, an already-assigned order you're fulfilling keeps the value that was current when you were assigned; only a new assignment created after the change reflects the new value. There is no Partner API endpoint to list or manage branch mappings yourself — that configuration is a tenant-side Store Dashboard action. You only ever read the frozen per-order value via GET /orders/{orderId}'s branch.externalLocationId, or the catalog's own branch.externalLocationId for your own reference.
Webhooks
If your app subscribes to events, iResto delivers them as signed HTTPS POST requests to the callback URL on your app's Marketplace contract version.
Envelope
{
"id": "<DELIVERY_ID>",
"type": "orders.created",
"version": 1,
"occurredAt": "2026-10-04T12:00:00Z",
"data": { "orderId": "<ORDER_ID>", "orderNumber": "ORD-20260101-123456789", "status": "Pending" }
}Headers
| Header | Value |
|---|---|
X-IResto-Event-Id | Same value as the envelope's id — stable across every retry of this delivery. |
X-IResto-Timestamp | Unix timestamp (seconds) of the send attempt, as a decimal string. |
X-IResto-Signature | HMAC-SHA256 signature, lowercase hex. |
Signature construction
HMAC-SHA256(secret, "{unixTimestamp}.{rawRequestBody}")Warning
Working examples (constant-time comparison where the language's standard library supports it):
public static bool VerifyWebhookSignature(
string webhookSigningSecret, string timestampHeader, byte[] rawBodyBytes, string signatureHeader)
{
var prefix = Encoding.UTF8.GetBytes(timestampHeader + ".");
var signedPayload = new byte[prefix.Length + rawBodyBytes.Length];
Buffer.BlockCopy(prefix, class="docs-tok-number">0, signedPayload, class="docs-tok-number">0, prefix.Length);
Buffer.BlockCopy(rawBodyBytes, class="docs-tok-number">0, signedPayload, prefix.Length, rawBodyBytes.Length);
var keyBytes = Encoding.UTF8.GetBytes(webhookSigningSecret);
var expected = Convert.ToHexString(HMACSHA256.HashData(keyBytes, signedPayload)).ToLowerInvariant();
var expectedBytes = Encoding.UTF8.GetBytes(expected);
var actualBytes = Encoding.UTF8.GetBytes(signatureHeader);
return expectedBytes.Length == actualBytes.Length && CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
// IMPORTANT: rawBodyBytes must be the exact bytes read from the request body —
// never a re-serialized JSON object. Read the raw stream before any model binding.import { createHmac, timingSafeEqual } from "node:crypto";
// IMPORTANT: use the raw request body bytes (e.g. express.raw(), never a re-serialized
// JSON.stringify(req.body)) — re-serializing can silently change byte-for-byte content.
function verifyWebhookSignature(secret: string, timestampHeader: string, rawBody: Buffer, signatureHeader: string): boolean {
const signedPayload = Buffer.concat([Buffer.from(`${timestampHeader}.`), rawBody]);
const expected = createHmac("sha256", secret).update(signedPayload).digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
const actualBuf = Buffer.from(signatureHeader, "utf8");
return expectedBuf.length === actualBuf.length && timingSafeEqual(expectedBuf, actualBuf);
}import hmac
import hashlib
def verify_webhook_signature(secret: str, timestamp_header: str, raw_body: bytes, signature_header: str) -> bool:
# IMPORTANT: raw_body must be the exact request bytes (e.g. Flask's request.get_data(),
# not json.dumps(request.json)) — re-serializing can silently change the byte content.
signed_payload = f"{timestamp_header}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)At-least-once delivery
X-IResto-Event-Id is stable across retries of the same delivery. Your receiver must deduplicate by this id and process it at most once. Respond 2xx quickly once durably accepted — don't do slow synchronous work in the handler.
Events
Only events genuinely emitted in the deployed runtime, per your app's subscribed EventsJson:
| Event | Broadcast / Targeted | Payload | Notes |
|---|---|---|---|
orders.created | Broadcast — every subscribed installation | Order summary | Fired for every new order, including one you created yourself — no source-exclusion. |
payment.completed | Broadcast | Payment/order summary | Raised on a real gateway-confirmed payment transition. |
delivery.assigned | Targeted — the assigned installation only | orderId, orderNumber, assignmentId | Never broadcast to any other installation. |
delivery.cancelled | Targeted — the formerly-assigned installation | orderId, orderNumber, assignmentId | Fired when the tenant cancels the order and it had a live assignment to you. Not fired when you cancel it yourself. |
delivery.superseded | Targeted — the superseded installation | orderId, orderNumber, assignmentId | Fired on reassignment to a different installation. The new installation gets its own delivery.assigned. |
orders.cancelled | Targeted — the order's source installation only | orderId, orderNumber, externalOrderId, status | Fired whenever an order with a source installation becomes Cancelled — by the tenant, or by the source itself via POST /orders/{orderId}/cancel. |
Warning
orders.updated and catalog.updated are registered event-type codes but have no active emission site today — do not build against either; no webhook is currently sent for them. Order-status polling via GET /orders/{orderId} remains the only way to observe most status transitions. None of the three delivery.* events or orders.cancelled carry customer PII, an address, financial data, or a free-text reason — only bare identifiers. Use GET /orders/{orderId} if your integration needs more.
Note
system.test is a reserved diagnostic event the tenant can trigger manually to verify your receiver is reachable — delivered through the same signed, retried pipeline, but it is not a subscribable business event and carries no business data. Idempotency
Every mutating call requires an Idempotency-Key header (any string, no leading/trailing whitespace, at most 128 characters). Missing it is rejected with 400.
| Scenario | Behavior |
|---|---|
| Same key + same payload | The original frozen response is returned verbatim — never re-executed. Safe to retry on timeout/network failure. |
| Same key + different payload | 409 — a client bug (reused key, different intent), not a retry signal. |
| Two concurrent requests, same key | Exactly one mutation happens; the other transparently replays the winner's result. |
Scoped per installation — your key never collides with another installation's identical literal string.
# First attempt — network times out before you see the response
curl -X POST "https://api.resto.isoft4is.net/api/partner/v1/orders" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Idempotency-Key: 8f14e45f-ceea-4f3e-9f6f-example" \
-d '{ "...": "same request body" }'
# Safe retry — SAME key, SAME body: returns the original frozen response, never a duplicate order
curl -X POST "https://api.resto.isoft4is.net/api/partner/v1/orders" \
-H "Authorization: ApiKey <KEY_ID>.<API_SECRET>" \
-H "Idempotency-Key: 8f14e45f-ceea-4f3e-9f6f-example" \
-d '{ "...": "same request body" }'Retries & Reliability
If your webhook receiver responds with a retryable outcome (5xx, 408/429, or a network-level timeout), iResto retries with exponential backoff:
| Parameter | Value |
|---|---|
| Base delay | 2 minutes |
| Multiplier | ×3 per attempt |
| Max delay per attempt (cap) | 12 hours |
| Max attempts | 8 |
| Jitter | ±20%, applied after the cap |
| Nominal delay sequence | 2, 6, 18, 54, 162, 486 min, then capped at 12h — roughly 24h total retry window |
Any other 4xx (400/401/403/404/422/etc.) is a permanent failure and is never retried — return one of these if your endpoint can return a definitive rejection.
Note
X-IResto-Event-Id. These are the actual current code values — don't assume a stronger timing guarantee than listed; the schedule may be tuned over time. Rate Limits
| Limit | Scope | Window |
|---|---|---|
| 120 requests | Pre-authentication, by IP address | 1 minute |
| 60 requests | Per installation, once authenticated — catalog reads share this same budget | 1 minute |
Exceeding either returns 429 with a Retry-After header (seconds). Respect it — do not retry in a tight loop.
Errors
Every error is an RFC 7807 application/problem+json body with a stable code field:
{
"status": 403,
"title": "Forbidden",
"detail": "Your installation does not have the required scope for this action.",
"code": "auth.insufficient_scope"
}| Status | Meaning |
|---|---|
400 | Malformed request — validation failure, missing required field, missing/reused Idempotency-Key with a different payload, etc. |
401 | Missing, invalid, or revoked credential. |
403 | Valid credential, but your installation lacks the required scope (auth.insufficient_scope). |
404 | The resource doesn't exist, or exists but isn't visible to your installation — deliberately indistinguishable. |
409 | A state conflict — an invalid delivery-status transition, an idempotency-key reused for different intent, a price mismatch, or a duplicate externalOrderId. |
429 | Rate limited — see above. |
These are deliberately indistinguishable from the outside:
- Invalid authentication (401) vs. insufficient scope (403) — these ARE distinguished; don't conflate them.
- Ownership-hidden 404 — "doesn't exist" vs. "exists but isn't yours" are never told apart.
- Validation failure (400) vs. idempotency conflict (409) vs. concurrency conflict (409) — each has its own
codevalue; inspect it, don't infer from the status alone.
Integration Guides
Build an Order Source
Catalog → Create Order → GET/Poll.
- Call
Catalog.Readto get real branch/product/variant/modifier ids for the branch your customer picked. - Build line items from those ids and call
POST /orderswith a freshIdempotency-Keyand your ownexternalOrderId. - Store the returned
orderIdalongside your own order record. - Poll
GET /orders/{orderId}on your own schedule to observe status changes — V1 has no order-status webhook. - If the order is still
Pendingand the customer wants to back out, callPOST /orders/{orderId}/cancel.
async function pollOrderUntilSettled(orderId: string): Promise<void> {
const SETTLED_STATUSES = new Set([class="docs-tok-number">6, class="docs-tok-number">7, class="docs-tok-number">8]); // Completed, Rejected, Cancelled
while (true) {
const response = await fetch(`https://api.resto.isoft4is.net/api/partner/v1/orders/${orderId}`, {
headers: { Authorization: authHeader },
});
const order = await response.json();
if (SETTLED_STATUSES.has(order.status)) {
return; // done — update your own system of record
}
await new Promise((resolve) => setTimeout(resolve, 15_000)); // poll on your own schedule
}
}Build a Delivery Provider
delivery.assigned webhook → GET order → Accept → Driver → ETA → Tracking → InTransit → Delivered.
// 1. Receive + verify the delivery.assigned webhook (see Webhooks above)
app.post("/webhooks/iresto", async (req, res) => {
if (!verifyWebhookSignature(secret, req.header("X-IResto-Timestamp")!, req.rawBody, req.header("X-IResto-Signature")!)) {
return res.status(class="docs-tok-number">401).end();
}
const envelope = JSON.parse(req.rawBody.toString("utf8"));
if (envelope.type === "delivery.assigned") {
await handleDeliveryAssigned(envelope.data.orderId); // enqueue; respond 2xx fast
}
res.status(class="docs-tok-number">200).end();
});
// 2. Fetch full order details, then move through the lifecycle
async function handleDeliveryAssigned(orderId: string) {
const order = await getOrder(orderId); // GET /api/partner/v1/orders/{orderId}
await acceptDelivery(orderId); // POST /deliveries/{orderId}/accept
await updateDriver(orderId, "Omar K.", "+15550001234"); // PATCH .../driver
await updateEta(orderId, new Date(Date.now() + class="docs-tok-number">25 * 60_000).toISOString()); // PATCH .../eta
await updateTracking(orderId, `https://track.example.com/${orderId}`); // PATCH .../tracking
await updateStatus(orderId, class="docs-tok-number">4); // PATCH .../status -> InTransit
// ... driver completes the delivery ...
await updateStatus(orderId, class="docs-tok-number">5); // PATCH .../status -> Delivered
}