Central de Pedidos

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 clientSecret is 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.
  • 5xx or 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:

  • CONCLUDED closes the order; it is not the delivery. It comes after DELIVERED.
  • DISPATCHED is the event name; the status is IN_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 403 by requesting a new token before giving up
  • My webhook replies 204 before processing
  • I verify X-App-Signature over the raw body, using my webhook secret
  • I deduplicate by eventId and do not assume arrival order
  • After CREATED, I fetch the order with GET /orders/{orderId}
  • The store’s menu items have an externalCode my system recognizes
  • I treat 202 as “accepted”, not “applied”, and know that 202 on a repeat is success
  • Only CANCELLED cancels. CANCELLATION_REQUEST_ACCEPTED does not
  • I apply CANCELLED from 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.

  1. In Insomnia, Import → the downloaded .json file.
  2. In the Sandbox environment, fill client_id and client_secret with the store’s credentials.
  3. Run 1 · Token first. Every other call picks up its token automatically.
  4. Fill order_id with the orderId of 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/

Insomnia collection

v1.0.0

Download .json

api-homolog.centraldepedidos.delivery

First release: token, order, status changes, cancellation and event polling.