S
Sócios AI Hubby Sócios AI
Referência da API · v1 · contrato 1.3 · amostras capturadas em 12/09/2026

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.

Base URL
https://hub.sociosai.com
Autenticação
Authorization: Bearer shk_…
Formato
JSON · UTF-8 · snake_case
Webhooks
HMAC-SHA256 · at-least-once
Spec
/openapi.json · /docs
Versão do contrato
X-Hub-Contract-Version: 1.3

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.

1 · Conta

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).

2 · Webhook

Você registra a URL onde quer receber os eventos de cada subconta e guarda o secret para validar a assinatura.

3 · Canal

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.

4 · Tráfego

POST /v1/messages envia. O hub entrega message.received e os recibos message.status na sua URL, assinados.

Conceitos

ConceitoO que é
TenantA 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.
SubcontaUm 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 keyCredencial 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.
CanalUm número de WhatsApp, uma conta profissional do Instagram ou uma página do Facebook conectados a uma conta. Identificado por channel_id.
ContatoQuem fala com o canal. external_id é o identificador dele no canal (wa_id, PSID, IGSID) e é o to do envio.
ConversaContato + canal. O hub cria e mantém; o conversation_id chega em todo evento de mensagem.
Janela de 24hRegra 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ênciaModo 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.
EventoTudo 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.
EntregaUma tentativa de levar um evento a uma assinatura de webhook. Tem estado (pending, delivered, failed, dead), pode ser consultada e reentregue.

Convenções

HeaderUso
Authorization: Bearer shk_…Obrigatório em /v1/*. Uso exclusivo em backend, nunca em browser ou app móvel.
X-Subaccount-IdCom 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-KeyEm POST /v1/messages. Repetir com a mesma key devolve 200 com a mesma message_id, sem duplicar.
X-Hub-Contract-VersionEm 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.

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.

GET/v1/me

Quem 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.

GET/v1/api-keys

Listar 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"
    }
  ]
}
POST/v1/api-keys/rotate

Rotacionar 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

CampoTipoDescrição
grace_hoursinteger, opcionalQuanto 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

POST/v1/subaccounts/{id}/api-keys

Criar key de subconta

Só a key do tenant cria. O valor da key aparece apenas nesta resposta. Pode ter prazo de validade.

Headers: Authorization

CampoTipoDescrição
namestring, obrigatórioNome de referência (até 80 caracteres)
expires_atstring ISO 8601, opcionalExpiraçã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"
}
GET/v1/subaccounts/{id}/api-keys

Listar 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"
    }
  ]
}
DELETE/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.

POST/v1/subaccounts

Criar 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

CampoTipoDescrição
namestring, obrigatórioNome do cliente
external_refstring, opcionalSeu identificador desta conta. Único por tenant, até 120 caracteres
max_channelsinteger ou null, opcionalLimite de canais da subconta. Ausente ou null = ilimitado
slugstring, opcionalDerivado 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"
    }
  }
}
GET/v1/subaccounts

Listar 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"
    }
  ]
}
GET/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"
}
PATCH/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

CampoTipoDescrição
namestring, opcional
max_channelsinteger ou null, opcionalSubir o limite libera novas conexões na hora; baixar não desconecta canais
external_refstring ou null, opcional
statusactive | suspended, opcionalVer 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"
}
GET/v1/usage

Uso 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.

Canalchannel_typeO que o cliente faz no wizardValidade da sessão
whatsapp + coexistence: trueLogin na Meta, aceita os termos, escaneia um QR no app WhatsApp Business do celular. O app continua funcionando.2 h
whatsapp + coexistence: falseLogin na Meta, cadastra o número, verificação por SMS ou ligação.30 min
InstagraminstagramLogin no Facebook, escolhe a página que tem a conta profissional do Instagram vinculada.30 min
MessengermessengerLogin no Facebook, escolhe a página.30 min
POST/v1/connect-sessions

Criar 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

CampoTipoDescrição
channel_typewhatsapp | instagram | messengerPadrão whatsapp
coexistencebooleanSó 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_urlstring, opcionalPara 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

  1. O hub emite channel.connected na assinatura da subconta (ou channel.reconnected, se for um canal que estava desconectado). O data.channel_id é o id do canal.
  2. O cliente é redirecionado ao return_url. Consulte GET /v1/channels se 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

GET/v1/channels

Listar 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"
    }
  ]
}
GET/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"
  }
}
DELETE/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

CampoTipoDescrição
reasonstring, opcionalVai 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.

POST/v1/channels

Criar 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"
}
GET/v1/channels/whatsapp/embedded-signup

Config 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"
}
POST/v1/channels/whatsapp/connect

Concluir 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

CampoTipoDescrição
codestring, obrigatórioDevolvido pelo popup
waba_idstring, obrigatório
phone_number_idstring, obrigatório
display_namestring, opcional
pinstring, opcional6 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

POST/v1/connect-sessions

Criar 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

CampoTipoDescrição
integrationgoogle_calendarObrigatório aqui. Exclusivo com channel_type
return_urlstring, opcionalRecebe ?calendar_account_id= ao voltar
reconnect_account_idstring, opcionalReautorizar 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

GET/v1/calendar-accounts

Listar 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"
    }
  ]
}
GET/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"
    }
  ]
}
PATCH/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

CampoTipoDescrição
use_for_availabilityboolean
timezonestring, IANAex.: 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"
    }
  ]
}
DELETE/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

POST/v1/calendars/availability

Horá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

CampoTipoDescrição
fromISO 8601 com offset
toISO 8601 com offsetmáx. 62 dias depois de from
duration_mininteiroduração do atendimento
buffer_mininteiro, opcionalfolga antes e depois de cada ocupação
min_notice_mininteiro, opcionalnão oferece slots que começam antes de agora + este valor
step_mininteiro, opcionalpasso entre candidatos; padrão = duration_min
tzIANA, opcionalpadrão: fuso da agenda primária
working_hoursobjeto, opcionalpor dia da semana (mon..sun), lista de janelas HH:MM; dia ausente = fechado; sem o campo = 24h
calendar_idslista, opcionalrestringe 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

POST/v1/calendars/{id}/events

Criar 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

CampoTipoDescrição
startISO 8601 com offset
endISO 8601 com offset
titlestring
descriptionstring, opcional
locationstring, opcional
contact_idstring, opcionalcontato da mesma subconta
attendeeslista de {email, name}, opcional
meetbooleanpadrão false
reminderslista de {method: popup | email, minutes}, opcional
allow_overlapbooleanpadrão false
tzIANA, opcionalpadrã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"
      ]
    }
  }
}
GET/v1/calendars/{id}/events

Listar 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"
}
PATCH/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"
}
DELETE/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

POST/v1/messages

Enviar 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

CampoTipoDescrição
channel_idstring, obrigatórioCanal da conta
tostring, obrigatórioWhatsApp: 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
typetext | 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"
}
RespostaQuando
202 {message_id, status: "queued"}Enfileirada
200 {message_id, status}Mesma Idempotency-Key de uma mensagem já aceita
422 window_closedJanela de 24h fechada. WhatsApp: envie template. Instagram e Messenger: aguarde o contato escrever
409 channel_disconnectedCanal desconectado
404 channel_not_foundchannel_id não pertence à conta da chamada
422 invalid_payloadCorpo 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
}
GET/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.

GET/v1/conversations

Listar 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"
      }
    }
  ]
}
GET/v1/conversations/{id}/messages

Ler 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": []
}
POST/v1/conversations/{id}/sync

Sincronizar 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.

GET/v1/templates

Listar 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"
    }
  ]
}
POST/v1/templates

Criar 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

CampoTipoDescrição
namestringminúsculas, números e _
languagestringex. pt_BR
categorystringUTILITY | MARKETING | AUTHENTICATION
componentsarrayformato da Meta
channel_idstring, opcionalqual 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"
}
GET/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"
    }
  ]
}
PATCH/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"
}
DELETE/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"
  }
]
GET/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
}
GET/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

POST/v1/webhooks

Registrar 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

CampoTipoDescrição
urlstring, obrigatórioEndpoint que receberá os POSTs. https obrigatório
eventsarray de string, opcionalFiltro. ["*"] (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://\""
      ]
    }
  }
}
GET/v1/webhooks

Listar 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"
    }
  ]
}
GET/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
}
PATCH/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

CampoTipoDescrição
urlstring, opcionalhttps obrigatório
eventsarray de string, opcional
activeboolean, 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"
}
DELETE/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
POST/v1/webhooks/{id}/rotate-secret

Trocar 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.

POST/v1/webhooks/{id}/test

Ping 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ê.

GET/v1/webhooks/{id}/deliveries

Entregas 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
}
GET/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"
}
POST/v1/webhooks/{id}/deliveries/{delivery_id}/redeliver

Reentregar 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 envelopeSignificado
idId 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).
eventTipo (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_atEpoch em segundos, momento da entrega. Numa retentativa vem um valor novo; o id não muda.
subaccount_idConta dona do evento. Resolva o secret por ele e confira que bate com a rota em que o evento chegou.
channel_idCanal do evento, quando houver (ausente em message.status, template.* e webhook.test).
dataPayload do evento. data.timestamp, quando existe, é epoch em milissegundos, hora do fato na Meta.

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.

eventQuandodata
message.receivedUm contato escreveu (WhatsApp, Instagram, Messenger)message_id, channel, channel_id, conversation_id, contact_id, from{{external_id, name?}}, type, timestamp, content, media?[]
message.statusRecibo de mensagem enviadamessage_id, status, timestamp?, error?
message.echoMensagem enviada pelo aplicativo WhatsApp Business, Instagram ou Messengermessage_id, provider_message_id, channel, channel_id, conversation_id, contact_id, to{{external_id}}, type, timestamp, content, source
channel.connectedWizard concluído (ou canal criado pela API)channel_id, type, display_name, coexistence?
channel.reconnectedCanal desconectado foi reconectado (mesmo channel_id)channel_id, type, display_name, coexistence?
channel.disconnectedDesconexão pela API (initiated_by: "tenant") ou pela Meta ("provider", com o motivo)channel_id, type, display_name, reason, initiated_by
channel.health_changedQualidade, limite de mensagens ou cobrança do número mudaram na Metachannel_id, type, health{{quality_rating?, messaging_limit?, billing_ok?, updated_at}}
template.statusTemplate aprovado, rejeitado, pausadoname, language, status, reason?, timestamp
template.category_changedA Meta reclassificou a categoria (antes de valer)name, language, from, to, effective_at
history.completedImport do histórico do app (coexistência) terminouchannel_id, chunks
webhook.testPing de POST /v1/webhooks/{{id}}/testsubscription_id, message: "ping", requested_at

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.

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"
    }
  ]
}

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.

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.

GET/v1/events

Eventos 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"
    ]
  }
}
GET/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"
    }
  ]
}
GET/v1/audit

Trilha 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"
    ]
  }
}
typeQuando
api_key.created · api_key.revoked · api_key.rotatedKeys de subconta criadas ou revogadas; rotação do dono da key
webhook.created · webhook.updated · webhook.deleted · webhook.secret_rotated · webhook.tested · webhook.redeliveredCiclo de vida das assinaturas
subaccount.created · subaccount.updated · subaccount.suspended · subaccount.reactivatedNa trilha do tenant, com subaccount_id
tenant.created · tenant.updated · tenant.suspended · tenant.reactivatedAçõ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

CanalDentro da janela de 24hFora da janelaLimite de envio
Livre: texto, mídiaSó type: "template" aprovado; texto livre devolve 422 window_closedRegulado pelo hub. Número em coexistência: teto da Meta de 20 msg/s
InstagramLivreBloqueado (422); não existe template2 msg/s por conta, regulado pelo hub
MessengerLivreBloqueado (422); não existe templateRegulado pelo hub

Limites da API

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á.

codeHTTPretryableSignificado
unauthorized401nãoAPI key ausente, inválida, revogada ou expirada
forbidden403nãoX-Subaccount-Id não pertence a esta key, ou é uma key de subconta usando o header ou tentando gerenciar subcontas
account_suspended403nãoA conta da chamada (ou a mãe dela) está suspensa
calendar_needs_reauth403nãoGoogle Agenda: a conta precisa ser reautorizada (details.calendar_account_id)
calendar_scope_missing422nãoGoogle Agenda: o cliente negou parte das permissões no consentimento
calendar_conflict409às vezesGoogle Agenda: slot ocupado (não repetir) ou compromisso mudou no Google desde a leitura (retryable: true: releia e tente de novo)
calendar_rate_limited429simGoogle Agenda: quota do Google esgotada; o hub já tentou 3 vezes com backoff
calendar_not_found404nãoGoogle Agenda: conta ou agenda inexistente ou de outra subconta
calendar_limit_reached422nãoGoogle Agenda: subconta atingiu max_calendars
integration_unavailable503nãoA integração pedida não está configurada neste hub
invalid_state400nãoCallback OAuth com state desconhecido (link adulterado ou expirado)
not_found404nãoRecurso inexistente ou de outra conta (assinatura, entrega, evento, subconta, mídia)
channel_not_found404nãochannel_id não é da conta
channel_disconnected409nãoCanal existe mas está desconectado
conflict409nãoexternal_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_expired410nãoSessão de conexão expirada ou já usada (na página hospedada)
event_expired410nãoO conteúdo do evento saiu pela retenção; nada a reentregar
window_closed422nãoFora da janela de 24h
invalid_payload422nãoCorpo ou query inválidos, evento desconhecido, header onde não cabe; details traz os campos
quota_exceeded422nãomax_channels da subconta atingido
rate_limited429simAcima do limite de requisições; header Retry-After: 1
internal_error500simFalha do hub
provider_error502simA Meta recusou ou falhou; meta_code e detail explicam

12Checklist de go-live

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

Dúvidas de contrato ou credenciais: sysadmin@metamorph-ai.com.