OpenDelivery integration for POS systems
Central de Pedidos receives the store’s orders from iFood, Keeta, 99 and its own delivery channel, and forwards all of them to your system (POS, ERP or management software). Your system sends back the status changes, and the end customer keeps tracking the order in the app where they bought, with no one opening the marketplace portal.
The integration follows the OpenDelivery v2 standard (2.0.0-rc.3). If your system already
consumes it from another provider, almost nothing here is new.
This documentation covers the sandbox environment. The production URL is provided when your integration is certified.
{BASE} = https://api-homolog.centraldepedidos.delivery/opendelivery
| Module | What it does | Status |
|---|---|---|
| Authentication | OAuth 2.0 client_credentials token |
available |
| Orders | get the order and change its status | available |
| Events | signed webhook or polling | available |
| Merchant | store data and integration settings | coming soon |
Roles
The standard defines two sides:
| Role | Who | What it does |
|---|---|---|
| Ordering Application | Central de Pedidos | owns the order, hosts the APIs and sends the events |
| Software Service | you, the POS | consumes the orders, changes the status and receives the events |
In practice, you call our APIs to fetch orders and change their status, and we call your webhook to tell you something changed.
iFood / Keeta / 99 / own delivery
│
▼
┌──────────────────────┐
│ Central de Pedidos │ Ordering Application
└──────────────────────┘
│ ▲
webhook │ │ POST /orders/{id}/confirm, /dispatch…
(event) ▼ │ GET /orders/{id}
┌──────────────────────┐
│ your POS │ Software Service
└──────────────────────┘
Before you start
There are two different secrets, one for each direction. Mixing them up is the most common mistake in this integration.
| What it is | Who creates it | What it is for | |
|---|---|---|---|
clientId |
identifies the store and the integration | Central | requesting the token |
clientSecret |
Central’s secret | Central, shown only once | requesting the token |
webhookUrl |
your URL, which receives the events | you | receiving notifications |
| webhook secret | your secret | you | we sign every notification with it |
The store owner sets up the integration in the Central dashboard. They enter your webhookUrl and
your webhook secret, and receive the clientId and clientSecret to pass on to you.
- The
clientSecretis shown only once. If it is lost, the store owner generates a new one and the old one stops working immediately. - One pair of credentials covers one store. If you serve several Central stores, you will have one pair per store.
Authentication
POST {BASE}/oauth/token
Content-Type: application/json
{
"grantType": "client_credentials",
"clientId": "4vK2p…",
"clientSecret": "K9xQ…"
}
We also accept grant_type, client_id and client_secret. Use whichever style your HTTP client
already produces.
{
"accessToken": "eyJ…",
"tokenType": "bearer",
"expiresIn": 3600
}
Send the token on every other call:
Authorization: Bearer {accessToken}
Request a token once an hour, not on every call. Store it and renew it shortly before it expires.
| Code | When |
|---|---|
200 |
token issued |
400 |
clientId or clientSecret missing, or unsupported grantType |
401 |
invalid credentials (we do not distinguish “does not exist” from “wrong secret”) |
403 |
the integration is disabled in the store’s dashboard |
When a token stops working early
In the three cases below, the next call returns 403, effective immediately:
- the store owner disables the integration;
- the store owner generates a new secret (the old one dies at once);
- the store owner deletes the integration.
Handle 403 like this: request a new token and, if it fails again, contact the store owner.
Receiving events by webhook
When an order arrives at the store or changes state, we call your webhookUrl:
POST {your webhookUrl}
Content-Type: application/json
X-App-Id: f8ed2d7a-…
X-App-MerchantId: store-123
X-App-Signature: 9a7f2c…
{
"eventId": "8c1f…",
"eventType": "CREATED",
"orderId": "9f2c…",
"orderURL": "{BASE}/orders/9f2c…",
"createdAt": "2026-09-22T14:30:00.000Z",
"sourceAppId": "f8ed2d7a-…"
}
The event does not carry the order. It tells you something happened and where to fetch it:
follow the orderURL or call GET /orders/{orderId}.
Reply 204, and quickly
Reply 204 No Content (or 200 with an empty body) as soon as you receive it, without waiting
to finish processing.
2xx: delivered.4xx: permanent error. The notification is not resent.5xxor timeout: we retry with increasing backoff. Once retries run out, the order is flagged as not delivered in the store’s dashboard.
Verify the signature
The three headers are mandatory in the standard:
| Header | What it is |
|---|---|
X-App-Id |
our application id (fixed) |
X-App-MerchantId |
the store of the event: its id in your system, when the store owner registered it |
X-App-Signature |
HMAC-SHA256 of the raw body, hex-encoded, keyed with your webhook secret |
import hmac, hashlib
expected = hmac.new(webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers['X-App-Signature']):
return 401
The key is the secret you provided at setup, not the
clientSecret. Sign the bytes you received, not re-serialized JSON: any difference in whitespace, key order or character encoding changes the hash.
If your webhook requires its own authentication, the store owner can enable an extra
Authorization header at setup (a fixed Bearer token, or OAuth 2.0 client_credentials against
your token URL). The three headers above are always sent.
Deduplicate by eventId
The same event may arrive more than once, because of retries, timeouts or network issues. The
eventId is the same on every attempt: store the ones you have processed and ignore repeats.
Do not assume arrival order. If two events arrive out of order, resolve it with
GET /orders/{orderId}, which is the source of truth.
Polling for events
Use polling if your POS has no public address (it runs inside the restaurant, behind NAT) or as a safety net for when your webhook is down. The store owner picks the delivery mode in the integration settings:
| Mode | What happens |
|---|---|
WEBHOOK |
we only push. Polling returns 403 POLLING_NOT_ENABLED. |
POLLING |
we push nothing. You fetch. |
BOTH |
we push and keep. Whatever your side does not confirm stays available for polling. |
Fetch
GET {BASE}/events:polling
Authorization: Bearer {accessToken}
200 [
{ "eventId": "999f01b4-…", "eventType": "READY_FOR_PICKUP",
"orderId": "54785df5-…",
"orderURL": "{BASE}/orders/54785df5-…",
"createdAt": "2026-09-24T14:15:21.753Z",
"sourceAppId": "f8ed2d7a-…" }
]
204 // nothing pending
Events come oldest first, up to 1000 per call. The filter is optional:
?eventType=CONFIRMED&eventType=DISPATCHED. As the standard requires, types outside the filter are
discarded and do not come back.
Acknowledge
POST {BASE}/events/acknowledgment
Authorization: Bearer {accessToken}
Content-Type: application/json
{ "eventIds": ["999f01b4-…", "b752c253-…"] }
202 { "acknowledged": 2, "received": 2 }
Acknowledge everything you received, including types you do not use. Anything not acknowledged
comes back on the next fetch. An unknown id is not an error: you get 202 with a lower
acknowledged.
Events expire after 8 hours, acknowledged or not. If your POS is offline for longer than that, reconcile through
GET /orders/{orderId}.
In BOTH mode, an event delivered with 2xx on the webhook leaves the polling queue. If the 2xx
is lost on the way back, the same event may arrive through both paths, which is why
deduplication by eventId applies to both.
Get the order
GET {BASE}/orders/{orderId}
Authorization: Bearer {accessToken}
This is the protocol’s source of truth: the status field is the real state of the order, and
events are only notifications. Never infer the final status from the events. When in doubt (a
lost event, out of order, a failed webhook), fetch the order.
The format is the OpenDelivery v2 Order.
Amounts are in reais, with decimals. Every price comes as
{"value": 45.90, "currency": "BRL"}. If your system works in cents, convert when reading.
{
"id": "9f2c…",
"displayId": "1042",
"createdAt": "2026-09-22T14:00:00.000Z",
"status": "CONFIRMED",
"lastEvent": "CONFIRMED",
"merchant": { "id": "…", "name": "Test Pizzeria", "externalCode": "store-123" },
"timing": { "orderTiming": "INSTANT" },
"fulfillment": {
"orderType": "DELIVERY",
"delivery": {
"address": { "country": "BR", "street": "Rua A", "number": "10",
"city": "Porto Alegre", "state": "RS", "postalCode": "90000000",
"neighborhood": "Centro" },
"deliveredBy": "MARKETPLACE",
"estimatedDeliveryDateTime": "2026-09-22T14:40:00.000Z"
}
},
"items": [{
"id": "i1", "index": 0, "name": "Pizza G", "externalCode": "SKU-1",
"quantity": 1, "unit": "UN",
"pricing": { "unitPrice": {"value": 45.90, "currency": "BRL"},
"optionsPrice": {"value": 5.00, "currency": "BRL"},
"totalPrice": {"value": 50.90, "currency": "BRL"} },
"options": [{ "id": "o1", "index": 0, "name": "Borda", "quantity": 1, "unit": "UN",
"pricing": { "unitPrice": {"value": 5.00, "currency": "BRL"},
"totalPrice": {"value": 5.00, "currency": "BRL"} } }]
}],
"otherFees": [{ "name": "Taxa de entrega", "type": "DELIVERY_FEE",
"receivedBy": "MARKETPLACE",
"price": {"value": 7.00, "currency": "BRL"} }],
"total": { "itemsPrice": {"value": 50.90, "currency": "BRL"},
"otherFees": {"value": 7.00, "currency": "BRL"},
"discount": {"value": 0.00, "currency": "BRL"},
"orderAmount": {"value": 57.90, "currency": "BRL"} },
"payments": { "prepaid": 57.90, "pending": 0.00,
"methods": [{ "value": 57.90, "currency": "BRL",
"type": "PREPAID", "method": "CREDIT", "brand": "VISA" }] },
"customer": { "id": "cus-1", "name": "Ana",
"phone": {"number": "+5551999990000"}, "ordersCountOnMerchant": 3 }
}
items[].externalCode is what links the item to your menu. It comes from the external code
registered in the store’s catalog. If it is empty, the order arrives and you cannot post it. Check
this with the store owner before the first order.
Changing the status
There is one endpoint per transition:
| Call | The order becomes |
|---|---|
POST {BASE}/orders/{orderId}/confirm |
CONFIRMED |
POST {BASE}/orders/{orderId}/preparing |
PREPARING |
POST {BASE}/orders/{orderId}/ready-for-pickup |
READY |
POST {BASE}/orders/{orderId}/dispatch |
IN_DELIVERY |
POST {BASE}/orders/{orderId}/delivered |
DELIVERED |
All of them return 202 Accepted.
202 means “accepted”, not “applied”
The real state arrives later, in the matching event or in GET /orders/{orderId}.
Repeating also returns 202. Confirming an order that is already confirmed is not an error.
409 is reserved for a truly invalid transition, such as confirming a cancelled order.
Status can go back
We do not block regressions: if your POS sends preparing after dispatch, we accept it. That lets
you fix a mistake without a special route.
An out-of-order event, however, is discarded: if an old retry arrives after a newer one, it is
not applied (the response is still 202). The comparison uses the event date.
Cancellation
There are two different paths.
You request a cancellation
POST {BASE}/orders/{orderId}/requestCancellation
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"reason": "Item out of stock after confirmation",
"code": "UNAVAILABLE_ITEM",
"mode": "MANUAL"
}
Accepted code values: SYSTEMIC_ISSUES, DUPLICATE_APPLICATION, UNAVAILABLE_ITEM,
RESTAURANT_WITHOUT_DELIVERY_PERSON, OUTDATED_MENU, ORDER_OUTSIDE_THE_DELIVERY_AREA,
BLOCKED_CUSTOMER, OUTSIDE_DELIVERY_HOURS, INTERNAL_DIFFICULTIES_OF_THE_RESTAURANT,
RISK_AREA, DELIVERY_PROBLEM.
mode is AUTO (an automatic decision by your system) or MANUAL (someone pressed the button).
The response is 202, but the order is not cancelled yet. The origin decides, and the outcome
arrives by event:
CANCELLATION_REQUESTED → CANCELLATION_REQUEST_ACCEPTED → CANCELLED (accepted)
CANCELLATION_REQUESTED → CANCELLATION_REQUEST_DENIED (denied)
Only CANCELLED means cancelled.
The origin cancels
When iFood, Keeta or the customer cancels, CANCELLED arrives directly and you must apply it.
On this path there is nothing to accept or deny.
Cancelling one item
POST {BASE}/orders/{orderId}/items/{itemId}/cancel is not supported yet and returns 501 with
code: NOT_IMPLEMENTED. We would rather say so than return 202 and do nothing.
Event vocabulary
Events that change the status
| Event | Resulting status | Mandatory |
|---|---|---|
CREATED |
CREATED |
yes |
CONFIRMED |
CONFIRMED |
yes |
PREPARING |
PREPARING |
no |
READY_FOR_PICKUP |
READY |
yes, for pickup |
DISPATCHED |
IN_DELIVERY |
no |
DELIVERED |
DELIVERED |
yes |
CANCELLED |
CANCELLED |
yes |
CONCLUDED |
CONCLUDED |
no |
Two misleading names:
CONCLUDEDcloses the order; it is not the delivery. It comes afterDELIVERED.DISPATCHEDis the event name; the status isIN_DELIVERY.
Informational events
CANCELLATION_REQUESTED, CANCELLATION_REQUEST_ACCEPTED, CANCELLATION_REQUEST_DENIED,
ITEM_CANCELLED, PREPARATION_REQUESTED, PICKUP_ONGOING, RIDER_ARRIVED_AT_STORE,
ORDER_COLLECTED, DELIVERY_ONGOING, ARRIVED_AT_CUSTOMER.
Acknowledge these events and move on: none of them changes the order.
PICKED_UP was removed from the core in v2. We still accept it as input, but never send it. Use
DISPATCHED or DELIVERED.
Errors
{
"code": "INVALID_ORDER_STATE_TRANSITION",
"message": "Cannot confirm an order in CANCELLED status"
}
| Code | Meaning | What to do |
|---|---|---|
400 |
invalid body or missing field | fix the request; retrying will not help |
401 |
missing, invalid or expired token | get a new token |
403 |
integration disabled, secret rotated, or order from another store | new token; if it persists, contact the store owner |
404 |
order does not exist in this store | check the orderId |
409 |
truly invalid transition | read the message (never happens on a repeat) |
501 |
feature not supported yet | see what we do not support yet |
5xx |
our failure | retry with increasing backoff |
Merchant
Coming soon. This module is not available yet.
It will let your system read the store’s data and manage the integration settings, such as the webhook URL and secret, through the API. Today the store owner does this in the Central dashboard.
The module’s documentation will be published here, together with a new version of the Insomnia collection.
Certification checklist
- I request a token once an hour, not on every call
- I handle
403by requesting a new token before giving up - My webhook replies
204before processing - I verify
X-App-Signatureover the raw body, using my webhook secret - I deduplicate by
eventIdand do not assume arrival order - After
CREATED, I fetch the order withGET /orders/{orderId} - The store’s menu items have an
externalCodemy system recognizes - I treat
202as “accepted”, not “applied”, and know that202on a repeat is success - Only
CANCELLEDcancels.CANCELLATION_REQUEST_ACCEPTEDdoes not - I apply
CANCELLEDfrom the origin without negotiating - I acknowledge informational events without touching the order
- If I poll: I acknowledge every event, including types I ignore
- If I poll: I reconcile through the order when offline for more than 8 hours
What we do not support yet
| Status | |
|---|---|
| Item cancellation | POST …/items/{itemId}/cancel returns 501; the ITEM_CANCELLED event is acknowledged and does not change the order |
| Merchant module | coming soon |
GET /orders/{id}/tracking, /details, /validateCode |
not published |
Insomnia collection
The collection has every call on this page, in the order you use them. You will find it in the Insomnia collection box: at the top of the page on mobile, next to the text on desktop.
- In Insomnia, Import → the downloaded
.jsonfile. - In the Sandbox environment, fill
client_idandclient_secretwith the store’s credentials. - Run 1 · Token first. Every other call picks up its token automatically.
- Fill
order_idwith theorderIdof an order you received.
The collection is versioned: every version stays available forever at the same address, and previous ones are listed in the download box.
Protocol reference: https://docs.opendelivery.com.br/protocol/orders/