openapi: 3.1.0 info: title: API Broski version: "1.0" description: | API do gateway Broski para pagamentos por **MB WAY** e **Multibanco** (EUR, Portugal). Base URL: `https://api.broski.pt`. Autenticação por chave secreta (`Authorization: Bearer sk_live_…`), gerada no painel em Configurações → API. Valores sempre em **cêntimos** (inteiros). Guias completos em https://docs.broski.pt. A API é exclusivamente servidor→servidor (CORS bloqueia browser); fluxo 100% server-side — sem SDK, client_secret, iframe ou redirect. servers: - url: https://api.broski.pt security: - chaveSecreta: [] tags: - name: Pedidos - name: Estornos paths: /v1/orders: post: tags: [Pedidos] summary: Criar pedido operationId: criarPedido parameters: - name: Idempotency-Key in: header required: true schema: { type: string, minLength: 1, maxLength: 128 } description: Obrigatório. Reenvios com a MESMA chave e o MESMO corpo devolvem a resposta original (header `Idempotent-Replay: true`). Mesma chave com corpo diferente → 409. Retenção garantida ≥ 24 h. requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/NovoPedido" } examples: mbway: value: { amount: 1990, method: mbway, phone: "+351912345678", external_reference: "pedido-1042", description: "Curso de tráfego pago", customer: { name: "Ana Cliente", email: "ana@exemplo.pt" } } multibanco: value: { amount: 4990, method: multibanco, external_reference: "pedido-1043", customer: { email: "rui@exemplo.pt" } } responses: "201": description: Pedido criado. MB WAY nasce `pending` (cliente confirma no telemóvel); Multibanco nasce `awaiting_payment` com o voucher (exiba entidade, referência e valor ao cliente). content: application/json: schema: { $ref: "#/components/schemas/Pedido" } "400": { $ref: "#/components/responses/Erro400" } "401": { $ref: "#/components/responses/Erro401" } "402": description: O pagamento não pôde ser iniciado (`payment_failed`). O pedido é desfeito — a mesma `external_reference` pode ser reutilizada. content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } "409": description: "`external_reference_taken` (referência já usada) ou `idempotency_key_reused` (mesma Idempotency-Key com corpo diferente)." content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } "413": { $ref: "#/components/responses/Erro413" } "429": { $ref: "#/components/responses/Erro429" } get: tags: [Pedidos] summary: Listar pedidos operationId: listarPedidos parameters: - { name: status, in: query, schema: { type: string, enum: [pending, awaiting_payment, paid, expired, failed, refunded] }, description: "Valor fora do enum → 400." } - { name: method, in: query, schema: { type: string, enum: [mbway, multibanco] } } - { name: starting_after, in: query, schema: { type: string }, description: "Cursor: o `id` do último pedido da página anterior." } responses: "200": description: Página de até 25 pedidos, do mais novo ao mais antigo. content: application/json: schema: type: object properties: data: type: array items: { $ref: "#/components/schemas/PedidoResumo" } has_more: { type: boolean } "400": { $ref: "#/components/responses/Erro400" } "401": { $ref: "#/components/responses/Erro401" } "429": { $ref: "#/components/responses/Erro429" } /v1/orders/{id}: get: tags: [Pedidos] summary: Consultar pedido operationId: consultarPedido parameters: - { name: id, in: path, required: true, schema: { type: string }, example: ord_9f2c1ab34d } responses: "200": description: O pedido. content: { application/json: { schema: { $ref: "#/components/schemas/Pedido" } } } "401": { $ref: "#/components/responses/Erro401" } "404": { $ref: "#/components/responses/Erro404" } /v1/orders/{id}/refunds: post: tags: [Estornos] summary: Estornar pedido operationId: estornarPedido parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object properties: amount: { type: integer, description: "Cêntimos. Omitido ou 0 = estorno TOTAL do valor restante. Parciais são repetíveis até o total." } # corpo é OBRIGATÓRIO: envie {} para estorno total reason: { type: string, description: "Motivo (opcional, uso interno seu)." } responses: "201": description: Estorno executado. content: application/json: schema: type: object properties: id: { type: string, example: ref_5ba01cd922 } amount: { type: integer } status: { type: string, enum: [succeeded] } "400": description: "`refund_exceeds_amount` (maior que o restante) ou `validation_failed`." content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } "401": { $ref: "#/components/responses/Erro401" } "404": { $ref: "#/components/responses/Erro404" } "409": description: "`order_not_paid` — só pedidos pagos podem ser estornados." content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } webhooks: order.paid: post: summary: Pagamento confirmado (evento essencial — Multibanco confirma dias depois) requestBody: content: { application/json: { schema: { $ref: "#/components/schemas/Evento" } } } responses: { "200": { description: "Responda 2xx em até 10 s. Sem 2xx → retries por ~24 h." } } order.awaiting_payment: post: { summary: Voucher Multibanco emitido, responses: { "200": { description: ok } } } order.failed: post: { summary: Pagamento falhou, responses: { "200": { description: ok } } } order.expired: post: { summary: Pedido/voucher expirou, responses: { "200": { description: ok } } } order.refunded: post: { summary: Estorno total concluído, responses: { "200": { description: ok } } } components: securitySchemes: chaveSecreta: type: http scheme: bearer description: "Chave secreta do painel (`sk_live_…`). Só no SEU servidor — nunca em browser, app ou repositório." schemas: NovoPedido: type: object required: [amount, method, external_reference] properties: amount: { type: integer, description: "Cêntimos. MB WAY: 50–500000 (€0,50–€5.000). Multibanco: 1–100000000." } currency: { type: string, enum: [EUR], description: "Opcional; apenas EUR." } method: { type: string, enum: [mbway, multibanco] } phone: { type: string, description: "Obrigatório para MB WAY. Telemóvel PT: +3519XXXXXXXX." } external_reference: { type: string, pattern: "^[A-Za-z0-9_\\-\\.]{1,128}$", description: "Seu identificador único do pedido." } description: { type: string, maxLength: 140, description: "Nome do produto/serviço (aparece no seu painel; não é devolvido pela API)." } customer: type: object description: Dados do cliente (opcionais; armazenados cifrados, nunca devolvidos pela API). properties: name: { type: string } email: { type: string } phone: { type: string } nif: { type: string, description: "NIF PT de 9 dígitos (validado se presente)." } Pedido: type: object properties: id: { type: string, example: ord_9f2c1ab34d } status: { type: string, enum: [pending, awaiting_payment, paid, expired, failed, refunded] } amount: { type: integer } currency: { type: string, enum: [EUR] } method: { type: string, enum: [mbway, multibanco] } external_reference: { type: string } livemode: { type: boolean } created_at: { type: string, format: date-time } paid_at: { type: string, format: date-time, description: "Presente só quando pago." } multibanco: type: object description: Voucher (apenas method=multibanco). EXIBA entidade, referência e valor ao cliente. properties: entity: { type: string, example: "11249" } reference: { type: string, example: "123 456 789" } amount: { type: integer } expires_at: { type: string, format: date-time } PedidoResumo: type: object description: Item da listagem (shape reduzido — sem `currency` nem `multibanco`). properties: id: { type: string } status: { type: string } amount: { type: integer } method: { type: string } external_reference: { type: string } livemode: { type: boolean } created_at: { type: string, format: date-time } paid_at: { type: string, format: date-time } Evento: type: object description: "Envelope dos webhooks. Verifique SEMPRE o header `Broski-Signature` (t=,v1=; v1 = HMAC-SHA256(secret, t + \".\" + corpo_cru)). Entrega at-least-once, sem ordem garantida — deduplique por `id`." properties: id: { type: string, example: evt_ab12cd34ef } type: { type: string, enum: [order.awaiting_payment, order.paid, order.failed, order.expired, order.refunded] } created: { type: integer, description: Unix epoch. } data: type: object properties: object: { $ref: "#/components/schemas/Pedido" } Erro: type: object properties: error: type: object properties: type: { type: string, enum: [invalid_request, authentication_error, payment_error, conflict, not_found, rate_limited, internal_error] } code: { type: string } message: { type: string, description: Mensagem em PT-BR, segura para exibir em logs. } param: { type: string, description: Campo relacionado (quando aplicável). } responses: Erro400: { description: "Requisição inválida (`validation_failed`, `invalid_json`, `currency_not_supported`, `amount_below_fee`).", content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } } Erro401: { description: "Credenciais inválidas (`unauthorized`).", content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } } Erro404: { description: "Não encontrado (`resource_not_found`).", content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } } Erro413: { description: "Corpo acima de 64 KB (`body_too_large`).", content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } } Erro429: { description: "Acima de 120 req/min por chave (`rate_limited`).", content: { application/json: { schema: { $ref: "#/components/schemas/Erro" } } } }