Sócios AI Hub · Referência da API v1
O hub é a camada de mensageria da Sócios AI entre o seu sistema e a Meta. Ele conecta números de WhatsApp (API oficial, inclusive em coexistência com o app), contas do Instagram e páginas do Messenger, e expõe tudo por uma única API REST e por webhooks assinados. Este documento vale para qualquer integrador, seja um app da Sócios AI ou um sistema de terceiros: descreve como o hub funciona, o que cada endpoint espera e devolve, e o que os webhooks entregam. Todas as amostras foram capturadas executando o código de produção do hub.
01Como o hub funciona
A Sócios AI é Tech Provider da Meta. O cliente final conecta o número, a conta do Instagram ou a página dele ao app da Meta do hub, por um wizard hospedado pelo hub, e a partir daí o hub recebe os webhooks da Meta e envia mensagens em nome desse cliente. Quem consome a API não precisa ter app da Meta, App Review nem URL de webhook cadastrada na Meta.
Você recebe uma API key do seu tenant. Cada cliente final seu vira uma subconta, criada por você com o seu próprio id (external_ref).
Você registra a URL onde quer receber os eventos de cada subconta e guarda o secret para validar a assinatura.
Você gera um link de conexão e manda o cliente para ele. Ele autoriza na Meta e volta; o hub avisa por channel.connected.
POST /v1/messages envia. O hub entrega message.received e os recibos message.status na sua URL, assinados.
Conceitos
| Conceito | O que é |
|---|---|
| Tenant | A sua conta no hub. Dona da API key shk_…. Vê e opera todas as suas subcontas. Um tenant pode ser um app inteiro (com milhares de clientes finais) ou uma única empresa. |
| Subconta | Um cliente final seu. Isola canais, contatos, conversas, eventos e uso. Tem limite de canais (max_channels), que você define; o hub bloqueia o excedente. Pode ser suspensa por você e pode ter API key própria, que enxerga só ela. |
| API key | Credencial shk_…, de uso exclusivo em backend. A key do tenant age em qualquer subconta via header X-Subaccount-Id; a key de subconta age só nela mesma, sem header. |
| Canal | Um número de WhatsApp, uma conta profissional do Instagram ou uma página do Facebook conectados a uma conta. Identificado por channel_id. |
| Contato | Quem fala com o canal. external_id é o identificador dele no canal (wa_id, PSID, IGSID) e é o to do envio. |
| Conversa | Contato + canal. O hub cria e mantém; o conversation_id chega em todo evento de mensagem. |
| Janela de 24h | Regra da Meta: mensagem livre só até 24h após a última mensagem do contato. O hub aplica e devolve window_closed quando fecha. No WhatsApp, template aprovado destrava; Instagram e Messenger não têm template. |
| Coexistência | Modo do WhatsApp em que o número continua funcionando no app WhatsApp Business do celular e, ao mesmo tempo, na API. Mensagens que o cliente final enviar pelo celular chegam como message.echo. |
| Evento | Tudo que o hub conta para você: mensagem recebida, recibo, canal conectado, template aprovado. Sai por webhook e fica consultável em GET /v1/events por 90 dias. |
| Entrega | Uma tentativa de levar um evento a uma assinatura de webhook. Tem estado (pending, delivered, failed, dead), pode ser consultada e reentregue. |
Convenções
| Header | Uso |
|---|---|
Authorization: Bearer shk_… | Obrigatório em /v1/*. Uso exclusivo em backend, nunca em browser ou app móvel. |
X-Subaccount-Id | Com a key do tenant: age em nome da subconta indicada (tem de ser sua, senão 403 forbidden). Sem o header, a chamada age no próprio tenant. Com key de subconta o header é sempre recusado (403). Rotas de gestão de subcontas e de keys não aceitam o header (422). |
Idempotency-Key | Em POST /v1/messages. Repetir com a mesma key devolve 200 com a mesma message_id, sem duplicar. |
X-Hub-Contract-Version | Em toda resposta. Só muda em quebra de contrato; campo novo não muda. Registre o valor e alerte quando mudar. Este documento descreve a 1.3. |
- Nomes: todo campo de request, resposta e evento é
snake_case. Único resquício: o aliasexternalIdemfrom/todos eventos, que será removido; useexternal_id. - Datas: rotas REST em ISO 8601 (UTC). Nos webhooks,
created_atdo envelope é epoch em segundos edata.timestampé epoch em milissegundos. - Ids: opacos, strings. Nas amostras deste documento a subconta é
cmtyomb7b0005n5s1bl1h6p5a. - Erros: um formato só, com
codeestável. Ramifique nocode;erroredetailsão texto para humano. Tabela completa em Códigos de erro. - Eventos novos e campos novos são mudança aditiva: trate evento desconhecido como no-op (responda 2xx) e ignore campos que não conhece.
Changelog do contrato
1.3 (01/09/2026): GET /v1/channels, GET /v1/messages/{id} e GET /v1/webhooks passaram de camelCase para snake_case (display_name, external_id, created_at, provider_message_id, conversation_id, channel_id, updated_at). Em eventos, from/to ganharam external_id; externalId ainda é emitido por compatibilidade e será removido.
Aditivo (02/09/2026): keys por subconta (POST/GET/DELETE /v1/subaccounts/{id}/api-keys), expires_at em keys, status expired; /v1/api-keys/* e /v1/subaccounts* passam a recusar X-Subaccount-Id com 422.
Aditivo (05/09/2026, ciclo de vida da integração): GET /v1/me; external_ref e status (active | suspended) em subcontas, GET /v1/subaccounts/{id} e filtros ?external_ref/?status; contas suspensas respondem 403 account_suspended; webhooks ganharam GET/PATCH /{id}, POST /{id}/test (ping assinado), POST /{id}/rotate-secret, GET /{id}/deliveries, GET/POST /{id}/deliveries/{delivery_id}[/redeliver]; GET /v1/events e /v1/events/{id} (os eventos entregues, consultáveis); GET /v1/audit (trilha de auditoria); evento novo webhook.test; erros novos account_suspended e event_expired. Assinaturas de webhook passam a recusar nomes de evento desconhecidos (422).
Aditivo (12/09/2026, Google Agenda): a subconta conecta a Google Agenda do cliente final pelo mesmo connect hospedado (POST /v1/connect-sessions com integration: "google_calendar"); GET/DELETE /v1/calendar-accounts, GET/PATCH /v1/calendars, POST /v1/calendars/availability (horários livres), CRUD em /v1/calendars/{id}/events; eventos calendar.connected, calendar.reconnected, calendar.disconnected, calendar.event.created, calendar.event.updated, calendar.event.cancelled; erros novos calendar_needs_reauth, calendar_scope_missing, calendar_conflict, calendar_rate_limited, calendar_not_found, calendar_limit_reached, integration_unavailable, invalid_state; max_calendars nas subcontas. Seção Google Agenda.
Não cadastre o app da Meta do hub no seu sistema. Um app da Meta tem uma única URL de webhook por produto. O hub é o dono desse app; a integração é inteiramente pela API descrita aqui.
02Autenticação e identidade
Há dois tipos de key. A key do tenant dá acesso a todas as suas subcontas via X-Subaccount-Id. A key de subconta, criada por você para um cliente específico, autentica como aquela subconta: enxerga só ela, não aceita o header, não gerencia subcontas, tem cota de requisições própria e não é atingida pela rotação da key do tenant. É a credencial certa para uma instalação on-premise ou para um cliente que não pode receber a key do tenant.
/v1/meQuem sou
Primeira chamada de qualquer integração: confirma que a key vale, se X-Subaccount-Id foi aceito, quais limites e plano se aplicam, a versão do contrato e o catálogo de eventos que esta versão entrega. account é a conta desta chamada (a subconta, quando o header é usado); key_owner é o dono da key.
Headers: Authorization · X-Subaccount-Id
GET /v1/me
Authorization: Bearer shk_…
HTTP 200
x-hub-contract-version: 1.3
{
"account": {
"id": "cmtyomb120000n5s10af0zpfj",
"slug": "seu-app",
"name": "Seu app",
"kind": "tenant",
"parent_id": null,
"external_ref": null,
"status": "active",
"max_channels": null,
"max_calendars": null,
"rate_limit_rps": 30,
"plan": null,
"created_at": "2026-09-12T17:51:39.350Z"
},
"key_owner": {
"id": "cmtyomb120000n5s10af0zpfj",
"slug": "seu-app",
"kind": "tenant"
},
"api_key": {
"id": "cmtyomb120001n5s1a6i6is3c",
"name": "produção",
"status": "active",
"expires_at": null
},
"acting_as_subaccount": false,
"contract_version": "1.3",
"events": [
"message.received",
"message.status",
"message.echo",
"channel.connected",
"channel.reconnected",
"channel.disconnected",
"channel.health_changed",
"template.status",
"template.category_changed",
"history.completed",
"lead.received",
"calendar.connected",
"calendar.reconnected",
"calendar.disconnected",
"calendar.event.created",
"calendar.event.updated",
"calendar.event.cancelled",
"booking.created",
"booking.rescheduled",
"booking.cancelled",
"booking.confirmed",
"booking.reschedule_requested",
"booking.reminder_sent",
"booking.reminder_failed",
"webhook.test"
]
}Em nome de uma subconta, e com a key da subconta
GET /v1/me
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"account": {
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"kind": "subaccount",
"parent_id": "cmtyomb120000n5s10af0zpfj",
"external_ref": "crm-4821",
"status": "active",
"max_channels": 3,
"max_calendars": null,
"rate_limit_rps": 30,
"plan": null,
"created_at": "2026-09-12T17:51:39.575Z"
},
"key_owner": {
"id": "cmtyomb120000n5s10af0zpfj",
"slug": "seu-app",
"kind": "tenant"
},
"api_key": {
"id": "cmtyomb120001n5s1a6i6is3c",
"name": "produção",
"status": "active",
"expires_at": null
},
"acting_as_subaccount": true,
"contract_version": "1.3",
"events": [
"message.received",
"message.status",
"message.echo",
"channel.connected",
"channel.reconnected",
"channel.disconnected",
"channel.health_changed",
"template.status",
"template.category_changed",
"history.completed",
"lead.received",
"calendar.connected",
"calendar.reconnected",
"calendar.disconnected",
"calendar.event.created",
"calendar.event.updated",
"calendar.event.cancelled",
"booking.created",
"booking.rescheduled",
"booking.cancelled",
"booking.confirmed",
"booking.reschedule_requested",
"booking.reminder_sent",
"booking.reminder_failed",
"webhook.test"
]
}GET /v1/me
Authorization: Bearer shk_… (key da subconta)
HTTP 200
{
"account": {
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"kind": "subaccount",
"parent_id": "cmtyomb120000n5s10af0zpfj",
"external_ref": "crm-4821",
"status": "active",
"max_channels": 3,
"max_calendars": null,
"rate_limit_rps": 30,
"plan": null,
"created_at": "2026-09-12T17:51:39.575Z"
},
"key_owner": {
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"kind": "subaccount"
},
"api_key": {
"id": "cmtyombao000bn5s13vm7yetb",
"name": "instalação on-premise Clínica Sorriso",
"status": "active",
"expires_at": "2027-09-01T00:00:00.000Z"
},
"acting_as_subaccount": false,
"contract_version": "1.3",
"events": [
"message.received",
"message.status",
"message.echo",
"channel.connected",
"channel.reconnected",
"channel.disconnected",
"channel.health_changed",
"template.status",
"template.category_changed",
"history.completed",
"lead.received",
"calendar.connected",
"calendar.reconnected",
"calendar.disconnected",
"calendar.event.created",
"calendar.event.updated",
"calendar.event.cancelled",
"booking.created",
"booking.rescheduled",
"booking.cancelled",
"booking.confirmed",
"booking.reschedule_requested",
"booking.reminder_sent",
"booking.reminder_failed",
"webhook.test"
]
}Keys do dono da key
Estas rotas agem sempre no dono da key que autenticou: o tenant, ou a própria subconta quando a key é dela. Não aceitam X-Subaccount-Id.
/v1/api-keysListar as próprias keys
Sem o valor das keys. status: active, expiring (rotação em curso, ainda válida), revoked, expired.
Headers: Authorization
GET /v1/api-keys
Authorization: Bearer shk_…
HTTP 200
{
"data": [
{
"id": "cmtyomb120001n5s1a6i6is3c",
"name": "produção",
"status": "active",
"last_used_at": "2026-09-12T17:51:39.717Z",
"revoked_at": null,
"expires_at": null,
"created_at": "2026-09-12T17:51:39.350Z"
}
]
}/v1/api-keys/rotateRotacionar a própria key
Cria uma key nova e agenda a revogação de todas as ativas do mesmo dono (inclusive a que chamou) para daqui a grace_hours. A key nova aparece só nesta resposta: guarde-a no cofre antes de fechar a conexão. Rotacionar a key do tenant não atinge as keys das subcontas.
Headers: Authorization
| Campo | Tipo | Descrição |
|---|---|---|
grace_hours | integer, opcional | Quanto tempo as keys antigas continuam válidas. Padrão 24, máximo 168 (7 dias). 0 revoga na hora |
POST /v1/api-keys/rotate
Authorization: Bearer shk_…
{
"grace_hours": 24
}
HTTP 201
{
"key": "shk_sSeIXsU0W5Jl7OR0vvUpK3jpt6V2b16R",
"key_id": "cmtyomj5x006zn5s1ly2htuzi",
"old_keys_valid_until": "2026-09-13T17:51:49.892Z"
}O header X-Subaccount-Id é recusado aqui
POST /v1/api-keys/rotate
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"grace_hours": 24
}
HTTP 422
{
"error": "validation failed",
"detail": "X-Subaccount-Id não se aplica a esta rota; para keys de subconta use /v1/subaccounts/:id/api-keys",
"code": "invalid_payload",
"retryable": false,
"details": {
"header": "x-subaccount-id"
}
}Keys por subconta
/v1/subaccounts/{id}/api-keysCriar key de subconta
Só a key do tenant cria. O valor da key aparece apenas nesta resposta. Pode ter prazo de validade.
Headers: Authorization
| Campo | Tipo | Descrição |
|---|---|---|
name | string, obrigatório | Nome de referência (até 80 caracteres) |
expires_at | string ISO 8601, opcional | Expiração; precisa ser no futuro. Ausente = não expira |
POST /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a/api-keys
Authorization: Bearer shk_…
{
"name": "instalação on-premise Clínica Sorriso",
"expires_at": "2027-09-01T00:00:00.000Z"
}
HTTP 201
{
"key": "shk_XVgQZWaNJPfxd7j615Ue4wSJitrZAa9N",
"id": "cmtyombao000bn5s13vm7yetb",
"name": "instalação on-premise Clínica Sorriso",
"status": "active",
"last_used_at": null,
"revoked_at": null,
"expires_at": "2027-09-01T00:00:00.000Z",
"created_at": "2026-09-12T17:51:39.696Z",
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a"
}/v1/subaccounts/{id}/api-keysListar keys da subconta
Sem o valor das keys.
Headers: Authorization
GET /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a/api-keys
Authorization: Bearer shk_…
HTTP 200
{
"data": [
{
"id": "cmtyombao000bn5s13vm7yetb",
"name": "instalação on-premise Clínica Sorriso",
"status": "active",
"last_used_at": null,
"revoked_at": null,
"expires_at": "2027-09-01T00:00:00.000Z",
"created_at": "2026-09-12T17:51:39.696Z",
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a"
}
]
}/v1/subaccounts/{id}/api-keys/{key_id}Revogar key de subconta
Imediato, sem período de graça: revogar é ação de segurança.
Headers: Authorization
DELETE /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a/api-keys/cmtyombao000bn5s13vm7yetb
Authorization: Bearer shk_…
HTTP 200
{
"id": "cmtyombao000bn5s13vm7yetb",
"status": "revoked",
"revoked_at": "2026-09-12T17:51:49.904Z"
}O que a key de subconta vê, e o que ela não pode
GET /v1/usage
Authorization: Bearer shk_… (key da subconta)
HTTP 200
{
"tenant_id": "cmtyomb7b0005n5s1bl1h6p5a",
"period_start": "2026-09-01T00:00:00.000Z",
"channels": [
{
"type": "whatsapp",
"status": "connected",
"count": 1
},
{
"type": "instagram",
"status": "connected",
"count": 1
},
{
"type": "messenger",
"status": "connected",
"count": 1
}
],
"messages": {
"inbound": 7,
"outbound": 6
},
"max_channels": 3,
"calendar_accounts": {
"connected": 0,
"needs_reauth": 0
},
"max_calendars": null
}GET /v1/subaccounts
Authorization: Bearer shk_… (key da subconta)
HTTP 403
{
"error": "forbidden",
"detail": "subaccount keys cannot manage subaccounts",
"code": "forbidden",
"retryable": false
}GET /v1/channels
Authorization: Bearer shk_… (key da subconta)
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 403
{
"error": "forbidden",
"detail": "subaccount keys cannot act on behalf of another account",
"code": "forbidden",
"retryable": false
}Key inválida
GET /v1/me
Authorization: Bearer shk_chave_invalida
HTTP 401
{
"error": "unauthorized",
"detail": "invalid API key",
"code": "unauthorized",
"retryable": false
}03Subcontas
Uma subconta por cliente final. Guarde o id: é o valor de X-Subaccount-Id em todas as chamadas em nome dele. Quem cobra o cliente é você; o hub aplica o limite de canais e mede o uso.
/v1/subaccountsCriar subconta
Com external_ref (o id da empresa no seu sistema) a ativação fica idempotente: repetir a criação com o mesmo valor devolve 409 conflict com details.existing, a subconta já criada. Reprocessar uma fila de ativação nunca cria cliente duplicado. O slug devolvido vem prefixado pelo slug do tenant; use o id como chave.
Headers: Authorization
| Campo | Tipo | Descrição |
|---|---|---|
name | string, obrigatório | Nome do cliente |
external_ref | string, opcional | Seu identificador desta conta. Único por tenant, até 120 caracteres |
max_channels | integer ou null, opcional | Limite de canais da subconta. Ausente ou null = ilimitado |
slug | string, opcional | Derivado do nome quando ausente |
POST /v1/subaccounts
Authorization: Bearer shk_…
{
"name": "Clínica Sorriso",
"slug": "clinica-sorriso",
"max_channels": 2,
"external_ref": "crm-4821"
}
HTTP 201
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 2,
"max_calendars": null,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 0,
"created_at": "2026-09-12T17:51:39.575Z"
}POST /v1/subaccounts
Authorization: Bearer shk_…
{
"name": "Clínica Sorriso",
"max_channels": 2,
"external_ref": "crm-4821"
}
HTTP 409
{
"error": "conflict",
"detail": "a subaccount with this external_ref already exists",
"code": "conflict",
"retryable": false,
"details": {
"existing": {
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 2,
"max_calendars": null,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 0,
"created_at": "2026-09-12T17:51:39.575Z"
}
}
}/v1/subaccountsListar subcontas
Visão do tenant, com quantos canais cada subconta tem conectados. Filtros: ?external_ref (busca pela sua referência) e ?status (active | suspended).
Headers: Authorization
GET /v1/subaccounts
Authorization: Bearer shk_…
HTTP 200
{
"data": [
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 3,
"max_calendars": null,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 3,
"created_at": "2026-09-12T17:51:39.575Z"
}
]
}Buscar pelo external_ref e filtrar por status
GET /v1/subaccounts?external_ref=crm-4821
Authorization: Bearer shk_…
HTTP 200
{
"data": [
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 2,
"max_calendars": null,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 0,
"created_at": "2026-09-12T17:51:39.575Z"
}
]
}GET /v1/subaccounts?status=suspended
Authorization: Bearer shk_…
HTTP 200
{
"data": [
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 3,
"max_calendars": 1,
"external_ref": "crm-4821",
"status": "suspended",
"suspended_at": "2026-09-12T17:51:47.229Z",
"connected_channels": 3,
"created_at": "2026-09-12T17:51:39.575Z"
}
]
}/v1/subaccounts/{id}Detalhe de uma subconta
Mesmo objeto da criação, sempre atualizado. 404 se não for sua.
Headers: Authorization
GET /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a
Authorization: Bearer shk_…
HTTP 200
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 2,
"max_calendars": null,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 0,
"created_at": "2026-09-12T17:51:39.575Z"
}/v1/subaccounts/{id}Atualizar subconta
Ajusta name, max_channels, external_ref e/ou status. Responde com o mesmo objeto do POST. Trocar external_ref para um valor já usado por outra subconta devolve 409 com details.existing_id.
Headers: Authorization
| Campo | Tipo | Descrição |
|---|---|---|
name | string, opcional | |
max_channels | integer ou null, opcional | Subir o limite libera novas conexões na hora; baixar não desconecta canais |
external_ref | string ou null, opcional | |
status | active | suspended, opcional | Ver abaixo |
PATCH /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a
Authorization: Bearer shk_…
{
"max_channels": 3
}
HTTP 200
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 3,
"max_calendars": null,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 0,
"created_at": "2026-09-12T17:51:39.575Z"
}Suspensão
Cliente inadimplente? Suspenda a subconta em vez de apagar. A partir daí toda chamada em nome dela (pelo header ou pela key dela) responde 403 account_suspended, os webhooks param de ser entregues, e canais, conversas e eventos ficam intactos. Os eventos que acontecerem durante a suspensão continuam sendo gravados: ao reativar, a conta recupera o que perdeu por GET /v1/events.
PATCH /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a
Authorization: Bearer shk_…
{
"status": "suspended"
}
HTTP 200
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 3,
"max_calendars": 1,
"external_ref": "crm-4821",
"status": "suspended",
"suspended_at": "2026-09-12T17:51:47.229Z",
"connected_channels": 3,
"created_at": "2026-09-12T17:51:39.575Z"
}GET /v1/usage
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 403
{
"error": "account suspended",
"detail": "this subaccount is suspended",
"code": "account_suspended",
"retryable": false
}GET /v1/me
Authorization: Bearer shk_… (key da subconta)
HTTP 403
{
"error": "account suspended",
"detail": "this account is suspended",
"code": "account_suspended",
"retryable": false
}PATCH /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a
Authorization: Bearer shk_…
{
"status": "active"
}
HTTP 200
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 3,
"max_calendars": 1,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 3,
"created_at": "2026-09-12T17:51:39.575Z"
}GET /v1/events?event=message.received&limit=1
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomh4b006rn5s1s2e06jza",
"event": "message.received",
"channel_id": "cmtyombh50013n5s136btsszl",
"created_at": "2026-09-12T17:51:47.244Z",
"data": {
"from": {
"name": "Ana Souza",
"externalId": "5511977776666",
"external_id": "5511977776666"
},
"type": "text",
"channel": "whatsapp",
"content": {
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xpbi1zdXNwZW5kZWQ",
"from": "5511977776666",
"text": {
"body": "Consegui remarcar?"
},
"type": "text",
"timestamp": "1789232899"
},
"timestamp": 1789232899000,
"channel_id": "cmtyombh50013n5s136btsszl",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"message_id": "cmtyomh4h006vn5s17xmz94ge",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5"
},
"expired": false
}
],
"has_more": true,
"next_before": "cmtyomh4b006rn5s1s2e06jza"
}/v1/usageUso do mês
Canais por tipo e status, e mensagens recebidas e enviadas desde o primeiro dia do mês (UTC). Base para o seu billing por cliente. Sem o header, é o uso do próprio tenant.
Headers: Authorization · X-Subaccount-Id
GET /v1/usage
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"tenant_id": "cmtyomb7b0005n5s1bl1h6p5a",
"period_start": "2026-09-01T00:00:00.000Z",
"channels": [
{
"type": "whatsapp",
"status": "connected",
"count": 1
},
{
"type": "instagram",
"status": "connected",
"count": 1
},
{
"type": "messenger",
"status": "connected",
"count": 1
}
],
"messages": {
"inbound": 7,
"outbound": 6
},
"max_channels": 3,
"calendar_accounts": {
"connected": 0,
"needs_reauth": 0
},
"max_calendars": null
}Erros de escopo
GET /v1/channels
Authorization: Bearer shk_…
X-Subaccount-Id: cm00000000000000000000000
HTTP 403
{
"error": "forbidden",
"detail": "subaccount not found or not owned by this tenant",
"code": "forbidden",
"retryable": false
}GET /v1/subaccounts
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 422
{
"error": "validation failed",
"detail": "X-Subaccount-Id não se aplica a esta rota; para keys de subconta use /v1/subaccounts/:id/api-keys",
"code": "invalid_payload",
"retryable": false,
"details": {
"header": "x-subaccount-id"
}
}04Conectar canais
O hub hospeda o wizard de conexão. Você cria uma sessão, recebe uma URL de uso único e manda o cliente final para ela. Ele faz login na Meta, escaneia o QR (coexistência) ou escolhe a página, e o hub o devolve ao return_url. Nenhuma credencial da Meta passa pelo seu sistema. O canal criado chega pelo evento channel.connected e aparece em GET /v1/channels.
| Canal | channel_type | O que o cliente faz no wizard | Validade da sessão |
|---|---|---|---|
| WhatsApp em coexistência | whatsapp + coexistence: true | Login na Meta, aceita os termos, escaneia um QR no app WhatsApp Business do celular. O app continua funcionando. | 2 h |
| WhatsApp número novo | whatsapp + coexistence: false | Login na Meta, cadastra o número, verificação por SMS ou ligação. | 30 min |
instagram | Login no Facebook, escolhe a página que tem a conta profissional do Instagram vinculada. | 30 min | |
| Messenger | messenger | Login no Facebook, escolhe a página. | 30 min |
/v1/connect-sessionsCriar sessão de conexão
Falha com 422 quota_exceeded se a subconta já usa todos os canais do limite, antes de o cliente ver a Meta. Sessões expiram e são de uso único: gere no momento do clique. Uma sessão nova para um canal que estava desconectado o reconecta com o mesmo channel_id.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
channel_type | whatsapp | instagram | messenger | Padrão whatsapp |
coexistence | boolean | Só WhatsApp. true = número que já está no app WhatsApp Business. A Meta confirma a situação real do número durante o wizard |
return_url | string, opcional | Para onde o cliente volta ao terminar. Não recebe parâmetros |
POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "whatsapp",
"coexistence": true,
"return_url": "https://app.seuapp.com.br/canais?hub=ok"
}
HTTP 201
{
"id": "cmtyombg7000vn5s1nwe6plas",
"channel_type": "whatsapp",
"url": "https://hub.sociosai.com/connect/93S7G5WuC69IPDqRQy6vSwRetNq8JJjP",
"expires_at": "2026-09-12T19:51:39.895Z"
}Outras variações e os erros
POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "whatsapp",
"coexistence": false,
"return_url": "https://app.seuapp.com.br/canais?hub=ok"
}
HTTP 201
{
"id": "cmtyombgf000xn5s1lwh0vibc",
"channel_type": "whatsapp",
"url": "https://hub.sociosai.com/connect/BJm7pQJxULJm1U0Oc9usWUnIcobsstGu",
"expires_at": "2026-09-12T18:21:39.902Z"
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "instagram",
"return_url": "https://app.seuapp.com.br/canais?hub=ok"
}
HTTP 201
{
"id": "cmtyombgo000zn5s168myqrei",
"channel_type": "instagram",
"url": "https://hub.sociosai.com/connect/GP3hAkBIGrFQdaTuGT0sPMbTPjnxphHb",
"expires_at": "2026-09-12T18:21:39.912Z"
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "messenger",
"return_url": "https://app.seuapp.com.br/canais?hub=ok"
}
HTTP 201
{
"id": "cmtyombgu0011n5s13d9t7y85",
"channel_type": "messenger",
"url": "https://hub.sociosai.com/connect/ZfdqPg2U7EDjE0GZEwEdnUIfFyFZ0SE-",
"expires_at": "2026-09-12T18:21:39.918Z"
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "whatsapp",
"coexistence": true
}
HTTP 422
{
"error": "quota exceeded",
"detail": "tenant já usa 3/3 canais; ajuste o limite na conta-mãe",
"code": "quota_exceeded",
"retryable": false
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "sms"
}
HTTP 422
{
"error": "validation failed",
"code": "invalid_payload",
"retryable": false,
"details": {
"formErrors": [],
"fieldErrors": {
"channel_type": [
"Invalid enum value. Expected 'whatsapp' | 'messenger' | 'instagram', received 'sms'"
]
}
}
}O que acontece ao terminar
- O hub emite
channel.connectedna assinatura da subconta (ouchannel.reconnected, se for um canal que estava desconectado). Odata.channel_idé o id do canal. - O cliente é redirecionado ao
return_url. ConsulteGET /v1/channelsse preferir descobrir o canal por polling.
{
"id": "cmtyombh90015n5s1fjpfzazv",
"event": "channel.connected",
"created_at": 1789235499,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"channel_id": "cmtyombh50013n5s136btsszl",
"data": {
"channel_id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"display_name": "Clínica Sorriso",
"coexistence": true
}
}Canais
/v1/channelsListar canais
status é connected, disconnected ou pending. external_id é o id na Meta (phone_number_id, id da conta do Instagram, id da página). meta traz dados informativos; no WhatsApp, display_phone_number é o número formatado e coexistence diz o modo.
Headers: Authorization · X-Subaccount-Id
GET /v1/channels
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"status": "connected",
"display_name": "Clínica Sorriso",
"external_id": "106540262345678",
"meta": {
"waba_id": "102290129340398",
"coexistence": true,
"platform_type": "CLOUD_API",
"verified_name": "Clínica Sorriso",
"client_business_id": "2048571234567890",
"display_phone_number": "+55 11 93001-0001"
},
"created_at": "2026-09-12T17:51:39.930Z"
},
{
"id": "cmtyombho0019n5s1jeacwlaq",
"type": "instagram",
"status": "connected",
"display_name": "clinicasorriso",
"external_id": "17841405309211844",
"meta": {
"page_id": "105374289123456",
"page_name": "Clínica Sorriso",
"ig_username": "clinicasorriso"
},
"created_at": "2026-09-12T17:51:39.948Z"
},
{
"id": "cmtyombi2001dn5s166wsq7hd",
"type": "messenger",
"status": "connected",
"display_name": "Clínica Sorriso",
"external_id": "105374289123456",
"meta": {
"page_id": "105374289123456",
"page_name": "Clínica Sorriso",
"ig_username": "clinicasorriso"
},
"created_at": "2026-09-12T17:51:39.963Z"
}
]
}/v1/channels/{id}Detalhe do canal com saúde
Mesmo objeto, mais health: estado da conexão e, no WhatsApp, qualidade (quality_rating), limite de mensagens (messaging_limit) e meio de pagamento (billing_ok), conforme os webhooks da Meta forem chegando. ?refresh=1 força uma leitura na Meta. Mudanças chegam pelo evento channel.health_changed.
Headers: Authorization · X-Subaccount-Id
GET /v1/channels/cmtyombh50013n5s136btsszl?refresh=1
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"status": "connected",
"display_name": "Clínica Sorriso",
"external_id": "106540262345678",
"meta": {
"waba_id": "102290129340398",
"coexistence": true,
"platform_type": "CLOUD_API",
"verified_name": "Clínica Sorriso",
"client_business_id": "2048571234567890",
"display_phone_number": "+55 11 93001-0001"
},
"created_at": "2026-09-12T17:51:39.930Z",
"health": {
"connection_status": "CONNECTED",
"updated_at": "2026-09-12T17:51:43.068Z",
"quality_rating": "GREEN",
"messaging_limit": "TIER_1K",
"platform_type": "CLOUD_API",
"fetched_at": "2026-09-12T17:51:43.174Z"
}
}/v1/channels/{id}Desconectar canal
Cancela os webhooks no provider quando o tipo permite (provider_cleanup), marca disconnected, preserva contatos e conversas e emite channel.disconnected. Envios ao canal passam a responder 409 channel_disconnected. O mesmo canal pode ser reconectado depois por uma nova sessão de conexão.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
reason | string, opcional | Vai no evento channel.disconnected |
DELETE /v1/channels/cmtyombi2001dn5s166wsq7hd
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"reason": "cliente_cancelou"
}
HTTP 200
{
"id": "cmtyombi2001dn5s166wsq7hd",
"status": "disconnected",
"provider_cleanup": "done"
}O evento emitido, o erro ao enviar e a reconexão
{
"id": "cmtyome13005fn5s1mzfhi2xl",
"event": "channel.disconnected",
"created_at": 1789235503,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"data": {
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"type": "messenger",
"display_name": "Clínica Sorriso",
"reason": "cliente_cancelou",
"initiated_by": "tenant"
}
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"to": "7423981234567890",
"type": "text",
"text": {
"body": "Olá!"
}
}
HTTP 409
{
"error": "channel is disconnected",
"detail": "channel is disconnected",
"code": "channel_disconnected",
"retryable": false
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_type": "messenger",
"return_url": "https://app.seuapp.com.br/canais?hub=ok"
}
HTTP 201
{
"id": "cmtyome44005hn5s1bs67erni",
"channel_type": "messenger",
"url": "https://hub.sociosai.com/connect/B-MDT9e26vyUn3nrXMMjw9sfB59TIQRf",
"expires_at": "2026-09-12T18:21:43.348Z"
}{
"id": "cmtyome4i005ln5s1wdv4v415",
"event": "channel.reconnected",
"created_at": 1789235503,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"data": {
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"type": "messenger",
"display_name": "Clínica Sorriso"
}
}Caminho avançado: credencial própria e Embedded Signup no seu painel
Para quem já tem token da Meta, ou quer embutir o popup do Embedded Signup no próprio painel em vez de usar a página hospedada. O caminho normal para cliente final continua sendo a sessão de conexão.
/v1/channelsCriar canal com credencial própria
WhatsApp: {type: "whatsapp", phone_number_id, waba_id, access_token, display_name?}. Messenger ou Instagram: {type, page_id, access_token} (token de página; o hub valida o token, resolve o nome e a conta do Instagram vinculada). Telegram: {type: "telegram", bot_token}. Número, página ou bot já conectados em outra conta devolvem 409 conflict.
Headers: Authorization · X-Subaccount-Id
POST /v1/channels
Authorization: Bearer shk_…
{
"type": "messenger",
"page_id": "105374289123457",
"access_token": "EAABsbCS1iHgBAexemploDeTokenDePagina",
"display_name": "Página de suporte"
}
HTTP 201
{
"id": "cmtyomblo001ln5s1rpka7gvc",
"type": "messenger",
"status": "connected",
"display_name": "Página de suporte"
}/v1/channels/whatsapp/embedded-signupConfig pública do Embedded Signup
O app_id e o config_id para o FB.login() do seu painel, se você embutir o popup da Meta em vez de usar a página hospedada.
Headers: Authorization
GET /v1/channels/whatsapp/embedded-signup
Authorization: Bearer shk_…
HTTP 200
{
"app_id": "3562800103892744",
"config_id": "999006285944305",
"graph_version": "v21.0"
}/v1/channels/whatsapp/connectConcluir o Embedded Signup pela API
Recebe o resultado do popup (code, waba_id, phone_number_id) e faz o onboarding completo: troca o code por token, registra o número, assina os webhooks da WABA. Responde com o canal ativo. pin (6 dígitos) é opcional, para números com verificação em duas etapas.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
code | string, obrigatório | Devolvido pelo popup |
waba_id | string, obrigatório | |
phone_number_id | string, obrigatório | |
display_name | string, opcional | |
pin | string, opcional | 6 dígitos |
POST /v1/channels/whatsapp/connect
Authorization: Bearer shk_…
{
"code": "ES_CODE",
"waba_id": "102290129340398",
"phone_number_id": "106540262345679",
"display_name": "Suporte Seu app"
}
HTTP 201
{
"id": "cmtyomble001hn5s1wm9mtiw8",
"type": "whatsapp",
"status": "connected",
"display_name": "Suporte Seu app",
"phone_number": "+55 11 93001-0002",
"waba_id": "102290129340398"
}04bGoogle Agenda
A agenda do cliente final entra no hub como integração da subconta, não como canal: não tem conversa nem janela de 24h, tem disponibilidade, compromissos e mudanças. O caminho é o mesmo dos canais: você cria uma sessão de conexão, o cliente autoriza no Google e volta ao return_url com ?calendar_account_id=.... O hub pede ao Google só três permissões (listar agendas, ver horários ocupados, criar e alterar compromissos nas agendas dele), guarda o token cifrado e nunca guarda título nem descrição dos compromissos: isso vem do Google a cada leitura.
O que o integrador ganha: POST /v1/calendars/availability devolve os horários livres já com regras de negócio (duração, folga, horário de trabalho, antecedência), considerando todas as agendas marcadas; POST /v1/calendars/{id}/events marca com checagem de conflito, Meet e convidados; e o que o cliente mudar direto no Google (remarcou, cancelou) chega por webhook em calendar.event.updated e calendar.event.cancelled com changed_by: "external".
Conectar a agenda
/v1/connect-sessionsCriar sessão de conexão da Google Agenda
Mesma rota dos canais, com integration no lugar de channel_type. Falha com 422 calendar_limit_reached se a subconta já usa todas as contas de max_calendars. A sessão vale 30 min e é de uso único. Ao terminar, o hub emite calendar.connected com as agendas e redireciona o cliente.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
integration | google_calendar | Obrigatório aqui. Exclusivo com channel_type |
return_url | string, opcional | Recebe ?calendar_account_id= ao voltar |
reconnect_account_id | string, opcional | Reautorizar uma conta em needs_reauth; não conta na quota |
POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"integration": "google_calendar",
"return_url": "https://app.seuapp.com.br/agenda?hub=ok"
}
HTTP 201
{
"id": "cmtyomeye005tn5s1qmrbkdlj",
"integration": "google_calendar",
"url": "https://hub.sociosai.com/connect/vB9MDUS1QlutBie2Fvbmc1a6PS5jGrzh",
"expires_at": "2026-09-12T18:21:44.438Z"
}Limite de agendas e reautorização
PATCH /v1/subaccounts/cmtyomb7b0005n5s1bl1h6p5a
Authorization: Bearer shk_…
{
"max_calendars": 1
}
HTTP 200
{
"id": "cmtyomb7b0005n5s1bl1h6p5a",
"slug": "seu-app--clinica-sorriso",
"name": "Clínica Sorriso",
"max_channels": 3,
"max_calendars": 1,
"external_ref": "crm-4821",
"status": "active",
"suspended_at": null,
"connected_channels": 3,
"created_at": "2026-09-12T17:51:39.575Z"
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"integration": "google_calendar"
}
HTTP 422
{
"error": "calendar limit reached",
"detail": "subconta já usa 1/1 contas de agenda; ajuste max_calendars na conta-mãe",
"code": "calendar_limit_reached",
"retryable": false
}POST /v1/calendars/availability
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"from": "2026-09-14T00:00:00-03:00",
"to": "2026-09-14T23:59:00-03:00",
"duration_min": 30
}
HTTP 403
{
"error": "calendar account needs to be reconnected",
"detail": "calendar account clinica.sorriso@example.com is needs_reauth",
"code": "calendar_needs_reauth",
"retryable": false,
"details": {
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh"
}
}POST /v1/connect-sessions
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"integration": "google_calendar",
"reconnect_account_id": "cmtyomeyq005vn5s1kaei6flh"
}
HTTP 201
{
"id": "cmtyomfwv0069n5s1q4ijmm56",
"integration": "google_calendar",
"url": "https://hub.sociosai.com/connect/tZ2-WHi8z8zvT7mDRpRXlIsaYWPJ1Nsx",
"expires_at": "2026-09-12T18:21:45.679Z"
}GET /v1/calendar-accounts
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"display_name": "Clínica Sorriso",
"status": "connected",
"status_reason": null,
"scopes": [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events.freebusy",
"https://www.googleapis.com/auth/calendar.events.owned"
],
"connected_at": "2026-09-12T17:51:45.686Z",
"disconnected_at": null,
"calendars_count": 2,
"created_at": "2026-09-12T17:51:44.451Z"
}
]
}{
"id": "cmtyomfxf006fn5s1hgpw2b7a",
"event": "calendar.reconnected",
"created_at": 1789235505,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"data": {
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"calendars": [
{
"id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "clinica.sorriso@example.com",
"name": "Clínica Sorriso",
"primary": true,
"timezone": "America/Sao_Paulo",
"use_for_availability": true
},
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"external_id": "c_8f2a1b@group.calendar.google.com",
"name": "Sala 1 · Dra. Ana",
"primary": false,
"timezone": "America/Sao_Paulo",
"use_for_availability": true
}
]
}
}O que acontece ao terminar
{
"id": "cmtyomez20061n5s18dbi8eoo",
"event": "calendar.connected",
"created_at": 1789235504,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"data": {
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"calendars": [
{
"id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "clinica.sorriso@example.com",
"name": "Clínica Sorriso",
"primary": true,
"timezone": "America/Sao_Paulo",
"use_for_availability": true
},
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"external_id": "c_8f2a1b@group.calendar.google.com",
"name": "Sala 1 · Dra. Ana",
"primary": false,
"timezone": "America/Sao_Paulo",
"use_for_availability": false
}
]
}
}
calendars[] traz todas as agendas que o cliente enxerga. A primária já vem com use_for_availability: true; as outras (sala, outro profissional) você liga por PATCH /v1/calendars/{id}.
Contas e agendas
/v1/calendar-accountsListar contas de agenda
status é connected, needs_reauth (o token morreu no Google: cliente revogou, seis meses sem uso ou teto do Google; gere uma sessão com reconnect_account_id) ou disconnected. A credencial nunca aparece.
Headers: Authorization · X-Subaccount-Id
GET /v1/calendar-accounts
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"display_name": "Clínica Sorriso",
"status": "connected",
"status_reason": null,
"scopes": [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events.freebusy",
"https://www.googleapis.com/auth/calendar.events.owned"
],
"connected_at": "2026-09-12T17:51:44.450Z",
"disconnected_at": null,
"calendars_count": 2,
"created_at": "2026-09-12T17:51:44.451Z"
}
]
}/v1/calendar-accounts/{id}Detalhe da conta com as agendas
A conta e, em calendars, cada agenda com fuso, papel (owner, writer, reader), se entra na disponibilidade e se o push do Google está ativo (watch_active).
Headers: Authorization · X-Subaccount-Id
GET /v1/calendar-accounts/cmtyomeyq005vn5s1kaei6flh
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"display_name": "Clínica Sorriso",
"status": "connected",
"status_reason": null,
"scopes": [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events.freebusy",
"https://www.googleapis.com/auth/calendar.events.owned"
],
"connected_at": "2026-09-12T17:51:44.450Z",
"disconnected_at": null,
"calendars_count": 2,
"created_at": "2026-09-12T17:51:44.451Z",
"calendars": [
{
"id": "cmtyomeyr005xn5s1mebbmvqa",
"account_id": "cmtyomeyq005vn5s1kaei6flh",
"external_id": "clinica.sorriso@example.com",
"name": "Clínica Sorriso",
"timezone": "America/Sao_Paulo",
"primary": true,
"access_role": "owner",
"use_for_availability": true,
"watch_active": true,
"last_synced_at": null,
"created_at": "2026-09-12T17:51:44.452Z"
},
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"account_id": "cmtyomeyq005vn5s1kaei6flh",
"external_id": "c_8f2a1b@group.calendar.google.com",
"name": "Sala 1 · Dra. Ana",
"timezone": "America/Sao_Paulo",
"primary": false,
"access_role": "writer",
"use_for_availability": false,
"watch_active": false,
"last_synced_at": null,
"created_at": "2026-09-12T17:51:44.453Z"
}
]
}/v1/calendars/{id}Incluir a agenda na disponibilidade; fuso
Ligar use_for_availability faz a agenda contar nos horários livres e liga a notificação de mudanças do Google (renovada pelo hub a cada 7 dias); desligar a para. timezone (IANA) é o fuso padrão dos horários dessa agenda.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
use_for_availability | boolean | |
timezone | string, IANA | ex.: America/Sao_Paulo |
PATCH /v1/calendars/cmtyomeyt005zn5s1na0uw8m1
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"use_for_availability": true
}
HTTP 200
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"account_id": "cmtyomeyq005vn5s1kaei6flh",
"external_id": "c_8f2a1b@group.calendar.google.com",
"name": "Sala 1 · Dra. Ana",
"timezone": "America/Sao_Paulo",
"primary": false,
"access_role": "writer",
"use_for_availability": true,
"watch_active": true,
"last_synced_at": null,
"created_at": "2026-09-12T17:51:44.453Z"
}Todas as agendas da subconta
GET /v1/calendars
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomeyr005xn5s1mebbmvqa",
"account_id": "cmtyomeyq005vn5s1kaei6flh",
"external_id": "clinica.sorriso@example.com",
"name": "Clínica Sorriso",
"timezone": "America/Sao_Paulo",
"primary": true,
"access_role": "owner",
"use_for_availability": true,
"watch_active": true,
"last_synced_at": null,
"created_at": "2026-09-12T17:51:44.452Z"
},
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"account_id": "cmtyomeyq005vn5s1kaei6flh",
"external_id": "c_8f2a1b@group.calendar.google.com",
"name": "Sala 1 · Dra. Ana",
"timezone": "America/Sao_Paulo",
"primary": false,
"access_role": "writer",
"use_for_availability": true,
"watch_active": true,
"last_synced_at": null,
"created_at": "2026-09-12T17:51:44.453Z"
}
]
}/v1/calendar-accounts/{id}Desconectar conta de agenda
Revoga o token no Google, apaga a credencial do hub e para o push. A conta continua listada como disconnected e as agendas ficam (eventos já entregues referenciam os ids). Emite calendar.disconnected. Com ?purge=true apaga conta, agendas e compromissos de vez (pedido de eliminação do titular, LGPD art. 18) e registra calendar_account.purged na auditoria.
Headers: Authorization · X-Subaccount-Id
DELETE /v1/calendar-accounts/cmtyomeyq005vn5s1kaei6flh
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"display_name": "Clínica Sorriso",
"status": "disconnected",
"status_reason": "user",
"scopes": [
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
"https://www.googleapis.com/auth/calendar.events.freebusy",
"https://www.googleapis.com/auth/calendar.events.owned"
],
"connected_at": "2026-09-12T17:51:45.686Z",
"disconnected_at": "2026-09-12T17:51:45.716Z",
"calendars_count": 2,
"created_at": "2026-09-12T17:51:44.451Z"
}O evento emitido
{
"id": "cmtyomfy0006hn5s1cmsnxgyj",
"event": "calendar.disconnected",
"created_at": 1789235505,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"data": {
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"reason": "user"
}
}Horários livres
/v1/calendars/availabilityHorários livres
É o que o bot chama antes de oferecer horários. Consulta o Google (freebusy) para todas as agendas marcadas, mesmo de contas diferentes, une as ocupações e devolve os slots que cabem. Janela máxima de 62 dias. Com uma conta em needs_reauth entre as marcadas, 403 calendar_needs_reauth com details.calendar_account_id.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
from | ISO 8601 com offset | |
to | ISO 8601 com offset | máx. 62 dias depois de from |
duration_min | inteiro | duração do atendimento |
buffer_min | inteiro, opcional | folga antes e depois de cada ocupação |
min_notice_min | inteiro, opcional | não oferece slots que começam antes de agora + este valor |
step_min | inteiro, opcional | passo entre candidatos; padrão = duration_min |
tz | IANA, opcional | padrão: fuso da agenda primária |
working_hours | objeto, opcional | por dia da semana (mon..sun), lista de janelas HH:MM; dia ausente = fechado; sem o campo = 24h |
calendar_ids | lista, opcional | restringe a consulta |
POST /v1/calendars/availability
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"from": "2026-09-14T00:00:00-03:00",
"to": "2026-09-14T23:59:00-03:00",
"duration_min": 30,
"buffer_min": 10,
"min_notice_min": 60,
"working_hours": {
"mon": [
{
"start": "09:00",
"end": "12:00"
},
{
"start": "14:00",
"end": "18:00"
}
]
}
}
HTTP 200
{
"tz": "America/Sao_Paulo",
"from": "2026-09-14T00:00:00-03:00",
"to": "2026-09-14T23:59:00-03:00",
"duration_min": 30,
"slots": [
{
"start": "2026-09-14T09:00:00-03:00",
"end": "2026-09-14T09:30:00-03:00"
},
{
"start": "2026-09-14T11:30:00-03:00",
"end": "2026-09-14T12:00:00-03:00"
},
{
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00"
},
{
"start": "2026-09-14T14:30:00-03:00",
"end": "2026-09-14T15:00:00-03:00"
},
{
"start": "2026-09-14T15:00:00-03:00",
"end": "2026-09-14T15:30:00-03:00"
},
{
"start": "2026-09-14T15:30:00-03:00",
"end": "2026-09-14T16:00:00-03:00"
},
{
"start": "2026-09-14T16:00:00-03:00",
"end": "2026-09-14T16:30:00-03:00"
},
{
"start": "2026-09-14T16:30:00-03:00",
"end": "2026-09-14T17:00:00-03:00"
},
{
"start": "2026-09-14T17:00:00-03:00",
"end": "2026-09-14T17:30:00-03:00"
},
{
"start": "2026-09-14T17:30:00-03:00",
"end": "2026-09-14T18:00:00-03:00"
}
],
"calendars_considered": [
{
"id": "cmtyomeyr005xn5s1mebbmvqa",
"name": "Clínica Sorriso",
"account_id": "cmtyomeyq005vn5s1kaei6flh"
},
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"name": "Sala 1 · Dra. Ana",
"account_id": "cmtyomeyq005vn5s1kaei6flh"
}
]
}Compromissos
/v1/calendars/{id}/eventsCriar compromisso
Cria no Google e devolve o compromisso. Antes de criar, confere o freebusy do intervalo: ocupado → 409 calendar_conflict com details.busy (allow_overlap: true encaixa mesmo assim). meet: true gera o link do Google Meet; com attendees o Google envia o convite por e-mail; contact_id vincula ao contato do hub (o mesmo das conversas). Emite calendar.event.created.
Headers: Authorization · X-Subaccount-Id · Idempotency-Key
| Campo | Tipo | Descrição |
|---|---|---|
start | ISO 8601 com offset | |
end | ISO 8601 com offset | |
title | string | |
description | string, opcional | |
location | string, opcional | |
contact_id | string, opcional | contato da mesma subconta |
attendees | lista de {email, name}, opcional | |
meet | boolean | padrão false |
reminders | lista de {method: popup | email, minutes}, opcional | |
allow_overlap | boolean | padrão false |
tz | IANA, opcional | padrão: fuso da agenda |
POST /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: agendamento-mtyomamc
{
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza"
}
],
"meet": true,
"reminders": [
{
"method": "popup",
"minutes": 30
}
]
}
HTTP 201
{
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045163\"",
"updated_at": "2026-09-12T17:51:44.516Z"
}Idempotência, slot ocupado e payload inválido
POST /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: agendamento-mtyomamc
{
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"title": "Consulta · Ana Souza"
}
HTTP 200
{
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045163\"",
"updated_at": "2026-09-12T17:51:44.516Z"
}POST /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"start": "2026-09-14T10:00:00-03:00",
"end": "2026-09-14T10:30:00-03:00",
"title": "Encaixe"
}
HTTP 409
{
"error": "calendar conflict",
"detail": "time slot is busy",
"code": "calendar_conflict",
"retryable": false,
"details": {
"busy": [
{
"start": "2026-09-14T13:00:00Z",
"end": "2026-09-14T14:00:00Z"
}
]
}
}POST /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"start": "2026-09-14T10:00:00-03:00",
"end": "2026-09-14T09:00:00-03:00",
"title": "x"
}
HTTP 422
{
"error": "validation failed",
"code": "invalid_payload",
"retryable": false,
"details": {
"formErrors": [],
"fieldErrors": {
"end": [
"end must be after start"
]
}
}
}/v1/calendars/{id}/eventsListar compromissos
Lidos do Google (cache de 60 s), com singleEvents: recorrências vêm expandidas. ?from e ?to (padrão: agora e +30 dias, máx. 62). id é o id do hub quando o compromisso é conhecido; null quando só existe no Google e ainda não foi sincronizado.
Headers: Authorization · X-Subaccount-Id
GET /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events?from=2026-09-14T00%3A00%3A00-03%3A00&to=2026-09-14T23%3A59%3A00-03%3A00
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045163\"",
"updated_at": "2026-09-12T17:51:44.516Z"
}
]
}Um compromisso
GET /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events/cev_7kKkwmrwH1xSDuXl
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045163\"",
"updated_at": "2026-09-12T17:51:44.516Z"
}/v1/calendars/{id}/events/{event_id}Remarcar ou editar
start e end vão juntos (remarcação, com checagem de conflito). O hub manda If-Match com o etag conhecido: se o compromisso mudou no Google desde a última leitura, 409 calendar_conflict com retryable: true (releia e tente de novo). Emite calendar.event.updated.
Headers: Authorization · X-Subaccount-Id
PATCH /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events/cev_7kKkwmrwH1xSDuXl
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"start": "2026-09-14T15:00:00-03:00",
"end": "2026-09-14T15:30:00-03:00"
}
HTTP 200
{
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T15:00:00-03:00",
"end": "2026-09-14T15:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045504\"",
"updated_at": "2026-09-12T17:51:44.550Z"
}/v1/calendars/{id}/events/{event_id}Cancelar
Cancela no Google (convidados são avisados) e no hub. Emite calendar.event.cancelled. Cancelar de novo devolve 404.
Headers: Authorization · X-Subaccount-Id
DELETE /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events/cev_7kKkwmrwH1xSDuXl
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"start": "2026-09-14T15:00:00-03:00",
"end": "2026-09-14T15:30:00-03:00",
"tz": "America/Sao_Paulo",
"status": "cancelled",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub"
}Cancelar de novo
DELETE /v1/calendars/cmtyomeyr005xn5s1mebbmvqa/events/cev_7kKkwmrwH1xSDuXl
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 404
{
"error": "not found",
"detail": "event not found or already cancelled",
"code": "not_found",
"retryable": false
}Mudanças feitas direto no Google
Para cada agenda marcada o hub mantém uma notificação de mudanças (push) no Google e sincroniza de forma incremental. Quando a clínica remarca ou cancela na própria agenda, o integrador recebe calendar.event.updated ou calendar.event.cancelled com changed_by: "external", e um compromisso criado por lá chega como calendar.event.created. É assim que o bot fica sabendo que precisa avisar o paciente. Compromissos criados pela API chegam com changed_by: "hub". A primeira carga de uma agenda recém-conectada não gera eventos (seria o histórico inteiro).
{
"id": "cmtyomf1l0065n5s1jslwlwxp",
"event": "calendar.event.updated",
"created_at": 1789235504,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"data": {
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"event": {
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T15:00:00-03:00",
"end": "2026-09-14T15:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045504\"",
"updated_at": "2026-09-12T17:51:44.550Z"
},
"changed_by": "hub"
}
}
05Mensagens
/v1/messagesEnviar mensagem
Responde 202: a mensagem foi enfileirada. O envio real e os recibos chegam por webhook message.status (sent, delivered, read ou failed). Use Idempotency-Key em todo envio: em caso de timeout ou retry do seu lado, repetir a chamada devolve 200 com a mesma message_id.
Headers: Authorization · X-Subaccount-Id · Idempotency-Key
| Campo | Tipo | Descrição |
|---|---|---|
channel_id | string, obrigatório | Canal da conta |
to | string, obrigatório | WhatsApp: E.164 sem + (5511977776666). Instagram e Messenger: o from.external_id recebido em message.received; não é possível iniciar conversa com quem nunca escreveu |
type | text | media | template | |
text | {body} | Até 4096 caracteres |
media | {url, kind, caption?} | kind: image, document, audio, video. URL pública; o provider baixa |
template | {name, language, params?} | Só WhatsApp. params por posição: {"1": "…"} |
POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: seuapp-msg-000123
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511977776666",
"type": "text",
"text": {
"body": "Olá Ana! Temos horário amanhã às 14h. Confirma?"
}
}
HTTP 202
{
"message_id": "cmtyomcjs003mn5s1stirn6bw",
"status": "queued"
}Mídia, template, Instagram, Messenger e a mesma Idempotency-Key
POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: seuapp-msg-000124
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511977776666",
"type": "media",
"media": {
"kind": "document",
"url": "https://app.seuapp.com.br/arquivos/orcamento-ana.pdf",
"caption": "Seu orçamento"
}
}
HTTP 202
{
"message_id": "cmtyomcok003sn5s11qjzqf3p",
"status": "queued"
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: seuapp-msg-000125
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511966665555",
"type": "template",
"template": {
"name": "confirmacao_consulta",
"language": "pt_BR",
"params": {
"1": "Bruno",
"2": "10/09",
"3": "10:30"
}
}
}
HTTP 202
{
"message_id": "cmtyomcp10041n5s1nptpj62z",
"status": "queued"
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: seuapp-msg-000126
{
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"to": "5891234567890123",
"type": "text",
"text": {
"body": "Fazemos sim! Quer agendar uma avaliação?"
}
}
HTTP 202
{
"message_id": "cmtyomcpb0047n5s10a05ui59",
"status": "queued"
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: seuapp-msg-000127
{
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"to": "7423981234567890",
"type": "text",
"text": {
"body": "A limpeza sai por R$ 180."
}
}
HTTP 202
{
"message_id": "cmtyomcpm004dn5s15nvze5dv",
"status": "queued"
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
Idempotency-Key: seuapp-msg-000123
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511977776666",
"type": "text",
"text": {
"body": "Olá Ana! Temos horário amanhã às 14h. Confirma?"
}
}
HTTP 200
{
"message_id": "cmtyomcjs003mn5s1stirn6bw",
"status": "queued"
}| Resposta | Quando |
|---|---|
202 {message_id, status: "queued"} | Enfileirada |
200 {message_id, status} | Mesma Idempotency-Key de uma mensagem já aceita |
422 window_closed | Janela de 24h fechada. WhatsApp: envie template. Instagram e Messenger: aguarde o contato escrever |
409 channel_disconnected | Canal desconectado |
404 channel_not_found | channel_id não pertence à conta da chamada |
422 invalid_payload | Corpo inválido; detail ou details apontam o campo |
Erros, como o hub devolve
POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511955554444",
"type": "text",
"text": {
"body": "Olá!"
}
}
HTTP 422
{
"error": "customer service window closed",
"detail": "fora da janela de 24h só é permitido enviar template aprovado",
"code": "window_closed",
"retryable": false
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"to": "1122334455667788",
"type": "text",
"text": {
"body": "Olá!"
}
}
HTTP 422
{
"error": "customer service window closed",
"detail": "só é possível responder até 24h após a última mensagem do usuário neste canal",
"code": "window_closed",
"retryable": false
}POST /v1/messages
Authorization: Bearer shk_…
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511977776666",
"type": "text",
"text": {
"body": "Olá!"
}
}
HTTP 404
{
"error": "channel not found",
"code": "channel_not_found",
"retryable": false
}POST /v1/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"channel_id": "cmtyombh50013n5s136btsszl",
"to": "5511977776666",
"type": "text"
}
HTTP 422
{
"error": "validation failed",
"detail": "text required",
"code": "invalid_payload",
"retryable": false
}/v1/messages/{id}Consultar mensagem
Status atual e id da mensagem no provider (wamid no WhatsApp, mid no Instagram e Messenger). Timeline: queued → sent → delivered → read, ou failed com error.
Headers: Authorization · X-Subaccount-Id
GET /v1/messages/cmtyomcjs003mn5s1stirn6bw
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyomcjs003mn5s1stirn6bw",
"direction": "outbound",
"type": "text",
"status": "sent",
"error": null,
"provider_message_id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xvdXQx",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5",
"channel_id": "cmtyombh50013n5s136btsszl",
"created_at": "2026-09-12T17:51:41.321Z",
"updated_at": "2026-09-12T17:51:41.327Z"
}Conversas
Os webhooks são a fonte primária. Estas rotas permitem reconstruir o estado a qualquer momento: montar uma caixa de entrada, recuperar de eventos perdidos ou conferir a janela de 24h.
/v1/conversationsListar conversas
Ordenadas pela última mensagem. window_open e window_expires_at dizem se o canal aceita mensagem livre agora: use para habilitar ou não o campo de resposta livre na sua UI. Filtros: ?channel_id, ?limit (padrão 50, máx. 100).
Headers: Authorization · X-Subaccount-Id
GET /v1/conversations?limit=10
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomcq6004pn5s1z8jj7x6m",
"channel": {
"id": "cmtyombho0019n5s1jeacwlaq",
"type": "instagram",
"display_name": "clinicasorriso"
},
"contact": {
"id": "cmtyomcq2004nn5s1r7lszvyw",
"name": null,
"external_id": "1122334455667788"
},
"window_open": false,
"window_expires_at": null,
"last_message_at": "2026-09-12T17:51:41.550Z",
"last_message": null
},
{
"id": "cmtyomcpx004kn5s1dvpamyen",
"channel": {
"id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"display_name": "Clínica Sorriso"
},
"contact": {
"id": "cmtyomcpt004gn5s13hdfs51w",
"name": null,
"external_id": "5511955554444"
},
"window_open": false,
"window_expires_at": null,
"last_message_at": "2026-09-12T17:51:41.541Z",
"last_message": null
},
{
"id": "cmtyombmj002yn5s10jxhs478",
"channel": {
"id": "cmtyombi2001dn5s166wsq7hd",
"type": "messenger",
"display_name": "Clínica Sorriso"
},
"contact": {
"id": "cmtyombmf002en5s1cqq9wzlp",
"name": "Carlos Pereira",
"external_id": "7423981234567890"
},
"window_open": true,
"window_expires_at": "2026-09-13T16:57:09.000Z",
"last_message_at": "2026-09-12T17:51:41.528Z",
"last_message": {
"direction": "outbound",
"status": "sent",
"preview": "A limpeza sai por R$ 180.",
"created_at": "2026-09-12T17:51:41.531Z"
}
},
{
"id": "cmtyombmo0034n5s155id8ced",
"channel": {
"id": "cmtyombho0019n5s1jeacwlaq",
"type": "instagram",
"display_name": "clinicasorriso"
},
"contact": {
"id": "cmtyombmi002nn5s1b3abz0zr",
"name": "Mariana Lopes",
"external_id": "5891234567890123"
},
"window_open": true,
"window_expires_at": "2026-09-13T16:54:59.000Z",
"last_message_at": "2026-09-12T17:51:41.516Z",
"last_message": {
"direction": "outbound",
"status": "sent",
"preview": "Fazemos sim! Quer agendar uma avaliação?",
"created_at": "2026-09-12T17:51:41.520Z"
}
},
{
"id": "cmtyomcoy003zn5s1s43sb7oy",
"channel": {
"id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"display_name": "Clínica Sorriso"
},
"contact": {
"id": "cmtyomcou003vn5s1iuzgu92e",
"name": null,
"external_id": "5511966665555"
},
"window_open": false,
"window_expires_at": null,
"last_message_at": "2026-09-12T17:51:41.507Z",
"last_message": {
"direction": "outbound",
"status": "sent",
"preview": "[template confirmacao_consulta]",
"created_at": "2026-09-12T17:51:41.510Z"
}
},
{
"id": "cmtyombm80028n5s1j2qcqkn5",
"channel": {
"id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"display_name": "Clínica Sorriso"
},
"contact": {
"id": "cmtyombm5001rn5s1q3jtveqa",
"name": "Ana Souza",
"external_id": "5511977776666"
},
"window_open": true,
"window_expires_at": "2026-09-13T16:53:39.000Z",
"last_message_at": "2026-09-12T17:51:41.490Z",
"last_message": {
"direction": "outbound",
"status": "sent",
"preview": "Seu orçamento",
"created_at": "2026-09-12T17:51:41.492Z"
}
}
]
}/v1/conversations/{id}/messagesLer a thread
Em ordem cronológica, até 200 por chamada (?limit, padrão 100). text é uma prévia legível para qualquer tipo de mensagem. provider_message_id e created_at identificam e datam a mensagem na Meta. 404 se a conversa não for da conta.
Headers: Authorization · X-Subaccount-Id
GET /v1/conversations/cmtyomcpx004kn5s1dvpamyen/messages
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"conversation": {
"id": "cmtyomcpx004kn5s1dvpamyen",
"channel": {
"id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"display_name": "Clínica Sorriso"
},
"contact": {
"id": "cmtyomcpt004gn5s13hdfs51w",
"name": null,
"external_id": "5511955554444"
},
"window_open": false,
"window_expires_at": null
},
"data": []
}/v1/conversations/{id}/syncSincronizar histórico do Instagram ou Messenger
Consulta a API de Conversas da Meta para recuperar respostas enviadas pelo aplicativo do Instagram, Facebook ou Messenger. Responde {conversation_id, scanned, imported}. O Hub também reconcilia conversas recentes em segundo plano; mensagens novas saem em message.echo e aparecem na thread como outbound. A sincronização é idempotente pelo ID da Meta.
Headers: Authorization · X-Subaccount-Id
06Templates (WhatsApp)
Templates pertencem à conta do WhatsApp Business (WABA) do cliente final, não ao número. A aprovação é da Meta e é assíncrona: criar devolve PENDING e o resultado chega pelo evento template.status; uma reclassificação chega por template.category_changed. Nome e idioma identificam o template no envio. Categorias: UTILITY, MARKETING, AUTHENTICATION. Quando a conta tem números em WABAs diferentes, indique channel_id para escolher a WABA.
/v1/templatesListar templates
Filtros: ?status, ?language, ?name, ?limit, ?after (cursor devolvido em next_cursor), ?channel_id.
Headers: Authorization · X-Subaccount-Id
GET /v1/templates
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"name": "confirmacao_consulta",
"language": "pt_BR",
"status": "APPROVED",
"category": "UTILITY",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, sua consulta está confirmada para {{2}} às {{3}}."
}
],
"quality_score": "GREEN"
},
{
"name": "promo_clareamento",
"language": "pt_BR",
"status": "REJECTED",
"category": "MARKETING",
"components": [
{
"type": "BODY",
"text": "Clareamento com 30% off só hoje!"
}
],
"rejected_reason": "INVALID_FORMAT"
}
]
}/v1/templatesCriar template e submeter à aprovação
components segue o formato da Meta (HEADER, BODY, FOOTER, BUTTONS, com example para variáveis). O hub valida só o type de cada componente e repassa a recusa da Meta em detail. A Meta pode reclassificar a categoria já na submissão.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
name | string | minúsculas, números e _ |
language | string | ex. pt_BR |
category | string | UTILITY | MARKETING | AUTHENTICATION |
components | array | formato da Meta |
channel_id | string, opcional | qual WABA, se houver mais de uma |
POST /v1/templates
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"name": "lembrete_retorno",
"language": "pt_BR",
"category": "UTILITY",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, faz {{2}} meses da sua última consulta. Vamos agendar seu retorno?",
"example": {
"body_text": [
[
"Ana",
"6"
]
]
}
}
]
}
HTTP 201
{
"name": "lembrete_retorno",
"language": "pt_BR",
"category": "UTILITY",
"status": "PENDING"
}/v1/templates/{name}Detalhar template
Todas as línguas do nome, com status e motivo de rejeição por língua.
Headers: Authorization · X-Subaccount-Id
GET /v1/templates/confirmacao_consulta
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"name": "confirmacao_consulta",
"data": [
{
"name": "confirmacao_consulta",
"language": "pt_BR",
"status": "APPROVED",
"category": "UTILITY",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, sua consulta está confirmada para {{2}} às {{3}}."
}
],
"quality_score": "GREEN"
}
]
}/v1/templates/{name}Editar template
Corpo {language, components?, category?}. A Meta só aceita edição em APPROVED, REJECTED ou PAUSED, e o template volta a PENDING.
Headers: Authorization · X-Subaccount-Id
PATCH /v1/templates/promo_clareamento
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"language": "pt_BR",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, clareamento com 30% de desconto até {{2}}.",
"example": {
"body_text": [
[
"Ana",
"30/09"
]
]
}
}
]
}
HTTP 200
{
"name": "promo_clareamento",
"language": "pt_BR",
"status": "PENDING",
"category": "MARKETING",
"components": [
{
"type": "BODY",
"text": "Olá {{1}}, clareamento com 30% de desconto até {{2}}.",
"example": {
"body_text": [
[
"Ana",
"30/09"
]
]
}
}
],
"rejected_reason": "INVALID_FORMAT"
}/v1/templates/{name}Apagar template
Com ?language apaga só aquela língua; sem, apaga todas. A Meta bloqueia o nome por 30 dias para recriação.
Headers: Authorization · X-Subaccount-Id
DELETE /v1/templates/promo_clareamento?language=pt_BR
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"deleted": "promo_clareamento",
"language": "pt_BR"
}Os eventos de template
{
"name": "promo_clareamento",
"language": "pt_BR",
"status": "REJECTED",
"reason": "INVALID_FORMAT",
"timestamp": 1789231899000
}{
"name": "lembrete_retorno",
"language": "pt_BR",
"from": "UTILITY",
"to": "MARKETING",
"effective_at": 1789232499000
}07Mídia
Mídia recebida pelo WhatsApp chega em message.received com um bloco media. O media_id é do hub (med_…) e vale por 90 dias: o identificador do provider expira em poucos dias, o nosso não, então reprocessar um evento antigo continua funcionando. O download roda em fila; enquanto o hub ainda não terminou de guardar o arquivo, a rota serve direto do provider.
[
{
"media_id": "med_ed9beb3f9d338289f883a7271b29124d",
"url": "https://hub.sociosai.com/v1/media/med_ed9beb3f9d338289f883a7271b29124d",
"mime_type": "image/jpeg",
"filename": null,
"duration_ms": null,
"provider_media_id": "1234567890123456"
}
]/v1/media/{mediaId}Baixar mídia recebida
Devolve o arquivo em streaming, com Content-Type do original e Content-Disposition com o nome, quando houver. Usa a mesma autenticação da API, incluindo X-Subaccount-Id da conta dona do canal (sem ele, 404). Depois da retenção, ou se o download falhou de vez, responde 410.
Headers: Authorization · X-Subaccount-Id
GET /v1/media/med_ed9beb3f9d338289f883a7271b29124d
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
content-type: image/jpeg
content-length: 22
cache-control: private, max-age=300
(22 bytes)GET /v1/media/med_inexistente
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 404
{
"error": "not found",
"detail": "media not found",
"code": "not_found",
"retryable": false
}/v1/media/{channelId}/{mediaId}Baixar pelo id do provider (legado)
Proxy direto no provider com o token do canal: mediaId é o id da Meta (WhatsApp) ou o file_id (Telegram). Expira junto com o provider. Prefira o id estável acima.
Headers: Authorization · X-Subaccount-Id
GET /v1/media/cmtyombh50013n5s136btsszl/1234567890123456
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
content-type: image/jpeg
(22 bytes)Instagram e Messenger não passam pelo proxy: o anexo vem com a URL do CDN da Meta em content.attachments[].payload.url, que expira em poucas horas. Quem quiser guardar, baixa na hora do evento.
08Webhooks
Os eventos são entregues por conta: registre uma assinatura para cada subconta (com X-Subaccount-Id), apontando para a URL que você quiser. A recomendação é uma única URL para todas as subcontas: o envelope traz subaccount_id, e você resolve o secret por ele. Assim configuração nova é só uma linha na sua tabela de secrets, e uma URL trocada por engano vira erro de assinatura no primeiro evento em vez de silêncio.
Registro e ciclo de vida
/v1/webhooksRegistrar assinatura
A URL precisa ser https://. O secret devolvido assina todas as entregas desta assinatura e aparece só nesta resposta. Nomes de evento fora do catálogo (GET /v1/me → events) são recusados com 422 e details.unknown_events.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
url | string, obrigatório | Endpoint que receberá os POSTs. https obrigatório |
events | array de string, opcional | Filtro. ["*"] (padrão) recebe tudo. Ex.: ["message.received","message.status"] |
POST /v1/webhooks
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"*"
]
}
HTTP 201
{
"id": "cmtyombbl000fn5s1mprslwma",
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"*"
],
"active": true,
"created_at": "2026-09-12T17:51:39.730Z",
"updated_at": "2026-09-12T17:51:39.730Z",
"secret": "whsec_lmBItBN9tXILOJq9MIvev82V7lSgIgdE"
}Os dois erros de validação
POST /v1/webhooks
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"message.received",
"message.nope"
]
}
HTTP 422
{
"error": "validation failed",
"detail": "unknown events: message.nope",
"code": "invalid_payload",
"retryable": false,
"details": {
"unknown_events": [
"message.nope"
],
"known_events": [
"message.received",
"message.status",
"message.echo",
"channel.connected",
"channel.reconnected",
"channel.disconnected",
"channel.health_changed",
"template.status",
"template.category_changed",
"history.completed",
"lead.received",
"calendar.connected",
"calendar.reconnected",
"calendar.disconnected",
"calendar.event.created",
"calendar.event.updated",
"calendar.event.cancelled",
"booking.created",
"booking.rescheduled",
"booking.cancelled",
"booking.confirmed",
"booking.reschedule_requested",
"booking.reminder_sent",
"booking.reminder_failed",
"webhook.test"
]
}
}POST /v1/webhooks
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"url": "http://api.seuapp.com.br/hub/eventos"
}
HTTP 422
{
"error": "validation failed",
"code": "invalid_payload",
"retryable": false,
"details": {
"formErrors": [],
"fieldErrors": {
"url": [
"Invalid input: must start with \"https://\""
]
}
}
}/v1/webhooksListar assinaturas
Sem o secret. Assinaturas desativadas continuam na lista com active: false.
Headers: Authorization · X-Subaccount-Id
GET /v1/webhooks
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyombbl000fn5s1mprslwma",
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"message.received",
"message.status",
"channel.disconnected"
],
"active": false,
"created_at": "2026-09-12T17:51:39.730Z",
"updated_at": "2026-09-12T17:51:39.759Z"
},
{
"id": "cmtyombcp000pn5s10dhh8vj2",
"url": "http://127.0.0.1:35445/hub/eventos",
"events": [
"*"
],
"active": true,
"created_at": "2026-09-12T17:51:39.770Z",
"updated_at": "2026-09-12T17:51:39.770Z"
}
]
}/v1/webhooks/{id}Detalhe da assinatura
Sem o secret. 404 se for de outra conta.
Headers: Authorization · X-Subaccount-Id
GET /v1/webhooks/cmtyombbl000fn5s1mprslwma
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyombbl000fn5s1mprslwma",
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"*"
],
"active": true,
"created_at": "2026-09-12T17:51:39.730Z",
"updated_at": "2026-09-12T17:51:39.730Z"
}Assinatura inexistente
GET /v1/webhooks/cm00000000000000000000000
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 404
{
"error": "not found",
"detail": "webhook not found",
"code": "not_found",
"retryable": false
}/v1/webhooks/{id}Editar assinatura
Troca url, events e/ou active. active: false pausa as entregas: os eventos continuam sendo gravados (e consultáveis em GET /v1/events), mas nada é enviado; ping e reentrega respondem 409. Reative com active: true e reentregue o que ficou para trás.
Headers: Authorization · X-Subaccount-Id
| Campo | Tipo | Descrição |
|---|---|---|
url | string, opcional | https obrigatório |
events | array de string, opcional | |
active | boolean, opcional |
PATCH /v1/webhooks/cmtyombbl000fn5s1mprslwma
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"events": [
"message.received",
"message.status",
"channel.disconnected"
]
}
HTTP 200
{
"id": "cmtyombbl000fn5s1mprslwma",
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"message.received",
"message.status",
"channel.disconnected"
],
"active": true,
"created_at": "2026-09-12T17:51:39.730Z",
"updated_at": "2026-09-12T17:51:39.742Z"
}Pausar
PATCH /v1/webhooks/cmtyombbl000fn5s1mprslwma
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
{
"active": false
}
HTTP 200
{
"id": "cmtyombbl000fn5s1mprslwma",
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"message.received",
"message.status",
"channel.disconnected"
],
"active": false,
"created_at": "2026-09-12T17:51:39.730Z",
"updated_at": "2026-09-12T17:51:39.759Z"
}/v1/webhooks/{id}Desativar assinatura
Responde 204. A assinatura continua na listagem com active: false e as entregas passadas seguem consultáveis.
Headers: Authorization · X-Subaccount-Id
DELETE /v1/webhooks/cmtyombbl000fn5s1mprslwma
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 204/v1/webhooks/{id}/rotate-secretTrocar o secret
O secret novo aparece só nesta resposta. Entregas já na fila saem assinadas com o secret vigente na hora do envio, então a ordem certa é: guardar o novo, aceitar os dois por alguns minutos, e só então descartar o antigo. Fica na trilha de auditoria como webhook.secret_rotated.
Headers: Authorization · X-Subaccount-Id
POST /v1/webhooks/cmtyombbl000fn5s1mprslwma/rotate-secret
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 201
{
"id": "cmtyombbl000fn5s1mprslwma",
"url": "https://api.seuapp.com.br/hub/eventos",
"events": [
"message.received",
"message.status",
"channel.disconnected"
],
"active": true,
"created_at": "2026-09-12T17:51:39.730Z",
"updated_at": "2026-09-12T17:51:39.750Z",
"secret": "whsec_poql_Q6sc71pdE3ZEKyJ78E5Yx_-ZXY7"
}Testar o caminho: ping assinado
Antes de qualquer tráfego real, prove que a URL responde, que o HMAC fecha e que o seu endpoint devolve 2xx. O ping entra na mesma fila e sai com a mesma assinatura de um evento de verdade, dirigido à assinatura indicada mesmo que ela não liste webhook.test.
/v1/webhooks/{id}/testPing assinado
Responde 202 com o event_id e o delivery_id ({subscription_id}:{event_id}) para acompanhar. 409 se a assinatura estiver pausada.
Headers: Authorization · X-Subaccount-Id
POST /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/test
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 202
{
"event_id": "cmtyombcw000rn5s156s4594m",
"delivery_id": "cmtyombcp000pn5s10dhh8vj2:cmtyombcw000rn5s156s4594m",
"event": "webhook.test"
}O que chegou no endpoint
POST http://127.0.0.1:35445/hub/eventos
content-type: application/json
webhook-id: cmtyombcw000rn5s156s4594m
webhook-timestamp: 1789235499
webhook-signature: v1,iRQoy8/Mgrm72bjJm3Nm8o/c8ce2BoryJp0k5dKcMg0=
{
"id": "cmtyombcw000rn5s156s4594m",
"event": "webhook.test",
"created_at": 1789235499,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"data": {
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"message": "ping",
"requested_at": "2026-09-12T17:51:39.776Z"
}
}E o estado da entrega
GET /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/deliveries/cmtyombcp000pn5s10dhh8vj2:cmtyombcw000rn5s156s4594m
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyombcw000rn5s156s4594m",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"event_id": "cmtyombcw000rn5s156s4594m",
"event": "webhook.test",
"status": "delivered",
"attempts": 1,
"last_error": null,
"delivered_at": "2026-09-12T17:51:39.790Z",
"created_at": "2026-09-12T17:51:39.784Z"
}Ping em assinatura pausada
POST /v1/webhooks/cmtyombbl000fn5s1mprslwma/test
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 409
{
"error": "conflict",
"detail": "webhook is inactive",
"code": "conflict",
"retryable": false
}Entregas e reentrega
Cada evento gera uma entrega por assinatura. Falhou (resposta fora de 2xx ou demora acima de 10 s)? O hub tenta de novo com backoff exponencial a partir de 5 s, até 8 tentativas (cerca de 20 minutos). Depois disso a entrega fica dead: nada se perde, ela continua listada e pode ser reentregue por você.
/v1/webhooks/{id}/deliveriesEntregas de uma assinatura
Mais recentes primeiro. Filtros: ?status (pending | delivered | failed | dead), ?limit (padrão 50, máx. 200), ?before (paginação: valor de next_before da página anterior).
Headers: Authorization · X-Subaccount-Id
GET /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/deliveries?limit=3
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyome4i005ln5s1wdv4v415",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"event_id": "cmtyome4i005ln5s1wdv4v415",
"event": "channel.reconnected",
"status": "delivered",
"attempts": 1,
"last_error": null,
"delivered_at": "2026-09-12T17:51:43.371Z",
"created_at": "2026-09-12T17:51:43.369Z"
},
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyome13005fn5s1mzfhi2xl",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"event_id": "cmtyome13005fn5s1mzfhi2xl",
"event": "channel.disconnected",
"status": "delivered",
"attempts": 1,
"last_error": null,
"delivered_at": "2026-09-12T17:51:43.247Z",
"created_at": "2026-09-12T17:51:43.245Z"
},
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyomdwp005dn5s1l7lp0x8u",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"event_id": "cmtyomdwp005dn5s1l7lp0x8u",
"event": "history.completed",
"status": "delivered",
"attempts": 1,
"last_error": null,
"delivered_at": "2026-09-12T17:51:43.092Z",
"created_at": "2026-09-12T17:51:43.090Z"
}
],
"has_more": true,
"next_before": "2026-09-12T17:51:43.090Z"
}Só as mortas
GET /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/deliveries?status=dead
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"event_id": "cmtyombh90015n5s1fjpfzazv",
"event": "channel.connected",
"status": "dead",
"attempts": 8,
"last_error": "HTTP 503",
"delivered_at": "2026-09-12T17:51:39.946Z",
"created_at": "2026-09-12T17:51:39.942Z"
}
],
"has_more": false,
"next_before": null
}/v1/webhooks/{id}/deliveries/{delivery_id}Estado de uma entrega
attempts, last_error (HTTP recebido ou erro de rede) e delivered_at.
Headers: Authorization · X-Subaccount-Id
GET /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/deliveries/cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"event_id": "cmtyombh90015n5s1fjpfzazv",
"event": "channel.connected",
"status": "delivered",
"attempts": 9,
"last_error": null,
"delivered_at": "2026-09-12T17:51:44.291Z",
"created_at": "2026-09-12T17:51:39.942Z"
}/v1/webhooks/{id}/deliveries/{delivery_id}/redeliverReentregar um evento
Reenvia o mesmo evento (mesmo id, mesmo data) pela fila, a esta assinatura, inclusive uma entrega dead. Um receptor idempotente pelo webhook-id trata como duplicata, que é o comportamento certo. 409 se a assinatura estiver pausada; 410 event_expired quando o conteúdo já saiu pela retenção de 90 dias.
Headers: Authorization · X-Subaccount-Id
POST /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/deliveries/cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv/redeliver
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 202
{
"delivery_id": "cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv",
"event_id": "cmtyombh90015n5s1fjpfzazv",
"event": "channel.connected",
"status": "pending"
}Pausada e expurgado
POST /v1/webhooks/cmtyombbl000fn5s1mprslwma/deliveries/cmtyombbl000fn5s1mprslwma:cmtyombh90015n5s1fjpfzazv/redeliver
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 409
{
"error": "conflict",
"detail": "webhook is inactive",
"code": "conflict",
"retryable": false
}POST /v1/webhooks/cmtyombcp000pn5s10dhh8vj2/deliveries/cmtyombcp000pn5s10dhh8vj2:cmtyomdwb004vn5s1kc8flhtq/redeliver
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 410
{
"error": "event content expired by retention",
"detail": "event content was purged by retention; nothing to redeliver",
"code": "event_expired",
"retryable": false
}Entrega e assinatura
Cada evento é um POST com corpo JSON na URL da assinatura. Assim chegou uma mensagem de WhatsApp, com os headers exatos:
POST https://api.seuapp.com.br/hub/eventos
content-type: application/json
webhook-id: cmtyombm1001pn5s1graeqhqk
webhook-timestamp: 1789235500
webhook-signature: v1,4RtrNWozBewTBSRTeNBF1/YmJp1vqtbKSGxEnhVDDtw=
{
"id": "cmtyombm1001pn5s1graeqhqk",
"event": "message.received",
"created_at": 1789235500,
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"channel_id": "cmtyombh50013n5s136btsszl",
"data": {
"message_id": "cmtyombmc002an5s13k0wihcn",
"channel": "whatsapp",
"channel_id": "cmtyombh50013n5s136btsszl",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"from": {
"external_id": "5511977776666",
"externalId": "5511977776666",
"name": "Ana Souza"
},
"type": "text",
"timestamp": 1789231899000,
"content": {
"from": "5511977776666",
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xpbi10ZXh0",
"timestamp": "1789231899",
"type": "text",
"text": {
"body": "Oi, queria marcar uma avaliação."
}
}
}
}
| Campo do envelope | Significado |
|---|---|
id | Id do evento, igual ao header webhook-id. Chave de idempotência: a entrega é at-least-once, o mesmo evento pode chegar mais de uma vez (retentativa, reentrega manual). |
event | Tipo (catálogo na próxima seção). Tipos novos podem aparecer sem mudar a versão do contrato: trate desconhecido como no-op e responda 2xx. |
created_at | Epoch em segundos, momento da entrega. Numa retentativa vem um valor novo; o id não muda. |
subaccount_id | Conta dona do evento. Resolva o secret por ele e confira que bate com a rota em que o evento chegou. |
channel_id | Canal do evento, quando houver (ausente em message.status, template.* e webhook.test). |
data | Payload do evento. data.timestamp, quando existe, é epoch em milissegundos, hora do fato na Meta. |
- Resposta esperada: qualquer 2xx em até 10 segundos. Outra resposta, ou demora, conta como falha. Responda antes de processar.
- Ordem: não garantida entre eventos próximos. Recibos podem chegar fora de ordem (
readantes dedelivered); usedata.timestampe nunca rebaixe um status. - Filtro: a lista
eventsda assinatura decide o que é entregue;webhook.testignora o filtro.
Verificação
webhook-signature = "v1," + base64(HMAC-SHA256(secret, id + "." + timestamp + "." + corpo bruto)), onde secret é o da assinatura e o corpo bruto é o body exatamente como recebido, antes de qualquer parse. Rejeite timestamps com mais de 5 minutos de diferença. Referência em Node, com a resolução do secret por subaccount_id:
import { createHmac, timingSafeEqual } from 'node:crypto'
// secrets: Map<subaccount_id, secret> preenchido quando você registra cada assinatura
export function verifyHubWebhook(headers, rawBody, secrets) {
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
const sig = String(headers['webhook-signature'] ?? '') // "v1,<base64>"
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return null // anti-replay: 5 min
const envelope = JSON.parse(rawBody) // parse só para achar o secret
const secret = secrets.get(envelope.subaccount_id)
if (!secret) return null
const mac = createHmac('sha256', secret).update(`${id}.${ts}.${rawBody}`).digest('base64')
const expected = Buffer.from(`v1,${mac}`)
const received = Buffer.from(sig)
if (received.length !== expected.length || !timingSafeEqual(received, expected)) return null
return envelope // válido: processe pelo id
}
// Express: o corpo BRUTO é obrigatório para a assinatura fechar
app.post('/hub/eventos', express.raw({ type: 'application/json' }), async (req, res) => {
const env = verifyHubWebhook(req.headers, req.body.toString('utf8'), secrets)
if (!env) return res.status(401).end()
if (await jaProcessado(env.id)) return res.status(200).end() // at-least-once: duplicata é normal
await fila.add(env) // responda rápido; processe depois
res.status(200).end()
})
Mesma verificação em PHP
function verifyHubWebhook(array $headers, string $rawBody, callable $secretFor): ?array {
$id = $headers['webhook-id'] ?? '';
$ts = $headers['webhook-timestamp'] ?? '';
$sig = $headers['webhook-signature'] ?? '';
if (abs(time() - (int) $ts) > 300) return null;
$envelope = json_decode($rawBody, true);
$secret = $secretFor($envelope['subaccount_id'] ?? '');
if (!$secret) return null;
$mac = base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $secret, true));
return hash_equals("v1,$mac", $sig) ? $envelope : null;
}09Eventos
Catálogo completo do contrato 1.3, com o data real de cada um. GET /v1/me devolve esta mesma lista em events.
| event | Quando | data |
|---|---|---|
message.received | Um contato escreveu (WhatsApp, Instagram, Messenger) | message_id, channel, channel_id, conversation_id, contact_id, from{{external_id, name?}}, type, timestamp, content, media?[] |
message.status | Recibo de mensagem enviada | message_id, status, timestamp?, error? |
message.echo | Mensagem enviada pelo aplicativo WhatsApp Business, Instagram ou Messenger | message_id, provider_message_id, channel, channel_id, conversation_id, contact_id, to{{external_id}}, type, timestamp, content, source |
channel.connected | Wizard concluído (ou canal criado pela API) | channel_id, type, display_name, coexistence? |
channel.reconnected | Canal desconectado foi reconectado (mesmo channel_id) | channel_id, type, display_name, coexistence? |
channel.disconnected | Desconexão pela API (initiated_by: "tenant") ou pela Meta ("provider", com o motivo) | channel_id, type, display_name, reason, initiated_by |
channel.health_changed | Qualidade, limite de mensagens ou cobrança do número mudaram na Meta | channel_id, type, health{{quality_rating?, messaging_limit?, billing_ok?, updated_at}} |
template.status | Template aprovado, rejeitado, pausado | name, language, status, reason?, timestamp |
template.category_changed | A Meta reclassificou a categoria (antes de valer) | name, language, from, to, effective_at |
history.completed | Import do histórico do app (coexistência) terminou | channel_id, chunks |
webhook.test | Ping de POST /v1/webhooks/{{id}}/test | subscription_id, message: "ping", requested_at |
WhatsApp message.received, texto
{
"message_id": "cmtyombmc002an5s13k0wihcn",
"channel": "whatsapp",
"channel_id": "cmtyombh50013n5s136btsszl",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"from": {
"external_id": "5511977776666",
"externalId": "5511977776666",
"name": "Ana Souza"
},
"type": "text",
"timestamp": 1789231899000,
"content": {
"from": "5511977776666",
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xpbi10ZXh0",
"timestamp": "1789231899",
"type": "text",
"text": {
"body": "Oi, queria marcar uma avaliação."
}
}
}
content é a mensagem crua da Meta. Para texto, content.text.body. type segue o da Meta: text, image, audio, video, document, sticker, location, contacts, button, interactive, reaction.
WhatsApp message.received, imagem (com bloco media)
{
"message_id": "cmtyombmg002ln5s1xwu8ppga",
"channel": "whatsapp",
"channel_id": "cmtyombh50013n5s136btsszl",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"from": {
"external_id": "5511977776666",
"externalId": "5511977776666",
"name": "Ana Souza"
},
"type": "image",
"timestamp": 1789231959000,
"content": {
"from": "5511977776666",
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xpbi1pbWFnZQ",
"timestamp": "1789231959",
"type": "image",
"image": {
"mime_type": "image/jpeg",
"sha256": "e3b0c442",
"id": "1234567890123456",
"caption": "Foto do orçamento anterior"
}
},
"media": [
{
"media_id": "med_ed9beb3f9d338289f883a7271b29124d",
"url": "https://hub.sociosai.com/v1/media/med_ed9beb3f9d338289f883a7271b29124d",
"mime_type": "image/jpeg",
"filename": null,
"duration_ms": null,
"provider_media_id": "1234567890123456"
}
]
}
WhatsApp message.received, clique em botão de template
{
"message_id": "cmtyombmj002tn5s1tg09pdlw",
"channel": "whatsapp",
"channel_id": "cmtyombh50013n5s136btsszl",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"from": {
"external_id": "5511977776666",
"externalId": "5511977776666",
"name": "Ana Souza"
},
"type": "button",
"timestamp": 1789232019000,
"content": {
"from": "5511977776666",
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xpbi1idXR0b24",
"timestamp": "1789232019",
"type": "button",
"button": {
"text": "Confirmar",
"payload": "CONFIRMAR"
},
"context": {
"from": "5511930010001",
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xvdXQtdGVtcGxhdGUtYW50ZXJpb3I"
}
}
}
content.context.id é o wamid da mensagem enviada que continha o botão.
WhatsApp message.echo (coexistência)
{
"message_id": "cmtyombmo0030n5s1jcvmlyqa",
"channel": "whatsapp",
"channel_id": "cmtyombh50013n5s136btsszl",
"conversation_id": "cmtyombm80028n5s1j2qcqkn5",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"to": {
"external_id": "5511977776666",
"externalId": "5511977776666"
},
"type": "text",
"timestamp": 1789232079000,
"content": {
"from": "5511930010001",
"to": "5511977776666",
"id": "wamid.HBgNNTUxMTk3Nzc3NjY2NnxtdHlvbWFtY3xlY2hv",
"timestamp": "1789232079",
"type": "text",
"text": {
"body": "Claro, Ana! Tem horário amanhã às 14h."
}
},
"media": [],
"source": "business_app"
}
Não abre janela de 24h: é o próprio dono do número falando, por outro aparelho. Aparece na thread como mensagem outbound.
Instagram e Messenger também emitem message.echo para respostas feitas no aplicativo (source: "instagram_app" ou "messenger_app"). Ecos de mensagens já enviadas pela API do Hub são ignorados pelo ID da Meta.
Instagram message.received
{
"message_id": "cmtyombms0036n5s1ttuq2rmp",
"channel": "instagram",
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"conversation_id": "cmtyombmo0034n5s155id8ced",
"contact_id": "cmtyombmi002nn5s1b3abz0zr",
"from": {
"external_id": "5891234567890123",
"externalId": "5891234567890123",
"name": "Mariana Lopes"
},
"type": "text",
"timestamp": 1789232099000,
"content": {
"mid": "m_bXR5b21hbWN8aWcx",
"text": "Vocês fazem clareamento?"
}
}
No Instagram e no Messenger o texto fica em content.text (string). from.name vem do perfil e pode faltar quando a Meta não o expõe.
Messenger message.received, anexo
{
"message_id": "cmtyomcgy003gn5s1vtznwvom",
"channel": "messenger",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"conversation_id": "cmtyombmj002yn5s10jxhs478",
"contact_id": "cmtyombmf002en5s1cqq9wzlp",
"from": {
"external_id": "7423981234567890",
"externalId": "7423981234567890",
"name": "Carlos Pereira"
},
"type": "image",
"timestamp": 1789232199000,
"content": {
"mid": "m_bXR5b21hbWN8ZmIy",
"attachments": [
{
"type": "image",
"payload": {
"url": "https://scontent.xx.fbcdn.net/v/t1.15752-9/exemplo.jpg?oh=…"
}
}
]
}
}
Messenger message.received, postback
{
"message_id": "cmtyomch0003in5s1jakmj2np",
"channel": "messenger",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"conversation_id": "cmtyombmj002yn5s10jxhs478",
"contact_id": "cmtyombmf002en5s1cqq9wzlp",
"from": {
"external_id": "7423981234567890",
"externalId": "7423981234567890",
"name": "Carlos Pereira"
},
"type": "postback",
"timestamp": 1789232229000,
"content": {
"title": "Começar",
"payload": "GET_STARTED"
}
}
message.status
{
"message_id": "cmtyomcjs003mn5s1stirn6bw",
"status": "sent"
}
{
"message_id": "cmtyomcjs003mn5s1stirn6bw",
"status": "delivered",
"timestamp": 1789232299000
}
{
"message_id": "cmtyomcjs003mn5s1stirn6bw",
"status": "read",
"timestamp": 1789232359000
}
status é lista fechada: sent, delivered, read, failed. Em failed vem error com o motivo da Meta (ex.: 131047, janela fechada). timestamp falta no sent (é o hub confirmando o envio) e é a hora da Meta nos demais. A ordem de chegada não é garantida: read pode chegar antes de delivered.
channel.connected, channel.reconnected e channel.disconnected
{
"channel_id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"display_name": "Clínica Sorriso",
"coexistence": true
}
{
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"type": "messenger",
"display_name": "Clínica Sorriso"
}
{
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"type": "messenger",
"display_name": "Clínica Sorriso",
"reason": "cliente_cancelou",
"initiated_by": "tenant"
}
channel.health_changed
{
"channel_id": "cmtyombh50013n5s136btsszl",
"type": "whatsapp",
"health": {
"quality_rating": "FLAGGED",
"messaging_limit": "TIER_1K",
"updated_at": "2026-09-12T17:51:43.068Z"
}
}
health é o estado mesclado: cada webhook da Meta traz um pedaço (qualidade, limite, cobrança) e o hub só emite quando algo mudou de fato. billing_ok: false é o caso clássico de "tudo parece certo e nada sai": o cliente conectou e não cadastrou meio de pagamento na Meta. Se a Meta desativar o número, chega também um channel.disconnected com initiated_by: "provider" e o motivo.
template.status e template.category_changed
{
"name": "lembrete_retorno",
"language": "pt_BR",
"status": "APPROVED",
"timestamp": 1789231899000
}
{
"name": "lembrete_retorno",
"language": "pt_BR",
"from": "UTILITY",
"to": "MARKETING",
"effective_at": 1789232499000
}
history.completed
{
"channel_id": "cmtyombh50013n5s136btsszl",
"chunks": 1
}
Coexistência: a Meta importa o histórico recente do app após o onboarding; quando termina, o hub avisa e a thread fica disponível em /v1/conversations.
calendar.connected, calendar.reconnected e calendar.disconnected
{
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"calendars": [
{
"id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "clinica.sorriso@example.com",
"name": "Clínica Sorriso",
"primary": true,
"timezone": "America/Sao_Paulo",
"use_for_availability": true
},
{
"id": "cmtyomeyt005zn5s1na0uw8m1",
"external_id": "c_8f2a1b@group.calendar.google.com",
"name": "Sala 1 · Dra. Ana",
"primary": false,
"timezone": "America/Sao_Paulo",
"use_for_availability": false
}
]
}
{
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"provider": "google_calendar",
"email": "clinica.sorriso@example.com",
"reason": "user"
}
reason em calendar.disconnected: user (desconectada pela API) ou invalid_grant (o token morreu no Google; as chamadas passam a responder 403 calendar_needs_reauth até uma nova sessão com reconnect_account_id).
calendar.event.created, calendar.event.updated e calendar.event.cancelled
{
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"event": {
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"title": "Consulta · Ana Souza",
"description": "Retorno de avaliação",
"location": null,
"start": "2026-09-14T14:00:00-03:00",
"end": "2026-09-14T14:30:00-03:00",
"tz": "America/Sao_Paulo",
"all_day": false,
"status": "confirmed",
"meet_url": "https://meet.google.com/im1-0ew9-tyw",
"html_link": "https://www.google.com/calendar/event?eid=im10ew9tyw1jmyi",
"attendees": [
{
"email": "ana.souza@example.com",
"name": "Ana Souza",
"response": null
}
],
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub",
"etag": "\"17892355045163\"",
"updated_at": "2026-09-12T17:51:44.516Z"
},
"changed_by": "hub"
}
{
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh",
"event": {
"id": "cev_7kKkwmrwH1xSDuXl",
"calendar_id": "cmtyomeyr005xn5s1mebbmvqa",
"external_id": "im10ew9tyw1jmyi",
"start": "2026-09-14T15:00:00-03:00",
"end": "2026-09-14T15:30:00-03:00",
"tz": "America/Sao_Paulo",
"status": "cancelled",
"contact_id": "cmtyombm5001rn5s1q3jtveqa",
"source": "hub"
},
"changed_by": "hub"
}
changed_by diz de onde veio a mudança: hub (pela API) ou external (direto no Google). event é o compromisso como em GET /v1/calendars/{id}/events/{event_id}; em cancelled vem reduzido (id, horário, status, contato).
webhook.test
{
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"message": "ping",
"requested_at": "2026-09-12T17:51:39.776Z"
}
10Recuperação e auditoria
Ficou fora do ar, perdeu um evento, ainda não tinha webhook quando o cliente conectou? Os eventos que saíram por webhook ficam consultáveis por 90 dias, com o mesmo data do envelope. E toda ação sensível na conta (keys, webhooks, subcontas, suspensão) fica numa trilha de auditoria que sobrevive à retenção.
/v1/eventsEventos da conta
Mais recentes primeiro, no mesmo formato do envelope (sem headers). Filtros: ?event (um nome do catálogo; desconhecido → 422), ?channel_id, ?since e ?until (ISO 8601), ?limit (padrão 50, máx. 200), ?before (paginação: o id devolvido em next_before). Depois da retenção, data vem null e expired = true.
Headers: Authorization · X-Subaccount-Id
GET /v1/events?limit=2
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyome4i005ln5s1wdv4v415",
"event": "channel.reconnected",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"created_at": "2026-09-12T17:51:43.362Z",
"data": {
"type": "messenger",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"display_name": "Clínica Sorriso"
},
"expired": false
},
{
"id": "cmtyome13005fn5s1mzfhi2xl",
"event": "channel.disconnected",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"created_at": "2026-09-12T17:51:43.240Z",
"data": {
"type": "messenger",
"reason": "cliente_cancelou",
"channel_id": "cmtyombi2001dn5s166wsq7hd",
"display_name": "Clínica Sorriso",
"initiated_by": "tenant"
},
"expired": false
}
],
"has_more": true,
"next_before": "cmtyome13005fn5s1mzfhi2xl"
}Filtro por tipo, paginação, filtro por canal e período, filtro inválido
GET /v1/events?event=message.status&limit=2
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomdwd0051n5s1d6dt8lir",
"event": "message.status",
"channel_id": null,
"created_at": "2026-09-12T17:51:43.070Z",
"data": {
"status": "delivered",
"timestamp": 1789232299000,
"message_id": "cmtyomcjs003mn5s1stirn6bw"
},
"expired": false
},
{
"id": "cmtyomdwb004vn5s1kc8flhtq",
"event": "message.status",
"channel_id": null,
"created_at": "2026-09-12T17:51:43.067Z",
"data": {
"status": "read",
"timestamp": 1789232359000,
"message_id": "cmtyomcjs003mn5s1stirn6bw"
},
"expired": false
}
],
"has_more": true,
"next_before": "cmtyomdwb004vn5s1kc8flhtq"
}GET /v1/events?event=message.status&limit=2&before=cmtyomdwb004vn5s1kc8flhtq
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomcpv004in5s1onw6ao13",
"event": "message.status",
"channel_id": null,
"created_at": "2026-09-12T17:51:41.540Z",
"data": {
"status": "sent",
"message_id": "cmtyomcpm004dn5s15nvze5dv"
},
"expired": false
},
{
"id": "cmtyomcpl004bn5s179t5w7xn",
"event": "message.status",
"channel_id": null,
"created_at": "2026-09-12T17:51:41.530Z",
"data": {
"status": "sent",
"message_id": "cmtyomcpb0047n5s10a05ui59"
},
"expired": false
}
],
"has_more": true,
"next_before": "cmtyomcpl004bn5s179t5w7xn"
}GET /v1/events?channel_id=cmtyombho0019n5s1jeacwlaq&since=2026-09-01T00:00:00Z&limit=2
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyombm8001zn5s14brtyv0m",
"event": "message.received",
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"created_at": "2026-09-12T17:51:40.112Z",
"data": {
"from": {
"name": "Mariana Lopes",
"externalId": "5891234567890123",
"external_id": "5891234567890123"
},
"type": "text",
"channel": "instagram",
"content": {
"mid": "m_bXR5b21hbWN8aWcx",
"text": "Vocês fazem clareamento?"
},
"timestamp": 1789232099000,
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"contact_id": "cmtyombmi002nn5s1b3abz0zr",
"message_id": "cmtyombms0036n5s1ttuq2rmp",
"conversation_id": "cmtyombmo0034n5s155id8ced"
},
"expired": false
},
{
"id": "cmtyombhq001bn5s16d2yzycd",
"event": "channel.connected",
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"created_at": "2026-09-12T17:51:39.950Z",
"data": {
"type": "instagram",
"channel_id": "cmtyombho0019n5s1jeacwlaq",
"display_name": "clinicasorriso"
},
"expired": false
}
],
"has_more": false,
"next_before": null
}GET /v1/events?event=message.nope
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 422
{
"error": "validation failed",
"detail": "unknown event: message.nope",
"code": "invalid_payload",
"retryable": false,
"details": {
"known_events": [
"message.received",
"message.status",
"message.echo",
"channel.connected",
"channel.reconnected",
"channel.disconnected",
"channel.health_changed",
"template.status",
"template.category_changed",
"history.completed",
"lead.received",
"calendar.connected",
"calendar.reconnected",
"calendar.disconnected",
"calendar.event.created",
"calendar.event.updated",
"calendar.event.cancelled",
"booking.created",
"booking.rescheduled",
"booking.cancelled",
"booking.confirmed",
"booking.reschedule_requested",
"booking.reminder_sent",
"booking.reminder_failed",
"webhook.test"
]
}
}/v1/events/{id}Um evento, com o estado das entregas
O evento e, em deliveries, o que aconteceu com ele em cada assinatura da conta. É a resposta para "esse evento chegou no meu sistema?".
Headers: Authorization · X-Subaccount-Id
GET /v1/events/cmtyombh90015n5s1fjpfzazv
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyombh90015n5s1fjpfzazv",
"event": "channel.connected",
"channel_id": "cmtyombh50013n5s136btsszl",
"created_at": "2026-09-12T17:51:39.933Z",
"data": {
"type": "whatsapp",
"channel_id": "cmtyombh50013n5s136btsszl",
"coexistence": true,
"display_name": "Clínica Sorriso"
},
"expired": false,
"deliveries": [
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"status": "dead",
"attempts": 8,
"last_error": "HTTP 503",
"delivered_at": "2026-09-12T17:51:39.946Z"
}
]
}Evento fora da retenção
GET /v1/events/cmtyomdwb004vn5s1kc8flhtq
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"id": "cmtyomdwb004vn5s1kc8flhtq",
"event": "message.status",
"channel_id": null,
"created_at": "2026-06-13T17:51:44.415Z",
"data": null,
"expired": true,
"deliveries": [
{
"id": "cmtyombcp000pn5s10dhh8vj2:cmtyomdwb004vn5s1kc8flhtq",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2",
"status": "delivered",
"attempts": 1,
"last_error": null,
"delivered_at": "2026-09-12T17:51:43.081Z"
}
]
}/v1/auditTrilha de auditoria
Quem criou ou revogou key, mexeu em webhook, criou, suspendeu ou reativou subconta. Sem o header, a trilha do tenant; com X-Subaccount-Id, a da subconta. actor diz qual key fez (via: "api") ou se foi a operação do hub (via: "admin"). Filtros: ?type, ?limit, ?before.
Headers: Authorization · X-Subaccount-Id
GET /v1/audit
Authorization: Bearer shk_…
HTTP 200
{
"data": [
{
"id": "cmtyomj610071n5s1o3w9d3pr",
"type": "api_key.rotated",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": null,
"ip": "127.0.0.1",
"target": {
"new_key_id": "cmtyomj5x006zn5s1ly2htuzi",
"old_keys_valid_until": "2026-09-13T17:51:49.892Z",
"rotated_by_key_hash_prefix": "dbfee15f7dfc"
},
"created_at": "2026-09-12T17:51:49.898Z"
},
{
"id": "cmtyomia4006xn5s109nx1ayt",
"type": "subaccount.reactivated",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {},
"created_at": "2026-09-12T17:51:48.749Z"
},
{
"id": "cmtyomh40006pn5s11nd6gi9q",
"type": "subaccount.suspended",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {},
"created_at": "2026-09-12T17:51:47.233Z"
},
{
"id": "cmtyomey8005rn5s1kjc6lm8f",
"type": "subaccount.updated",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"changes": {
"max_calendars": 1
}
},
"created_at": "2026-09-12T17:51:44.433Z"
},
{
"id": "cmtyomb9w0009n5s1fabdx4lt",
"type": "subaccount.updated",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"changes": {
"max_channels": 3
}
},
"created_at": "2026-09-12T17:51:39.669Z"
},
{
"id": "cmtyomb890007n5s1kg463z6p",
"type": "subaccount.created",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"name": "Clínica Sorriso",
"external_ref": "crm-4821",
"max_channels": 2
},
"created_at": "2026-09-12T17:51:39.610Z"
}
],
"has_more": false,
"next_before": null
}Trilha da subconta, filtro por tipo, tipo inválido
GET /v1/audit?limit=6
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyomj6i0075n5s17f3pm2vo",
"type": "webhook.deleted",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"url": "https://api.seuapp.com.br/hub/eventos",
"subscription_id": "cmtyombbl000fn5s1mprslwma"
},
"created_at": "2026-09-12T17:51:49.915Z"
},
{
"id": "cmtyomj6a0073n5s1h8wb07l8",
"type": "api_key.revoked",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"key_id": "cmtyombao000bn5s13vm7yetb"
},
"created_at": "2026-09-12T17:51:49.906Z"
},
{
"id": "cmtyomfy3006jn5s108nepv8u",
"type": "calendar_account.disconnected",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"email": "clinica.sorriso@example.com",
"calendar_account_id": "cmtyomeyq005vn5s1kaei6flh"
},
"created_at": "2026-09-12T17:51:45.723Z"
},
{
"id": "cmtyomeu7005pn5s1dmscl95e",
"type": "webhook.redelivered",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"event_id": "cmtyombh90015n5s1fjpfzazv",
"delivery_id": "cmtyombcp000pn5s10dhh8vj2:cmtyombh90015n5s1fjpfzazv",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2"
},
"created_at": "2026-09-12T17:51:44.288Z"
},
{
"id": "cmtyombd1000tn5s1e1f7d3p6",
"type": "webhook.tested",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"event_id": "cmtyombcw000rn5s156s4594m",
"subscription_id": "cmtyombcp000pn5s10dhh8vj2"
},
"created_at": "2026-09-12T17:51:39.781Z"
},
{
"id": "cmtyombch000nn5s1ezwr62ls",
"type": "webhook.updated",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"changes": {
"active": false
},
"subscription_id": "cmtyombbl000fn5s1mprslwma"
},
"created_at": "2026-09-12T17:51:39.762Z"
}
],
"has_more": true,
"next_before": "cmtyombch000nn5s1ezwr62ls"
}GET /v1/audit?type=webhook.secret_rotated
Authorization: Bearer shk_…
X-Subaccount-Id: cmtyomb7b0005n5s1bl1h6p5a
HTTP 200
{
"data": [
{
"id": "cmtyombc8000ln5s1burw2ng3",
"type": "webhook.secret_rotated",
"actor": {
"via": "api",
"api_key_id": "cmtyomb120001n5s1a6i6is3c",
"key_tenant_id": "cmtyomb120000n5s10af0zpfj"
},
"subaccount_id": "cmtyomb7b0005n5s1bl1h6p5a",
"ip": "127.0.0.1",
"target": {
"subscription_id": "cmtyombbl000fn5s1mprslwma"
},
"created_at": "2026-09-12T17:51:39.752Z"
}
],
"has_more": false,
"next_before": null
}GET /v1/audit?type=nope
Authorization: Bearer shk_…
HTTP 422
{
"error": "validation failed",
"detail": "unknown audit type: nope",
"code": "invalid_payload",
"retryable": false,
"details": {
"known_types": [
"api_key.created",
"api_key.revoked",
"api_key.rotated",
"webhook.created",
"webhook.updated",
"webhook.deleted",
"webhook.secret_rotated",
"webhook.tested",
"webhook.redelivered",
"subaccount.created",
"subaccount.updated",
"subaccount.suspended",
"subaccount.reactivated",
"tenant.created",
"tenant.updated",
"tenant.suspended",
"tenant.reactivated",
"calendar_account.disconnected",
"calendar_account.purged"
]
}
}| type | Quando |
|---|---|
api_key.created · api_key.revoked · api_key.rotated | Keys de subconta criadas ou revogadas; rotação do dono da key |
webhook.created · webhook.updated · webhook.deleted · webhook.secret_rotated · webhook.tested · webhook.redelivered | Ciclo de vida das assinaturas |
subaccount.created · subaccount.updated · subaccount.suspended · subaccount.reactivated | Na trilha do tenant, com subaccount_id |
tenant.created · tenant.updated · tenant.suspended · tenant.reactivated | Ações da operação do hub sobre a sua conta (via: "admin") |
11Limites, regras e códigos de erro
Regras da Meta aplicadas pelo hub
| Canal | Dentro da janela de 24h | Fora da janela | Limite de envio |
|---|---|---|---|
| Livre: texto, mídia | Só type: "template" aprovado; texto livre devolve 422 window_closed | Regulado pelo hub. Número em coexistência: teto da Meta de 20 msg/s | |
| Livre | Bloqueado (422); não existe template | 2 msg/s por conta, regulado pelo hub | |
| Messenger | Livre | Bloqueado (422); não existe template | Regulado pelo hub |
- Janela: abre e renova a cada mensagem recebida do contato;
message.echonão abre.GET /v1/conversationsexpõewindow_openewindow_expires_at. - Coexistência, o que muda no app do cliente: listas de transmissão, mensagens temporárias e de visualização única e localização ao vivo são desativadas pela Meta. Grupos não passam pela API.
- Cobrança da Meta: conversas iniciadas com template são cobradas pela Meta diretamente do cliente final, no meio de pagamento da conta do WhatsApp Business dele. O hub não intermedeia. Sem meio de pagamento, envios de template falham;
GET /v1/channels/{id}mostra isso emhealth.billing_ok.
Limites da API
- Requisições: 30 por segundo por conta (padrão; ajustável por contrato, o valor efetivo está em
GET /v1/me→rate_limit_rps). O bucket é da conta dona da key: com a key do tenant, todas as subcontas dividem o limite; uma key de subconta tem o seu próprio. Acima disso,429 rate_limitedcomRetry-After: 1. - Canais:
max_channelspor subconta, definido por você. Estourou:422 quota_exceededao criar a sessão. - Paginação:
?limitaté 200 em eventos, entregas e auditoria (100 em conversas);has_more+next_before. - Retenção: 90 dias para conteúdo de eventos, entregas e mídia. Auditoria, canais, contatos, conversas e mensagens não expiram.
- Sessões de conexão: uso único; 2 h (coexistência) ou 30 min.
GET /v1/me
Authorization: Bearer shk_…
HTTP 429
retry-after: 1
{
"error": "rate limit exceeded",
"detail": "30 req/s por conta",
"code": "rate_limited",
"retryable": true
}
Códigos de erro
Formato único: {error, detail, code, retryable, details?, meta_code?}. code é estável e faz parte do contrato; error e detail são texto para humano e mudam sem aviso (error é legado e some na v2). retryable diz se repetir mais tarde pode dar certo. meta_code traz o código original da Meta quando o erro nasceu lá.
code | HTTP | retryable | Significado |
|---|---|---|---|
unauthorized | 401 | não | API key ausente, inválida, revogada ou expirada |
forbidden | 403 | não | X-Subaccount-Id não pertence a esta key, ou é uma key de subconta usando o header ou tentando gerenciar subcontas |
account_suspended | 403 | não | A conta da chamada (ou a mãe dela) está suspensa |
calendar_needs_reauth | 403 | não | Google Agenda: a conta precisa ser reautorizada (details.calendar_account_id) |
calendar_scope_missing | 422 | não | Google Agenda: o cliente negou parte das permissões no consentimento |
calendar_conflict | 409 | às vezes | Google Agenda: slot ocupado (não repetir) ou compromisso mudou no Google desde a leitura (retryable: true: releia e tente de novo) |
calendar_rate_limited | 429 | sim | Google Agenda: quota do Google esgotada; o hub já tentou 3 vezes com backoff |
calendar_not_found | 404 | não | Google Agenda: conta ou agenda inexistente ou de outra subconta |
calendar_limit_reached | 422 | não | Google Agenda: subconta atingiu max_calendars |
integration_unavailable | 503 | não | A integração pedida não está configurada neste hub |
invalid_state | 400 | não | Callback OAuth com state desconhecido (link adulterado ou expirado) |
not_found | 404 | não | Recurso inexistente ou de outra conta (assinatura, entrega, evento, subconta, mídia) |
channel_not_found | 404 | não | channel_id não é da conta |
channel_disconnected | 409 | não | Canal existe mas está desconectado |
conflict | 409 | não | external_ref ou slug já em uso (details.existing); número ou página já conectado em outra conta; ping ou reentrega em assinatura pausada |
session_expired | 410 | não | Sessão de conexão expirada ou já usada (na página hospedada) |
event_expired | 410 | não | O conteúdo do evento saiu pela retenção; nada a reentregar |
window_closed | 422 | não | Fora da janela de 24h |
invalid_payload | 422 | não | Corpo ou query inválidos, evento desconhecido, header onde não cabe; details traz os campos |
quota_exceeded | 422 | não | max_channels da subconta atingido |
rate_limited | 429 | sim | Acima do limite de requisições; header Retry-After: 1 |
internal_error | 500 | sim | Falha do hub |
provider_error | 502 | sim | A Meta recusou ou falhou; meta_code e detail explicam |
12Checklist de go-live
GET /v1/meresponde 200 com a sua key, em produção, e ocontract_versionestá registrado no seu sistema com alerta de mudança.- A key vive só no backend (variável de ambiente ou cofre); nunca em browser, app móvel ou repositório.
- Cada cliente final vira uma subconta com
external_ref, e a ativação trata409 conflictcomo sucesso (reaproveitadetails.existing.id). - Uma assinatura de webhook por subconta, HTTPS, com o
secretguardado porsubaccount_id. - O endpoint verifica o HMAC sobre o corpo bruto, rejeita timestamps com mais de 5 minutos, responde 2xx em menos de 10 s e processa fora da requisição.
- Processamento idempotente pelo
webhook-id: duplicata não gera efeito colateral. POST /v1/webhooks/{id}/testentregou umwebhook.testválido e a entrega aparecedelivered.- Evento desconhecido é no-op com 2xx; campos novos são ignorados sem quebrar.
- Todo
POST /v1/messageslevaIdempotency-Key; a lógica ramifica emcode(window_closedoferece template no WhatsApp e bloqueia no Instagram e Messenger). - Recibos fora de ordem não rebaixam status;
data.timestampé a referência. - Rotina de recuperação: ao voltar de uma indisponibilidade,
GET /v1/events?since=…ou reentrega das entregasdead. - Suspensão e reativação de cliente mapeadas para
PATCH /v1/subaccounts/{id}comstatus. - Rotação de key e de secret ensaiadas uma vez (aceitar os dois secrets por alguns minutos).
- Alertas:
429repetido, entregasdeadacumulando,channel.disconnectedcominitiated_by: "provider",health.billing_ok: false.
13SDK oficial
Para integrações em TypeScript ou JavaScript existe o pacote @sociosai/hub-sdk: um cliente tipado sobre esta mesma API (nada além do que está documentado aqui) e a verificação de webhook pronta, com a resolução do secret por subaccount_id. Instale com npm install @sociosai/hub-sdk.
import { HubClient, parseWebhook } from '@sociosai/hub-sdk'
const hub = new HubClient({ apiKey: process.env.HUB_API_KEY })
const me = await hub.me()
const sub = await hub.subaccounts.create({ name: 'Clínica Sorriso', external_ref: 'crm-4821' })
await hub.as(sub.id).messages.send({ channel_id, to: '5511977776666', type: 'text', text: { body: 'Olá!' } })
// no seu endpoint de webhook (corpo bruto):
const event = await parseWebhook({ headers: req.headers, rawBody, resolveSecret: (subaccountId) => secrets.get(subaccountId) })
Qualquer outra linguagem usa a API diretamente; a spec em /openapi.json (OpenAPI 3.1) gera clients no Postman e nos geradores usuais.
Ambiente, credenciais e suporte
- Ambiente: produção única,
https://hub.sociosai.com. Para desenvolver, use uma subconta de teste sua; não use subcontas de clientes reais. - Credenciais: a API key do tenant é entregue por cofre, nunca por documento. A key é de uso exclusivo em backend.
- Validação: todas as amostras deste documento foram capturadas em 12/09/2026 executando o código de produção do hub (contrato 1.3), com a Meta simulada. Se algo aqui divergir do que a API responder, o defeito é do hub e a correção é nossa.
- Spec e guia: /openapi.json e /docs.
Dúvidas de contrato ou credenciais: sysadmin@metamorph-ai.com.