{
  "openapi": "3.1.0",
  "info": {
    "title": "CRPRO Hub API",
    "version": "1.0.0",
    "description": "API REST multitenant exclusiva para WhatsApp Business Platform. Respostas usam {data} ou {error}."
  },
  "servers": [
    {
      "url": "https://crprohub.com"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key hub_pk_... ou token de canal hub_ch_..."
      },
      "channelToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "Token hub_ch_... do canal exato. Obrigatorio na fachada /meta e incapaz de acessar outro numero."
      }
    },
    "parameters": {
      "ChannelId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": true,
        "schema": {
          "type": "string",
          "minLength": 8,
          "maxLength": 200
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message",
              "request_id"
            ],
            "properties": {
              "code": {
                "type": "string"
              },
              "message": {
                "type": "string"
              },
              "request_id": {
                "type": "string",
                "format": "uuid"
              },
              "details": {
                "type": [
                  "object",
                  "null"
                ]
              }
            }
          }
        }
      },
      "Channel": {
        "type": "object",
        "required": [
          "id",
          "name",
          "type",
          "status",
          "coexistence"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "type": {
            "const": "whatsapp"
          },
          "status": {
            "type": "string"
          },
          "display_phone_number": {
            "type": [
              "string",
              "null"
            ]
          },
          "verified_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "quality_rating": {
            "type": [
              "string",
              "null"
            ]
          },
          "coexistence": {
            "type": "boolean"
          },
          "subscribed_ok": {
            "type": "boolean"
          }
        }
      },
      "CreateChannel": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "type": {
            "const": "whatsapp"
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200
          },
          "webhook_url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048
          },
          "webhook_secret": {
            "type": "string",
            "minLength": 16,
            "maxLength": 512,
            "writeOnly": true
          },
          "webhook_events": {
            "type": "array",
            "items": {
              "enum": [
                "channel_connected",
                "channel_auto_imported",
                "channel_disconnected",
                "event_received"
              ]
            }
          }
        },
        "additionalProperties": false
      },
      "CompatibilityChannelCreated": {
        "type": "object",
        "required": [
          "channel",
          "webhook_id",
          "connect_url"
        ],
        "properties": {
          "channel": {
            "type": "object",
            "required": [
              "id",
              "token",
              "type",
              "status"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "token": {
                "type": "string",
                "pattern": "^hub_ch_",
                "writeOnly": true,
                "description": "Credencial duradoura do canal; nunca use como link publico."
              },
              "type": {
                "const": "whatsapp"
              },
              "status": {
                "type": "string"
              },
              "external_id": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "webhook_id": {
            "type": "string",
            "format": "uuid"
          },
          "connect_url": {
            "type": "string",
            "format": "uri",
            "description": "Link publico de onboarding, separado do token do canal, expira e funciona uma unica vez."
          }
        }
      },
      "Message": {
        "type": "object",
        "required": [
          "to",
          "type"
        ],
        "properties": {
          "to": {
            "type": "string",
            "pattern": "^[1-9][0-9]{7,14}$"
          },
          "type": {
            "enum": [
              "text",
              "image",
              "audio",
              "video",
              "document",
              "sticker",
              "location",
              "contacts",
              "reaction",
              "interactive",
              "template"
            ]
          },
          "text": {
            "type": "object"
          },
          "image": {
            "type": "object"
          },
          "audio": {
            "type": "object"
          },
          "video": {
            "type": "object"
          },
          "document": {
            "type": "object"
          },
          "sticker": {
            "type": "object"
          },
          "location": {
            "type": "object"
          },
          "contacts": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "reaction": {
            "type": "object"
          },
          "interactive": {
            "type": "object"
          },
          "template": {
            "type": "object"
          }
        },
        "additionalProperties": false
      },
      "Webhook": {
        "type": "object",
        "required": [
          "name",
          "url"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048
          },
          "event_types": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "payload_mode": {
            "enum": [
              "normalized",
              "meta_raw"
            ],
            "default": "normalized"
          },
          "delivery_format": {
            "description": "Como a entrega e assinada. 'native' assina timestamp.delivery_id.corpo e envia X-Hub-Signature-Version: v2. 'evohub' assina somente o corpo cru e envia X-Hub-Signature-Version: evohub-v1, para consumidores vindos do EvoHub que nao podem mudar o handler. Devolvido no GET para auditoria; nao muda depois da criacao.",
            "enum": [
              "native",
              "evohub"
            ],
            "default": "native"
          },
          "all_channels": {
            "type": "boolean",
            "default": true
          },
          "channel_ids": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          }
        },
        "additionalProperties": false
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Requisicao invalida",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Credencial ausente ou invalida",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Limite excedido",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/v1/api-keys": {
      "get": {
        "summary": "Lista chaves sem revelar segredos",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "post": {
        "summary": "Gera API key escopada; o segredo aparece uma vez",
        "responses": {
          "201": {
            "description": "Criada"
          }
        }
      }
    },
    "/api/v1/api-keys/{id}/revoke": {
      "post": {
        "summary": "Revoga API key",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "200": {
            "description": "Revogada"
          }
        }
      }
    },
    "/api/v1/channels": {
      "get": {
        "summary": "Lista canais",
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "summary": "Cria canal dentro da quota, com modo compativel EvoHub opcional e atomico",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateChannel"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Canal e token criados; modo compativel inclui webhook e link de conexao de uso unico",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CompatibilityChannelCreated"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "description": "Quota excedida"
          }
        }
      }
    },
    "/api/v1/channels/{id}": {
      "get": {
        "summary": "Consulta canal",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          },
          "404": {
            "description": "Nao encontrado"
          }
        }
      },
      "patch": {
        "summary": "Atualiza canal",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "200": {
            "description": "Atualizado"
          }
        }
      },
      "delete": {
        "summary": "Exclui o canal e, opcionalmente, tira o numero da Meta",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          },
          {
            "name": "deregister",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": false
            },
            "description": "true para tambem tirar o numero da Cloud API da Meta antes de arquivar. Omitido, o canal e apenas arquivado no Hub."
          }
        ],
        "responses": {
          "200": {
            "description": "Canal arquivado. O campo deregistro diz o que aconteceu do lado da Meta.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "archived": {
                          "type": "boolean"
                        },
                        "changed": {
                          "type": "boolean",
                          "description": "false quando o canal ja estava arquivado."
                        },
                        "deregistro": {
                          "type": "string",
                          "enum": [
                            "feito",
                            "falhou",
                            "nao-solicitado",
                            "sem-numero",
                            "sem-credencial"
                          ],
                          "description": "feito: numero desconectado na Meta. falhou: a Meta recusou; a exclusao ocorreu, mas o numero segue registrado la. sem-credencial: a credencial da Meta deste canal nao pode ser lida; o numero segue registrado. nao-solicitado: deregister nao foi pedido. sem-numero: o canal nunca teve numero conectado."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "description": "Soft delete idempotente: o canal some de toda a API e a vaga do plano e liberada na hora. Em qualquer exclusao o Hub revoga o token do canal, invalida links de conexao pendentes, desvincula o canal dos endpoints de webhook e apaga o token da Meta guardado para ele. Com deregister=true, antes de arquivar o Hub chama o deregister do numero na Cloud API — a chamada age so naquele numero e nao mexe no app da WABA, entao os outros numeros da mesma conta continuam funcionando. Depois do deregister o numero precisa ser registrado de novo, com o PIN da verificacao em duas etapas. Se a Meta recusar, a exclusao acontece do mesmo jeito e a falha fica no log de auditoria como channel.deregister_failed."
      }
    },
    "/api/v1/channels/{id}/connect-link": {
      "post": {
        "summary": "Gera link publico de onboarding de uso unico",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "201": {
            "description": "Link criado"
          }
        }
      }
    },
    "/api/v1/channels/{id}/diagnostics": {
      "get": {
        "summary": "Valida token, numero e assinatura de webhooks",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "200": {
            "description": "Diagnostico sem exposicao de segredos"
          }
        }
      }
    },
    "/api/v1/channels/{id}/media": {
      "post": {
        "summary": "Envia midia para a Meta",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Media ID criado"
          }
        }
      }
    },
    "/api/v1/channels/{id}/messages": {
      "get": {
        "summary": "Lista logs de mensagens de um canal, com paginacao por cursor",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "direction",
            "in": "query",
            "schema": {
              "enum": [
                "inbound",
                "outbound"
              ]
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 80
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Logs do numero sem payload Meta bruto"
          },
          "404": {
            "description": "Canal nao encontrado no tenant"
          }
        }
      },
      "post": {
        "summary": "Envia mensagem WhatsApp",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Message"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Aceita pela Meta; repetir a mesma Idempotency-Key e corpo nao envia novamente"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "409": {
            "description": "OPERATION_IN_PROGRESS ou SEND_RESULT_UNKNOWN; consulte os logs e nao gere outra chave automaticamente"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/channels/{id}/regenerate-token": {
      "post": {
        "summary": "Revoga e recria o token exclusivo do canal",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "201": {
            "description": "Token retornado uma vez"
          }
        }
      }
    },
    "/api/v1/channels/{id}/templates": {
      "get": {
        "summary": "Lista templates",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "post": {
        "summary": "Cria template",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "201": {
            "description": "Criado na Meta"
          }
        }
      }
    },
    "/api/v1/subscription": {
      "get": {
        "summary": "Consulta plano, status, quotas e uso",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/subscription/addons": {
      "post": {
        "summary": "Define o total de conexoes adicionais; a Stripe cobra a proporcao",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "201": {
            "description": "Cobranca criada"
          }
        }
      }
    },
    "/api/v1/subscription/addons/preview": {
      "post": {
        "summary": "Simula o valor proporcional de alterar as conexoes adicionais",
        "responses": {
          "200": {
            "description": "Preview"
          }
        }
      }
    },
    "/api/v1/subscription/checkout": {
      "post": {
        "summary": "Abre o checkout da Stripe para o plano Starter ou Pro",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "responses": {
          "201": {
            "description": "checkout_url da sessao; a quota so libera apos o pagamento"
          }
        }
      }
    },
    "/api/v1/subscription/portal": {
      "post": {
        "summary": "Abre o portal de cobranca para trocar cartao, ver faturas e cancelar",
        "responses": {
          "200": {
            "description": "portal_url de uso unico"
          }
        }
      }
    },
    "/api/v1/usage": {
      "get": {
        "summary": "Consulta uso atual das quotas",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "summary": "Lista endpoints",
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      },
      "post": {
        "summary": "Cria endpoint HMAC dentro da quota",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Webhook"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Segredo HMAC retornado uma vez"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}/deliveries": {
      "get": {
        "summary": "Lista tentativas e estados de entrega",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}/replay/{deliveryId}": {
      "post": {
        "summary": "Cria uma nova entrega para o mesmo evento, sem alterar a tentativa original",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          },
          {
            "name": "deliveryId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Replay enfileirado com outro delivery_id",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "delivery_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "event_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "replay_of_delivery_id": {
                          "type": "string",
                          "format": "uuid"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhooks/{id}/test": {
      "post": {
        "summary": "Enfileira evento de teste",
        "parameters": [
          {
            "$ref": "#/components/parameters/ChannelId"
          }
        ],
        "responses": {
          "202": {
            "description": "Enfileirado"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/health": {
      "get": {
        "summary": "Saude do processo",
        "security": [],
        "responses": {
          "200": {
            "description": "Processo saudavel"
          }
        }
      }
    },
    "/meta/{resourcePath}": {
      "parameters": [
        {
          "name": "resourcePath",
          "in": "path",
          "required": true,
          "description": "Caminho WhatsApp da allowlist; nao e um proxy Graph generico.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "summary": "Compatibilidade EvoHub para leitura escopada ao canal",
        "description": "Aceita somente numero proprio, a WABA propria (GET /{waba_id}), templates da WABA propria, perfil e midia previamente registrada como pertencente ao canal. /me, debug_token, subscribed_apps e IDs externos sao recusados. Em /{waba_id} o fields e restrito a id, name, currency, timezone_id, account_review_status e pricing_analytics -- e onde vive o relatorio de custo por dia x categoria x pais. O modificador do campo aceita apenas start, end, granularity, dimensions e metric_types; fora disso responde 400 INVALID_FIELD_MODIFIER.",
        "security": [
          {
            "channelToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Shape compativel com a Graph API"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Recurso ausente ou pertencente a outro canal"
          }
        }
      },
      "post": {
        "summary": "Compatibilidade EvoHub para mensagens, midia, templates e perfil",
        "description": "Allowlist: POST /{phone_number_id}/messages, POST /{phone_number_id}/media, POST /{phone_number_id}/whatsapp_business_profile, POST /{waba_id}/message_templates, POST /{waba_id}/header_handle e POST /{phone_number_id}/calls. O Hub injeta o token Meta no servidor. O token Meta nunca e aceito na query, nunca volta na resposta e nunca e registrado em log. Em /calls o corpo aceito e {messaging_product: 'whatsapp', call_id, action}, com action restrito a reject ou terminate (accept e pre_accept exigem sessao SDP e nao passam pela fachada); qualquer outro campo, inclusive session, e descartado antes de chegar a Meta, e corpo invalido responde 400 INVALID_CALL. O call_id nao e conferido contra o canal -- nao ha registro local de chamadas -- entao a posse fica por conta da Meta, limitada pelo phone_number_id do caminho, que precisa ser o do proprio canal. Em /{waba_id}/header_handle (so a WABA do proprio canal, escopo templates:write) o corpo e multipart/form-data com o campo file; o tipo e decidido pelos bytes, nao pelo content-type declarado: image/jpeg e image/png ate 5 MB, video/mp4 ate 16 MB, application/pdf ate 100 MB. O Hub faz a Resumable Upload da Meta com o token e o app do proprio canal e responde 200 {h}, o handle para example.header_handle de template com header de midia -- nunca o id da sessao de upload. Tipo fora da lista responde 400 INVALID_MEDIA; acima do teto do tipo, 413 MEDIA_TOO_LARGE. /{app_id}/uploads e /upload:{sessao} continuam fora da allowlist.", "x-scopes": ["messages:send", "media:write", "profile:write", "templates:write", "calls:write"],
        "security": [
          {
            "channelToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Shape compativel com a Graph API"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "Falha sanitizada da Meta"
          }
        }
      },
      "delete": {
        "summary": "Exclui template na WABA do proprio canal",
    "description": "Sem hsm_id a Meta apaga TODAS as versoes de idioma daquele nome. Envie hsm_id -- so digitos, o id que veio na listagem -- para limitar a exclusao a um idioma; a fachada repassa o parametro. hsm_id fora do formato responde 400 INVALID_TEMPLATE_ID.",
        "security": [
          {
            "channelToken": []
          }
        ],
        "responses": {
          "200": {
            "description": "Shape compativel com a Graph API"
          },
          "404": {
            "description": "Recurso nao pertence ao canal"
          }
        }
      }
    },
    "/ready": {
      "get": {
        "summary": "Readiness do banco standalone",
        "security": [],
        "responses": {
          "200": {
            "description": "Pronto"
          },
          "503": {
            "description": "Dependencia indisponivel"
          }
        }
      }
    }
  }
}
