Integração OpenDelivery para PDVs
A Central de Pedidos recebe os pedidos de iFood, Keeta, 99 e do delivery próprio da loja e repassa todos para o seu sistema (PDV, ERP ou sistema de gestão). O seu sistema devolve as mudanças de status, e o cliente final acompanha o pedido no aplicativo onde comprou, sem ninguém abrir o portal do marketplace.
A integração segue o padrão OpenDelivery v2 (2.0.0-rc.3). Se o seu sistema já consome esse
padrão de outro provedor, quase nada aqui é novo.
Esta documentação cobre o ambiente de homologação. A URL de produção é entregue na homologação da sua integração.
{BASE} = https://api-homolog.centraldepedidos.delivery/opendelivery
| Módulo | O que faz | Estado |
|---|---|---|
| Autenticação | token OAuth 2.0 client_credentials |
disponível |
| Pedidos | buscar o pedido e mudar o status | disponível |
| Eventos | webhook assinado ou busca por polling | disponível |
| Loja (Merchant) | dados da loja e configuração da integração | em breve |
Papéis
O padrão define dois lados:
| Papel | Quem | O que faz |
|---|---|---|
| Ordering Application | a Central de Pedidos | é dona do pedido, hospeda as APIs e envia os eventos |
| Software Service | você, o PDV | consome os pedidos, muda o status e recebe os eventos |
Na prática, você chama as nossas APIs para buscar pedidos e mudar status, e nós chamamos o seu webhook para avisar que algo mudou.
iFood / Keeta / 99 / delivery próprio
│
▼
┌──────────────────────┐
│ Central de Pedidos │ Ordering Application
└──────────────────────┘
│ ▲
webhook │ │ POST /orders/{id}/confirm, /dispatch…
(evento) ▼ │ GET /orders/{id}
┌──────────────────────┐
│ seu PDV │ Software Service
└──────────────────────┘
Antes de começar
São dois segredos diferentes, um em cada direção. Confundir os dois é o erro mais comum desta integração.
| O que é | Quem gera | Para que serve | |
|---|---|---|---|
clientId |
identifica a loja e a integração | a Central | pedir o token |
clientSecret |
o segredo da Central | a Central, exibido uma única vez | pedir o token |
webhookUrl |
a sua URL, que recebe os eventos | você | receber os avisos |
| segredo do webhook | o seu segredo | você | nós assinamos cada aviso com ele |
O lojista cadastra a integração no painel da Central. Ele informa a sua webhookUrl e o seu
segredo do webhook, e recebe o clientId e o clientSecret para repassar a você.
- O
clientSecretaparece uma vez só. Se ele se perder, o lojista gera outro, e o antigo deixa de valer na hora. - Um par de credenciais vale para uma loja. Se você atende várias lojas da Central, terá um par para cada uma.
Autenticação
POST {BASE}/oauth/token
Content-Type: application/json
{
"grantType": "client_credentials",
"clientId": "4vK2p…",
"clientSecret": "K9xQ…"
}
Também aceitamos grant_type, client_id e client_secret. Use o estilo que o seu cliente HTTP já
produz.
{
"accessToken": "eyJ…",
"tokenType": "bearer",
"expiresIn": 3600
}
Use o token em todas as outras chamadas:
Authorization: Bearer {accessToken}
Peça o token uma vez por hora, não a cada chamada. Guarde-o e renove pouco antes de expirar.
| Código | Quando |
|---|---|
200 |
token emitido |
400 |
falta clientId ou clientSecret, ou o grantType não é suportado |
401 |
credencial inválida (não distinguimos “não existe” de “segredo errado”) |
403 |
a integração está desativada no painel do lojista |
Quando o token para de valer antes da hora
Nos três casos abaixo, a próxima chamada responde 403, com efeito imediato:
- o lojista desativa a integração;
- o lojista gera um novo segredo (o antigo morre na hora);
- o lojista apaga a integração.
Trate 403 assim: peça um token novo e, se falhar de novo, fale com o lojista.
Receber eventos por webhook
Quando um pedido entra na loja ou muda de estado, chamamos a sua webhookUrl:
POST {sua webhookUrl}
Content-Type: application/json
X-App-Id: f8ed2d7a-…
X-App-MerchantId: loja-123
X-App-Signature: 9a7f2c…
{
"eventId": "8c1f…",
"eventType": "CREATED",
"orderId": "9f2c…",
"orderURL": "{BASE}/orders/9f2c…",
"createdAt": "2026-09-22T14:30:00.000Z",
"sourceAppId": "f8ed2d7a-…"
}
O evento não carrega o pedido. Ele avisa que algo aconteceu e diz onde buscar: siga a
orderURL ou chame GET /orders/{orderId}.
Responda 204, e rápido
Responda 204 No Content (ou 200 com corpo vazio) assim que receber, sem esperar terminar de
processar.
2xx: entregue.4xx: erro permanente. O aviso não é reenviado.5xxou timeout: reenviamos com espera crescente. Esgotadas as tentativas, o pedido fica marcado como não entregue no painel do lojista.
Confira a assinatura
Os três headers são obrigatórios no padrão:
| Header | O que é |
|---|---|
X-App-Id |
o id da nossa aplicação (fixo) |
X-App-MerchantId |
a loja do evento: o id dela no seu sistema, quando o lojista o cadastrou |
X-App-Signature |
HMAC-SHA256 do corpo cru, em hexadecimal, com o seu segredo do webhook |
import hmac, hashlib
esperado = hmac.new(segredo_do_webhook.encode(), corpo_cru, hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperado, request.headers['X-App-Signature']):
return 401
A chave é o segredo que você forneceu no cadastro, e não o
clientSecret. Assine os bytes recebidos, não o JSON reserializado: qualquer diferença de espaço, ordem de chave ou acentuação muda o hash.
Se o seu webhook exige autenticação própria, o lojista pode ativar no cadastro um Authorization
adicional (Bearer fixo ou OAuth 2.0 client_credentials contra a sua URL de token). Os três headers
acima continuam indo sempre.
Deduplique por eventId
O mesmo evento pode chegar mais de uma vez, por reenvio, timeout ou problema de rede. O eventId
é o mesmo em todas as tentativas: guarde os já processados e ignore as repetições.
Não assuma ordem de chegada. Se dois eventos chegarem trocados, resolva com
GET /orders/{orderId}, que é a fonte da verdade.
Buscar eventos por polling
Use polling se o seu PDV não tem endereço público (roda dentro do restaurante, atrás de NAT) ou como rede de segurança para quando o seu webhook estiver fora do ar. O lojista escolhe o modo de entrega no cadastro da integração:
| Modo | O que acontece |
|---|---|
WEBHOOK |
só empurramos. O polling responde 403 POLLING_NOT_ENABLED. |
POLLING |
não empurramos nada. Você busca. |
BOTH |
empurramos e guardamos. O que a sua ponta não confirmar fica disponível no polling. |
Buscar
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 // nada pendente
Os eventos vêm do mais antigo para o mais novo, até 1000 por chamada. O filtro é opcional:
?eventType=CONFIRMED&eventType=DISPATCHED. Como manda o padrão, os tipos fora do filtro são
descartados e não voltam.
Reconhecer
POST {BASE}/events/acknowledgment
Authorization: Bearer {accessToken}
Content-Type: application/json
{ "eventIds": ["999f01b4-…", "b752c253-…"] }
202 { "acknowledged": 2, "received": 2 }
Reconheça tudo o que recebeu, inclusive os tipos que você não usa. O que não for reconhecido
volta na próxima busca. Um id desconhecido não é erro: responde 202 com acknowledged menor.
O evento expira em 8 horas, reconhecido ou não. Se o seu PDV ficar fora do ar por mais tempo, reconcilie pelo
GET /orders/{orderId}.
No modo BOTH, um evento entregue com 2xx no webhook sai da fila de polling. Se o 2xx se perder
na volta, o mesmo evento pode chegar pelos dois caminhos, e por isso a deduplicação por eventId
vale para os dois.
Buscar o pedido
GET {BASE}/orders/{orderId}
Authorization: Bearer {accessToken}
É a fonte da verdade do protocolo: o campo status é o estado real do pedido, e os eventos são
só notificação. Nunca deduza o status final a partir dos eventos. Na dúvida (evento perdido,
fora de ordem, webhook que falhou), busque o pedido.
O formato é o Order do OpenDelivery v2.
Valores em reais, com casas decimais. Todo preço vem como
{"value": 45.90, "currency": "BRL"}. Se o seu sistema trabalha em centavos, converta na leitura.
{
"id": "9f2c…",
"displayId": "1042",
"createdAt": "2026-09-22T14:00:00.000Z",
"status": "CONFIRMED",
"lastEvent": "CONFIRMED",
"merchant": { "id": "…", "name": "Pizzaria do Teste", "externalCode": "loja-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 }
}
O items[].externalCode é o que liga o item ao seu cardápio. Ele vem do código externo
cadastrado no catálogo da loja. Se estiver vazio, o pedido chega e você não consegue lançá-lo.
Confira isso com o lojista antes do primeiro pedido.
Mudar o status
Há um endpoint por transição:
| Chamada | O pedido passa a |
|---|---|
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 |
Todos respondem 202 Accepted.
202 quer dizer “aceito”, não “aplicado”
O estado real chega depois, no evento correspondente ou em GET /orders/{orderId}.
Repetir também responde 202. Confirmar um pedido já confirmado não é erro. O 409 fica
reservado para uma transição realmente inválida, como confirmar um pedido cancelado.
O status pode voltar
Não bloqueamos regressão: se o seu PDV mandar preparing depois de dispatch, aceitamos. Assim
você corrige um engano sem rota especial.
Já um evento fora de ordem é descartado: se um reenvio antigo chegar depois de um mais novo, ele
não é aplicado (a resposta continua 202). A comparação é pela data do evento.
Cancelamento
São dois caminhos diferentes.
Você pede para cancelar
POST {BASE}/orders/{orderId}/requestCancellation
Authorization: Bearer {accessToken}
Content-Type: application/json
{
"reason": "Item em falta após a confirmação",
"code": "UNAVAILABLE_ITEM",
"mode": "MANUAL"
}
Valores aceitos em code: 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 é AUTO (decisão automática do seu sistema) ou MANUAL (alguém apertou o botão).
A resposta é 202, mas o pedido ainda não está cancelado. Quem decide é a origem, e o
resultado chega por evento:
CANCELLATION_REQUESTED → CANCELLATION_REQUEST_ACCEPTED → CANCELLED (aceito)
CANCELLATION_REQUESTED → CANCELLATION_REQUEST_DENIED (recusado)
Só CANCELLED significa cancelado.
A origem cancela
Quando quem cancela é o iFood, a Keeta ou o cliente, chega CANCELLED direto e você deve
aplicar. Nesse caminho não existe aceitar nem recusar.
Cancelar um item
POST {BASE}/orders/{orderId}/items/{itemId}/cancel ainda não é suportado e responde 501 com
code: NOT_IMPLEMENTED. Preferimos dizer isso a responder 202 sem fazer nada.
Vocabulário de eventos
Eventos que mudam o status
| Evento | Status resultante | Obrigatório |
|---|---|---|
CREATED |
CREATED |
sim |
CONFIRMED |
CONFIRMED |
sim |
PREPARING |
PREPARING |
não |
READY_FOR_PICKUP |
READY |
sim, em retirada |
DISPATCHED |
IN_DELIVERY |
não |
DELIVERED |
DELIVERED |
sim |
CANCELLED |
CANCELLED |
sim |
CONCLUDED |
CONCLUDED |
não |
Dois nomes que enganam:
CONCLUDEDé o fechamento do pedido, não a entrega. Ele vem depois deDELIVERED.DISPATCHEDé o nome do evento; o status éIN_DELIVERY.
Eventos informativos
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.
Reconheça esses eventos e siga em frente: nenhum deles altera o pedido.
PICKED_UP saiu do núcleo na v2. Continuamos aceitando na entrada, mas nunca o enviamos. Use
DISPATCHED ou DELIVERED.
Erros
{
"code": "INVALID_ORDER_STATE_TRANSITION",
"message": "Cannot confirm an order in CANCELLED status"
}
| Código | Significado | O que fazer |
|---|---|---|
400 |
corpo inválido ou campo faltando | corrigir a requisição; repetir não adianta |
401 |
token ausente, inválido ou expirado | pegar um token novo |
403 |
integração inativa, segredo trocado ou pedido de outra loja | token novo; se persistir, falar com o lojista |
404 |
pedido inexistente nessa loja | conferir o orderId |
409 |
transição realmente inválida | ler o message (não acontece em repetição) |
501 |
recurso ainda não suportado | ver o que ainda não suportamos |
5xx |
falha nossa | repetir com espera crescente |
Loja (Merchant)
Em breve. Este módulo ainda não está disponível.
Ele vai permitir ao seu sistema consultar os dados da loja e manter a configuração da integração, como a URL e o segredo do webhook, pela API. Hoje o lojista faz essa configuração no painel da Central.
A documentação do módulo será publicada aqui, junto com uma nova versão da coleção do Insomnia.
Checklist de homologação
- Peço o token uma vez por hora, não a cada chamada
- Trato
403pedindo um token novo antes de desistir - Meu webhook responde
204antes de processar - Confiro o
X-App-Signaturesobre o corpo cru, com o meu segredo do webhook - Deduplico por
eventIde não assumo ordem de chegada - Depois de
CREATED, busco o pedido comGET /orders/{orderId} - Os itens do cardápio da loja têm
externalCodeque o meu sistema reconhece - Trato
202como “aceito”, não “aplicado”, e sei que202em repetição é sucesso - Só
CANCELLEDcancela.CANCELLATION_REQUEST_ACCEPTEDnão cancela - Aplico o
CANCELLEDda origem sem negociar - Reconheço os eventos informativos sem mexer no pedido
- Se uso polling: reconheço todos os eventos, inclusive os tipos que ignoro
- Se uso polling: reconcilio pelo pedido quando fico mais de 8 horas fora do ar
O que ainda não suportamos
| Situação | |
|---|---|
| Cancelamento de item | POST …/items/{itemId}/cancel responde 501; o evento ITEM_CANCELLED é reconhecido e não altera o pedido |
| Módulo Loja (Merchant) | em breve |
GET /orders/{id}/tracking, /details, /validateCode |
não publicados |
Coleção do Insomnia
A coleção traz todas as chamadas desta página, na ordem de uso. Ela está no quadro Coleção do Insomnia, no topo da página no celular e ao lado do texto no computador.
- No Insomnia, Import → o arquivo
.jsonbaixado. - No ambiente Homologação, preencha
client_ideclient_secretcom as credenciais da loja. - Rode 1 · Token primeiro. As outras chamadas usam o token dele automaticamente.
- Preencha
order_idcom oorderIdde um pedido recebido.
A coleção é versionada: cada versão fica disponível para sempre no mesmo endereço, e as anteriores aparecem no quadro de download.
Referência do protocolo: https://docs.opendelivery.com.br/protocol/orders/