Skip to content

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:

ActorRole
Integration developer / providerYou — the team building a delivery-provider or order-source integration.
iResto MarketplaceThe platform registry of apps and contract versions. Today, creating and publishing an app is an iResto Platform Admin action — see the note below.
Restaurant / tenantThe iResto merchant who installs your app, consents to its contract, and connects it from their Store Dashboard.
Partner backendYour own server — the thing that actually calls the Partner API and receives webhooks.

The lifecycle

iResto Platform AdminCreate Marketplace App
iResto Platform AdminDefine ContractVersion (auth type, scopes, events, webhook URL)
iResto Platform AdminPublish the version
RestaurantInstall (names + consents to that exact published version)
RestaurantConnect
iRestoCredentials issued
Partner backendCall Partner API + receive signed webhooks

Warning

Creating and publishing a Marketplace app is an iResto Platform Admin action today — there is no developer self-service registration portal. To get your integration registered (app created, its first 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.

Full guide

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.

Full guide

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 Authorization Endpoint, Token Endpoint, Client ID, and Client Secret in this flow belong to your OAuth authorization server — they are not iResto API endpoints. iResto is the OAuth client here, not the provider. You issue these values from your own system; iResto Platform Admin registers them against your Marketplace app.

The flow

RestaurantClicks Connect
iRestoRedirects browser to YOUR Authorization Endpoint
Your OAuth serverAuthenticates, redirects back with a code
iResto (server-side)Exchanges code at YOUR Token Endpoint using Client ID + Secret
iRestoStores the resulting access/refresh token for this installation

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

Regardless of connection type, Connect also always issues a Partner API key for inbound calls — OAuth2 is additive, not a replacement for the ApiKey header on Partner API requests.

Credentials Explained

Three distinct credential pairs can exist for one installation — never confuse one for another:

CredentialCreated whenWho stores itUsed for
Partner OAuth Client ID / Client SecretOnce, by iResto Platform Admin, when registering your app's OAuth connectionIssued by you (it's your own OAuth server's credential) and stored encrypted by iRestoiResto authenticating itself to your authorization server during the Connect flow's token exchange — never used on Partner API calls.
Partner API Key ID / API SecretAt Connect (and again on reconnect/rotate) — every installation gets one, regardless of connection typeYou — shown once, never retrievable againThe Authorization: ApiKey {KeyId}.{secret} header on every Partner API call you make.
Webhook Signing SecretAt Connect, only if your app subscribes to eventsYou — shown once, never retrievable againVerifying X-IResto-Signature on every webhook you receive.

Warning

None of these three are interchangeable. A Partner API Secret never verifies a webhook signature, a Webhook Signing Secret never authenticates a Partner API call, and your OAuth Client Secret is never sent to you by iResto at all — you already have it, since it's yours.

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:

  1. A tenant installs your app from the Marketplace and connects it from their Store Dashboard.
  2. You receive a Partner API key (KeyId + secret) and, if you subscribe to webhooks, a webhook signing secret — shown once.
  3. You authenticate every request with Authorization: ApiKey {KeyId}.{secret}.
  4. You call the Partner API (read the catalog, create orders, or act on an assigned delivery).
  5. 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

Secrets must never be exposed client-side. Only call the Partner API from your own backend — never from a browser, a mobile app bundle, or any code a customer can inspect.

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

ScopeGrants
Orders.CreatePOST /orders — also lets you read and cancel an order you created.
Orders.ReadGET /orders/{orderId} for an order whose live delivery assignment is yours.
Delivery.AcceptPOST /deliveries/{orderId}/accept
Delivery.RejectPOST /deliveries/{orderId}/reject
Delivery.CancelPOST /deliveries/{orderId}/cancel
Delivery.UpdateStatusPATCH /deliveries/{orderId}/status
Delivery.UpdateDriverPATCH /deliveries/{orderId}/driver
Delivery.UpdateEtaPATCH /deliveries/{orderId}/eta
Delivery.UpdateTrackingPATCH /deliveries/{orderId}/tracking
Catalog.ReadGET /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

GET/api/partner/v1/catalog/branches Catalog.Read

Returns every active branch belonging to the tenant that installed your app.

[
  {
    "id": "<BRANCH_ID>",
    "name": "Downtown",
    "isActive": true,
    "externalLocationId": "STORE-4821"
  }
]

Products (summary)

GET/api/partner/v1/catalog/branches/{branchId}/products Catalog.Read

The 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)

GET/api/partner/v1/catalog/branches/{branchId}/products/{productId} Catalog.Read

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

ValueMeaning
0Available
1SoldOut at this branch
2Unavailable at this branch

Note

Catalog prices are display/catalog values, not locked transaction prices. 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

POST/api/partner/v1/orders Orders.Create

You 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

FieldSupported valuesRejected (400)
orderMode1 Delivery, 2 PickupDineIn — V1 has no table/in-person-service concept.
paymentMethod1 Cash, 2 CardOnDeliveryCardOnline — V1 has no Partner-initiated online payment and no "paid externally" field.

Idempotency-Key ≠ ExternalOrderId

<code>externalOrderId</code><code>Idempotency-Key</code>
What it identifiesYour own durable business record for this orderThis specific HTTP request attempt
LifespanPermanent — stays on the order foreverDisposable — only matters for retry/replay detection
ReuseNever reused for a genuinely different orderA 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, or paymentStatus.
  • Submitting any financial value as authoritative — only expectedTotal, as a non-authoritative guard.

Reading Orders

GET/api/partner/v1/orders/{orderId} Orders.Create or Orders.Read
curl "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 modelRequirementExtra fields in the response
Source-ownedOrders.Create + your installation created this order via POST /orderspaymentStatus, externalOrderId, subtotal, discountTotal, taxTotal, deliveryFee, serviceFee
Delivery-assignment-ownedOrders.Read + you hold the order's live delivery assignmentThe 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

POST/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

Cancelling a source-owned order that still has a live delivery assignment also terminates that assignment — the assigned delivery installation receives 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

POST/api/partner/v1/deliveries/{orderId}/accept Delivery.Accept
curl -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

POST/api/partner/v1/deliveries/{orderId}/reject Delivery.Reject
curl -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

POST/api/partner/v1/deliveries/{orderId}/cancel Delivery.Cancel
curl -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

Reject and Cancel are not interchangeable. Calling 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

PATCH/api/partner/v1/deliveries/{orderId}/driver Delivery.UpdateDriver
curl -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

PATCH/api/partner/v1/deliveries/{orderId}/eta Delivery.UpdateEta
curl -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

PATCH/api/partner/v1/deliveries/{orderId}/tracking Delivery.UpdateTracking

Must 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

PATCH/api/partner/v1/deliveries/{orderId}/status Delivery.UpdateStatus

Legal 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

The resolved 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

HeaderValue
X-IResto-Event-IdSame value as the envelope's id — stable across every retry of this delivery.
X-IResto-TimestampUnix timestamp (seconds) of the send attempt, as a decimal string.
X-IResto-SignatureHMAC-SHA256 signature, lowercase hex.

Signature construction

HMAC-SHA256(secret, "{unixTimestamp}.{rawRequestBody}")

Warning

Verification must use the raw request bytes exactly as received, before any JSON re-serialization. Re-serializing (even with logically identical content) can silently change property ordering or whitespace and break verification.

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:

EventBroadcast / TargetedPayloadNotes
orders.createdBroadcast — every subscribed installationOrder summaryFired for every new order, including one you created yourself — no source-exclusion.
payment.completedBroadcastPayment/order summaryRaised on a real gateway-confirmed payment transition.
delivery.assignedTargeted — the assigned installation onlyorderId, orderNumber, assignmentIdNever broadcast to any other installation.
delivery.cancelledTargeted — the formerly-assigned installationorderId, orderNumber, assignmentIdFired when the tenant cancels the order and it had a live assignment to you. Not fired when you cancel it yourself.
delivery.supersededTargeted — the superseded installationorderId, orderNumber, assignmentIdFired on reassignment to a different installation. The new installation gets its own delivery.assigned.
orders.cancelledTargeted — the order's source installation onlyorderId, orderNumber, externalOrderId, statusFired 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.

ScenarioBehavior
Same key + same payloadThe original frozen response is returned verbatim — never re-executed. Safe to retry on timeout/network failure.
Same key + different payload409 — a client bug (reused key, different intent), not a retry signal.
Two concurrent requests, same keyExactly 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:

ParameterValue
Base delay2 minutes
Multiplier×3 per attempt
Max delay per attempt (cap)12 hours
Max attempts8
Jitter±20%, applied after the cap
Nominal delay sequence2, 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

At-least-once, never exactly-once. Deduplicate every webhook by 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

LimitScopeWindow
120 requestsPre-authentication, by IP address1 minute
60 requestsPer installation, once authenticated — catalog reads share this same budget1 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"
}
StatusMeaning
400Malformed request — validation failure, missing required field, missing/reused Idempotency-Key with a different payload, etc.
401Missing, invalid, or revoked credential.
403Valid credential, but your installation lacks the required scope (auth.insufficient_scope).
404The resource doesn't exist, or exists but isn't visible to your installation — deliberately indistinguishable.
409A state conflict — an invalid delivery-status transition, an idempotency-key reused for different intent, a price mismatch, or a duplicate externalOrderId.
429Rate 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 code value; inspect it, don't infer from the status alone.

Integration Guides

Build an Order Source

Catalog → Create Order → GET/Poll.

  1. Call Catalog.Read to get real branch/product/variant/modifier ids for the branch your customer picked.
  2. Build line items from those ids and call POST /orders with a fresh Idempotency-Key and your own externalOrderId.
  3. Store the returned orderId alongside your own order record.
  4. Poll GET /orders/{orderId} on your own schedule to observe status changes — V1 has no order-status webhook.
  5. If the order is still Pending and the customer wants to back out, call POST /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
}