# Orbis API: integração de sites

Guia para ligar o site da Brasille, ou de outro restaurante, ao Orbis. **API v1 publicada em produção em 6 de novembro de 2026. Documentação revista em 9 de novembro de 2026.**

- Documentação pública: [docs.orbisgroup.pt](https://docs.orbisgroup.pt/).
- Contrato OpenAPI 3.1 para Postman e outras ferramentas: [openapi.json](https://docs.orbisgroup.pt/openapi.json).
- Base da API: `https://api.orbisgroup.pt/integrations/v1`.
- Os exemplos usam IDs e valores fictícios. Obter sempre os IDs e preços atuais no catálogo da unidade; estes exemplos não são os preços ou produtos reais da Brasille.

## Começar pela Brasille

1. O proprietário ou administrador entra na conta real da Brasille no Orbis e abre **Definições > API e site > Nova credencial**.
2. Seleciona a unidade da Brasille, um nome para a integração e apenas as permissões necessárias. A validade pode ser de 30, 90 ou 365 dias.
3. Guarda o segredo apresentado uma única vez numa variável privada do servidor do site, por exemplo `ORBIS_API_KEY`.
4. O servidor do site consulta `/catalog` para apresentar categorias, produtos, tamanhos, fotos, alergénios e regras de meio a meio.
5. O servidor envia pedidos de levantamento para `/orders` e pedidos de reserva para `/reservations`; guarda os IDs devolvidos para consultar os estados.

O navegador do visitante comunica com o servidor do site. É esse servidor que chama o Orbis. **A chave nunca deve entrar em JavaScript público, variáveis NEXT_PUBLIC_/VITE_, URLs, repositórios, capturas, analytics ou logs.** Não é necessário configurar CORS para esta ligação entre servidores.

Este guia documenta a integração externa. Não usar os endereços internos `/products/save`, `/orders/save` ou a sessão de um utilizador para integrar o site.

## Autenticação e permissões

Todas as operações exigem HTTPS e o cabeçalho:

```http
Authorization: Bearer <CHAVE_PRIVADA>
Accept: application/json
```

Nos pedidos POST, acrescentar `Content-Type: application/json`. O segredo real começa por `orbis_sk_`; não confundir com o prefixo resumido apresentado na lista de credenciais. As chaves simuladas da Demo não funcionam na API real.

A empresa e a unidade são determinadas pela chave. **Não enviar `organization_id`, `restaurant_id`, preços, totais ou `customer_id` no pedido externo.** Os schemas recusam campos adicionais.

| Permissão | Autoriza |
| --- | --- |
| `catalog:read` | Consultar o catálogo da unidade |
| `orders:write` | Criar pedidos na unidade |
| `orders:read` | Consultar pedidos originados pela mesma chave |
| `reservations:read` | Consultar disponibilidade e reservas originadas pela mesma chave |
| `reservations:write` | Criar pedidos de reserva na unidade |

Uma chave expirada ou revogada deixa de funcionar. Remover ou despromover o proprietário/administrador que a criou também pode retirar o acesso. A criação de pedidos e reservas exige uma conta com acesso operacional válido.

**Rotação:** os pedidos e reservas pertencem à credencial que os criou. Uma nova chave não consegue consultar os registos de uma chave anterior, mesmo sendo da mesma unidade. Planear a substituição antes de expirar, mantendo a chave antiga válida até concluir os acompanhamentos em curso; depois revogá-la. Não há migração automática desses acessos.

## Endereços disponíveis

| Método | Caminho relativo à base | Permissão | Sucesso |
| --- | --- | --- | --- |
| GET | `/catalog` | `catalog:read` | 200 |
| POST | `/orders` | `orders:write` | 201 |
| GET | `/orders/{id}` | `orders:read` | 200 |
| GET | `/availability?dia=2026-12-01&pessoas=2` | `reservations:read` | 200 |
| POST | `/reservations` | `reservations:write` | 201 |
| GET | `/reservations/{id}` | `reservations:read` | 200 |

As respostas são objetos JSON diretamente, sem uma camada `data`. Os nomes em português no catálogo e nas reservas fazem parte do contrato e devem ser preservados. A referência OpenAPI descreve todos os campos e tipos aceites.

## Catálogo e imagens

### GET /catalog

```bash
curl --fail-with-body 'https://api.orbisgroup.pt/integrations/v1/catalog' \
  -H "Authorization: Bearer ${ORBIS_API_KEY}" \
  -H 'Accept: application/json'
```

Resposta ilustrativa, com uma categoria e um produto:

```json
{
  "restaurant": {
    "id": "11111111-1111-4111-8111-111111111111",
    "name": "Restaurante de exemplo",
    "slug": "restaurante-exemplo",
    "address": null,
    "postal_code": null,
    "city": null,
    "phone": null,
    "timezone": "Europe/Lisbon",
    "opening_hours": null,
    "currency": "EUR"
  },
  "categorias": [
    {
      "categoria": "Pizzas",
      "ordem": 0,
      "itens": [
        {
          "id": "22222222-2222-4222-8222-222222222222",
          "nome": "Pizza A",
          "descricao": "Descrição de exemplo",
          "preco": 9.9,
          "alergenios": ["Glúten", "Leite"],
          "disponivel": true,
          "fotos": [],
          "variantes": [
            {
              "id": "33333333-3333-4333-8333-333333333333",
              "nome": "25 cm",
              "preco": 9.9,
              "disponivel": true,
              "alergenios": ["Glúten", "Leite"],
              "chave_tamanho": "25cm",
              "preco_meio_a_meio": null
            }
          ],
          "meio_a_meio": { "grupo": "pizzas", "regra_preco": "highest" }
        }
      ]
    }
  ]
}
```

`categorias` e `itens` podem estar vazios. O produto ou a variação indisponível não deve ser apresentado como comprável. `variantes: []` significa que o produto usa o preço base; quando existirem variações, selecionar uma opção válida e enviar o seu `variant_id`.

`fotos` contém as referências guardadas no Orbis, pela ordem de capa e ordenação. Pode estar vazio; não existe um mínimo de fotografias. Não assumir que todas as referências são URLs absolutas: uma referência relativa de armazenamento precisa de uma origem de publicação conhecida. Esta API não fornece URLs assinadas nem concede acesso ao armazenamento privado. Confirmar o formato das referências do catálogo da Brasille antes de ligar a galeria.

A moeda é `restaurant.currency`. `preco` é um valor absoluto na unidade monetária principal, por exemplo `9.9` corresponde a 9,90 EUR quando a moeda é EUR. Não é um valor em cêntimos nem um acréscimo ao preço base. O catálogo não fornece uma decomposição fiscal ou um campo de taxa de IVA: não inventar essa informação.

Usar estes preços de integração como referência. Os overrides do menu público antigo não são usados pelo motor que cria estes pedidos. Receitas, quantidades de insumos, stock, custos, margens e dados de outras empresas não são devolvidos.

## Tamanhos e pizza meio a meio

Cada tamanho tem o seu `id`, preço, disponibilidade e alergénios. A ficha técnica por tamanho fica no Orbis; o site envia apenas os identificadores da escolha.

O restaurante define a regra de preço de cada grupo:

| `regra_preco` | Cálculo |
| --- | --- |
| `highest` | Maior preço dos dois sabores no tamanho escolhido |
| `average` | Média dos dois preços, arredondada aos cêntimos |
| `fixed` | `preco_meio_a_meio` configurado para o tamanho |

Só combinar dois produtos distintos que permitam meio a meio, com o mesmo `grupo`, `regra_preco` e `chave_tamanho`. Para `fixed`, ambos têm de ter o mesmo preço fixo configurado. `meio_a_meio: null` desativa combinações nesse produto.

Uma pizza meio a meio é **uma linha, uma quantidade e exatamente duas metades**. O primeiro par em `halves` tem de coincidir com `product_id` e `variant_id` da linha. O backend valida a combinação, calcula o preço e guarda o histórico. O consumo de insumos usa metade da ficha de cada sabor, multiplicada pela quantidade, nas transições operacionais existentes. O site não envia nem calcula baixas de stock.

## Criar e acompanhar pedidos

### POST /orders

Corpo para um pedido de levantamento com um tamanho:

```json
{
  "request_id": "44444444-4444-4444-8444-444444444444",
  "type": "takeaway",
  "guest_name": "Cliente de exemplo",
  "guest_phone": "910000000",
  "note": "Nota opcional do pedido",
  "items": [
    {
      "product_id": "22222222-2222-4222-8222-222222222222",

      "variant_id": "33333333-3333-4333-8333-333333333333",
      "quantity": 1,
      "note": "Nota opcional do item"
    }
  ]
}
```

Exemplo de uma linha meio a meio, a colocar em `items`:

```json
{
  "product_id": "22222222-2222-4222-8222-222222222222",
  "variant_id": "33333333-3333-4333-8333-333333333333",
  "quantity": 1,
  "halves": [
    {
      "product_id": "22222222-2222-4222-8222-222222222222",
      "variant_id": "33333333-3333-4333-8333-333333333333"
    },
    {
      "product_id": "55555555-5555-4555-8555-555555555555",
      "variant_id": "66666666-6666-4666-8666-666666666666"
    }
  ]
}
```

| Campo | Regra |
| --- | --- |
| `request_id` | UUID novo por intenção de envio; reutilizar apenas em repetição do mesmo envio |
| `type` | `takeaway` ou `dine_in`; `delivery` não é aceite nesta versão |
| `table_id` | UUID de uma mesa válida da unidade, obrigatório em `dine_in`; não existe aqui um endpoint para listar mesas |
| `guest_name`, `guest_phone`, `note` | Opcionais; até 160, 40 e 500 caracteres, respetivamente |
| `items` | Entre 1 e 200 linhas |
| `items[].quantity` | Número maior que zero, até 999 e com no máximo duas casas decimais |
| `items[].note` | Opcional, até 300 caracteres |
| `items[].variant_id` | Obrigatório para produtos que tenham variações; omitir nos produtos sem opções |

Resposta 201 ilustrativa para a linha normal:

```json
{
  "id": "77777777-7777-4777-8777-777777777777",
  "order_number": "1042",
  "status": "placed",
  "type": "takeaway",
  "subtotal": 9.9,
  "total": 9.9,
  "created_at": "2026-12-01T19:00:00Z",
  "items": [
    {
      "id": "88888888-8888-4888-8888-888888888888",
      "product_id": "22222222-2222-4222-8222-222222222222",
      "variant_id": "33333333-3333-4333-8333-333333333333",
      "product_name": "Pizza A",
      "variant_name": "25 cm",
      "composition": null,
      "quantity": 1,
      "unit_price": 9.9,
      "total": 9.9,
      "note": "Nota opcional do item",
      "status": "pending"
    }
  ]
}
```

Numa linha meio a meio, `composition` é uma lista de duas partes com `product_id`, `variant_id`, `product_name`, `variant_name`, `size_key`, `ratio` e `unit_price`. Cada `ratio` é 0.5; `composition[].unit_price` é o preço do sabor, não o total da pizza. O valor cobrado pela linha é `items[].unit_price` e o total da linha é `items[].total`.

### GET /orders/{id}

Consultar o ID devolvido na criação, por exemplo `/orders/77777777-7777-4777-8777-777777777777`. A resposta 200 tem a mesma estrutura da criação, com os estados atuais. Só é visível um pedido criado por esta chave.

Os estados operacionais podem incluir `placed`, `accepted`, `in_preparation`, `ready`, `served`, `out_for_delivery`, `delivered` e `cancelled`; itens podem ter `pending`, `in_preparation`, `ready`, `served` e `cancelled`. O contrato devolve strings: tratar um valor novo sem bloquear a página. `placed` significa pedido registado, **não pagamento recebido**.

Usar a resposta do servidor para mostrar o preço final e guardar o ID no servidor do site. Não mostrar sucesso só porque o visitante clicou em finalizar. Nesta versão não há webhook externo de estados: o acompanhamento faz-se por consulta, com frequência moderada e respeitando os limites.

## Disponibilidade e pedidos de reserva

### GET /availability

Enviar `dia` como data do calendário da unidade (`AAAA-MM-DD`) e `pessoas` como inteiro de 1 a 50. Usar a timezone devolvida no catálogo para apresentar o calendário.

```bash
curl --fail-with-body \
  'https://api.orbisgroup.pt/integrations/v1/availability?dia=2026-12-01&pessoas=2' \
  -H "Authorization: Bearer ${ORBIS_API_KEY}"
```

Resposta 200 ilustrativa:

```json
{
  "dia": "2026-12-01",
  "pessoas": 2,
  "horas": [
    { "slot_at": "2026-12-01T20:00:00Z", "lugares_livres": 12, "cabe": true }
  ],
  "policy": {
    "deposit_mode": "none",
    "deposit_amount": 0,
    "payment_deadline_minutes": 30,
    "cancellation_hours": 24,
    "terms": "Condições de exemplo do restaurante."
  }
}
```

`horas` pode estar vazio e `policy` pode ser `null`. Oferecer apenas os horários com `cabe: true`. A consulta não bloqueia lugares: a criação volta a verificar capacidade e pode devolver 409 se o horário deixar de estar disponível.

`slot_at` é um instante absoluto. Enviá-lo como `inicio` sem reinterpretar o fuso horário. Apresentar o horário em `restaurant.timezone`; não acrescentar nem retirar uma hora manualmente no horário de verão.

`deposit_mode` pode ser `none`, `per_booking` ou `per_person`. Apresentar `terms` e as condições ao visitante antes de recolher a aceitação. `policy: null` não obriga a inventar termos.

### POST /reservations

```json
{
  "request_id": "99999999-9999-4999-8999-999999999999",
  "inicio": "2026-12-01T20:00:00Z",
  "pessoas": 2,
  "nome": "Cliente de exemplo",
  "telefone": "910000000",
  "email": "cliente@example.com",
  "nota": "Nota opcional",
  "aceita_politica": true
}
```

| Campo | Regra |
| --- | --- |
| `request_id` | UUID, com as mesmas regras de repetição dos pedidos |
| `inicio` | ISO 8601 com `Z` ou offset; usar o `slot_at` escolhido |
| `pessoas` | Inteiro de 1 a 50; sujeito à configuração e capacidade do restaurante |
| `nome` | Obrigatório, de 2 a 120 caracteres |
| `telefone` | Obrigatório, de 6 a 30 caracteres |
| `email` | Opcional, endereço válido até 160 caracteres |
| `nota` | Opcional, até 500 caracteres |
| `aceita_politica` | `true` quando existe uma política de reserva; enviar após aceitação do visitante |

Resposta 201 ilustrativa para uma reserva que aguarda condições:

```json
{
  "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
  "starts_at": "2026-12-01T20:00:00Z",
  "party_size": 2,
  "duration_minutes": 90,
  "status": "pending",
  "deposit_due": 10,
  "deposit_status": "pending",
  "payment_expires_at": "2026-12-01T19:30:00Z"
}
```

Estes valores exemplificam um caso com sinal e não correspondem necessariamente à política do exemplo de disponibilidade anterior. O backend respeita a política real, aprovação, antecedência e capacidade. **Só `status: confirmed` deve aparecer como reserva confirmada.** `pending` é um pedido de reserva pendente. Não apresentar um pagamento como concluído a partir de `deposit_due` ou de um HTTP 201.

### GET /reservations/{id}

Consultar o ID guardado, por exemplo `/reservations/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa`. A resposta 200 tem os mesmos campos da criação e os estados atuais. Só é visível uma reserva originada pela mesma chave.

`payment_expires_at` pode ser `null`. A API não devolve códigos de gestão privados nem os contactos de outros clientes. A equipa gere confirmação e alterações no módulo Reservas do Orbis. A resposta não inclui uma ligação de pagamento de sinal e este contrato não inicia pagamentos automaticamente.

## Repetição segura e limites

Gerar um UUID para `request_id` no servidor do site ao iniciar cada envio. Guardar esse identificador e o corpo enviado. Se houver timeout ou perda da resposta, repetir **com a mesma chave, o mesmo request_id e os mesmos dados**. Os POST devolvem 201 também na repetição bem-sucedida.

A mesma chave, operação e `request_id` não criam dois registos. Alterar o corpo com o mesmo identificador devolve 409. Um novo identificador representa uma nova intenção e pode criar outro pedido ou reserva. Os identificadores não autorizam recuperar registos de outras credenciais.

Os limites atuais são de **60 consultas por minuto por rota e IP** e **10 criações por minuto por rota e IP**, sujeitos também aos limites gerais do serviço. Um servidor de site pode partilhar o mesmo IP entre visitantes. Respeitar `Retry-After` quando devolvido e aplicar espera progressiva nos erros temporários; não repetir erros 400/401/403 em ciclo.

As respostas da integração usam `Cache-Control: no-store`. Não guardar respostas autenticadas em caches partilhadas/CDN. Guardar apenas os dados necessários ao acompanhamento no servidor do site, com controlo de acesso por visitante; conhecer um ID não deve permitir a um visitante consultar a encomenda de outro através do seu site.

## Erros e tratamento no site

Erros de acesso ou negócio têm normalmente esta forma:

```json
{
  "error": "integration_request_conflict",
  "message": "integration_request_conflict"
}
```

Erros de formato devolvem HTTP 400 com `error: validation_error` e uma lista `issues` com o campo e a causa. A estrutura dessa lista segue o validador do backend; não depender de uma mensagem traduzida fixa.

| HTTP | Código habitual | Ação recomendada |
| --- | --- | --- |
| 400 | `validation_error` ou `integration_request_invalid` | Rever campos, opções, disponibilidade do produto e aceitação de política |
| 401 | `unauthorized` | Rever segredo completo, validade, revogação e ambiente real/Demo |
| 403 | `forbidden` | Rever permissões, unidade, acesso do criador e estado operacional da conta |
| 404 | `not_found` | Rever ID e credencial de origem; não revelar dados de outra chave |
| 409 | `integration_request_conflict` | Rever reutilização de request_id; numa reserva, voltar a consultar disponibilidade |
| 429 | `too_many_requests` | Respeitar Retry-After quando presente e reduzir frequência |
| 500 | `internal_server_error` | Não confirmar sucesso; guardar request_id e repetir apenas o mesmo envio quando apropriado |

Um 400 pode representar uma escolha que já não é válida. Voltar a consultar o catálogo antes de pedir ao cliente para selecionar outra opção. Não substituir silenciosamente um sabor ou tamanho. Um 409 de reserva pode indicar indisponibilidade, não apenas um envio alterado.

## Exemplo de ligação no servidor

Exemplo para Node.js com `fetch`, sem bibliotecas adicionais. Colocar exclusivamente no servidor do site. Não executar exemplos POST em produção como simples teste: criam pedidos ou reservas reais.

```javascript
const base = 'https://api.orbisgroup.pt/integrations/v1'
const key = process.env.ORBIS_API_KEY
if (!key) throw new Error('Falta configurar ORBIS_API_KEY no servidor')

async function orbis(path, payload) {
  const response = await fetch(`${base}${path}`, {
    method: payload === undefined ? 'GET' : 'POST',
    headers: {
      Authorization: `Bearer ${key}`,
      Accept: 'application/json',
      ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
    },
    ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
    signal: AbortSignal.timeout(15000),
    cache: 'no-store',
  })
  const body = await response.json()
  if (!response.ok) {
    // Tratar o erro no servidor; não registar a chave nem contactos do cliente.
    throw new Error(`Orbis ${response.status}: ${body.error ?? 'erro'}`)
  }
  return body
}

const catalog = await orbis('/catalog')
// Numa intenção de compra, guardar um crypto.randomUUID() e o corpo antes
// de chamar orbis('/orders', payload). Repetir o mesmo envio após timeout.
```

Proteger os endereços do próprio site contra abuso, validar as escolhas do visitante e associar os IDs Orbis à sessão desse visitante no servidor. A chave dá acesso ao site como integração; não autentica cada visitante final.

## O que esta versão não disponibiliza

- Pagamento online do pedido, checkout Stripe do restaurante ou ligação para pagar o sinal de uma reserva. O Stripe das licenças Orbis é outro fluxo.
- Pedidos de entrega (`delivery`), gestão de moradas ou cálculo de taxas de entrega nesta API externa.
- Cancelar/editar pedidos e reservas, alterar o catálogo ou listar todas as mesas, todos os pedidos ou todas as reservas da empresa.
- Webhooks externos de alteração de estado.
- Receitas, fichas técnicas, quantidades de insumos, custos, margens ou acesso à base de dados.

Para a Brasille, já é possível integrar catálogo com tamanhos e meio a meio, pedidos de levantamento e pedidos de reserva. Se o site precisar de checkout com pagamento, entrega ou outras operações acima, é necessário acrescentar e publicar esse contrato antes de mostrar o fluxo como disponível.

## Checklist de entrega da Brasille

- Criar a credencial na conta real e unidade correta, com permissões mínimas e prazo de rotação definido.
- Retirar o catálogo fixo do site como fonte de verdade; consumir os dados da unidade no Orbis.
- Confirmar fotos, tamanhos, disponibilidade e política de meio a meio efetivamente configurados pelo restaurante.
- Guardar o segredo só no servidor; nunca o expor no browser ou na documentação.
- Mostrar os totais e estados devolvidos pelo Orbis; conservar IDs e request_id por envio.
- Apresentar condições de reserva e distinguir pendente de confirmada.
- Validar manualmente o fluxo com autorização do responsável, sem cobrar ou enviar notificações a clientes por engano.

A implementação de referência está em `infra/api/src/modules/integrations/`. O contrato OpenAPI é gerado a partir dos schemas publicados do backend. O [contrato interno de variações](https://github.com/OrbisGroupLda/Orbis/blob/codex/operacao-real-paga/docs/PRODUCT_VARIANTS_API.md) destina-se à gestão autenticada e não substitui esta API de integração.
