Central de Pedidos

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 clientSecret aparece 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.
  • 5xx ou 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 de DELIVERED.
  • 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 403 pedindo um token novo antes de desistir
  • Meu webhook responde 204 antes de processar
  • Confiro o X-App-Signature sobre o corpo cru, com o meu segredo do webhook
  • Deduplico por eventId e não assumo ordem de chegada
  • Depois de CREATED, busco o pedido com GET /orders/{orderId}
  • Os itens do cardápio da loja têm externalCode que o meu sistema reconhece
  • Trato 202 como “aceito”, não “aplicado”, e sei que 202 em repetição é sucesso
  • Só CANCELLED cancela. CANCELLATION_REQUEST_ACCEPTED não cancela
  • Aplico o CANCELLED da 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.

  1. No Insomnia, Import → o arquivo .json baixado.
  2. No ambiente Homologação, preencha client_id e client_secret com as credenciais da loja.
  3. Rode 1 · Token primeiro. As outras chamadas usam o token dele automaticamente.
  4. Preencha order_id com o orderId de 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/

Coleção do Insomnia

v1.0.0

Baixar .json

api-homolog.centraldepedidos.delivery

Primeira versão: token, pedido, mudanças de status, cancelamento e polling de eventos.