Guia de integração
Tudo que você precisa para colocar mensagens no ar: autenticação, subcontas,
conexão de canais, envio, webhooks e recuperação. A referência completa por endpoint está no
OpenAPI (importe no Postman/Insomnia ou gere um client) e na
referência da API com amostras reais. Para Node/TypeScript existe o
SDK oficial @sociosai/hub-sdk (seção 9).
0 · Modelo de contas
Você é um tenant e recebe uma API key. Cada cliente final seu é uma subconta:
ela isola canais, conversas, contatos, webhooks e uso. A key do tenant age em nome de qualquer
subconta pelo header X-Subaccount-Id. Para instalações em servidor do cliente existe
a key de subconta, que enxerga só aquela conta. O hub não fatura o seu cliente: você cobra
no seu plano, o hub aplica o limite (max_channels) e mede o uso.
1 · Autenticação e identidade
Toda chamada leva Authorization: Bearer shk_…. A key é backend-only.
Comece por GET /v1/me: confirma a key, o header, os limites, o plano, a versão do
contrato e o catálogo de eventos que esta versão entrega.
GET /v1/me
→ {"account": {"id": "…", "kind": "tenant", "status": "active", "rate_limit_rps": 30, "plan": {…}},
"key_owner": {…}, "acting_as_subaccount": false, "contract_version": "1.3", "events": [ … ]}
Rotação de credencial: POST /v1/api-keys/rotate (a antiga expira após um grace
period). Keys por subconta: POST /v1/subaccounts/{id}/api-keys cria uma
key que autentica como a subconta (não aceita X-Subaccount-Id, não gerencia subcontas,
tem cota própria, a rotação do tenant não a atinge). Revogue com
DELETE /v1/subaccounts/{id}/api-keys/{key_id}. As rotas /v1/api-keys/* e
/v1/subaccounts* recusam X-Subaccount-Id (422).
2 · Subcontas
Crie uma por cliente na ativação dele. Mande o seu id da empresa em
external_ref: a criação vira idempotente (repetir devolve 409 com a subconta existente
em details.existing) e você recupera a conta com
GET /v1/subaccounts?external_ref=… sem precisar ter guardado o nosso id.
POST /v1/subaccounts {"name": "Clínica Sorriso", "max_channels": 2, "external_ref": "empresa-8812"}
→ 201 {"id": "…", "slug": "seuapp--clinica-sorriso", "status": "active", "external_ref": "empresa-8812", …}
PATCH /v1/subaccounts/{id} {"status": "suspended"} // inadimplência: 403 account_suspended para ela,
// webhooks param, dados e eventos ficam
GET /v1/usage (X-Subaccount-Id) → canais + mensagens do mês
3 · Conectar canais
Gere uma sessão e redirecione o cliente. Ele autoriza na Meta pelo wizard hospedado e volta
para o seu return_url com o canal ativo:
POST /v1/connect-sessions (X-Subaccount-Id)
{"channel_type": "whatsapp", "coexistence": true, "return_url": "https://seuapp.com/canais"}
→ {"url": "https://hub.sociosai.com/connect/…", "expires_at": "…"} (uso único; 2h coexistência, 30 min os demais)
- coexistence: true: WhatsApp de número que já está no app WhatsApp Business. O wizard
mostra um QR, o cliente escaneia no celular e o app continua funcionando. Mensagens
enviadas pelo celular chegam a você como
message.echo. - Instagram/Messenger: mesmo fluxo com escolha de página (
channel_typecorrespondente). - O canal novo chega no evento
channel.connected; fallback:GET /v1/channels.
3b · Google Agenda do cliente
Mesma sessão, com integration em vez de channel_type. O cliente autoriza no
Google (só três permissões: listar agendas, ver horários ocupados, criar e alterar compromissos) e
volta ao return_url com ?calendar_account_id=…. O hub guarda o token cifrado e
não guarda título nem descrição dos compromissos: vêm do Google a cada leitura.
POST /v1/connect-sessions (X-Subaccount-Id)
{"integration": "google_calendar", "return_url": "https://seuapp.com/agenda"} → {"url": "…"}
POST /v1/calendars/availability {"from": "…", "to": "…", "duration_min": 30, "buffer_min": 10,
"working_hours": {"mon": [{"start": "09:00", "end": "18:00"}]}} → {"slots": [{"start", "end"}, …]}
POST /v1/calendars/{id}/events {"start": "…", "end": "…", "title": "Consulta", "contact_id": "…", "meet": true}
→ 201 (409 calendar_conflict se o slot foi tomado; Idempotency-Key suportado)
PATCH /v1/calendars/{id}/events/{event_id} remarcar · DELETE cancelar · GET /v1/calendars/{id}/events listar
- Disponibilidade considera todas as agendas marcadas com
use_for_availability(PATCH /v1/calendars/{id}), mesmo de contas diferentes: profissional + sala, por exemplo. - O que o cliente mudar direto no Google chega em
calendar.event.updated/calendar.event.cancelledcomchanged_by: "external". É assim que o bot sabe avisar o paciente. - Token morto no Google →
calendar.disconnected(reason: "invalid_grant") e403 calendar_needs_reauth; reconecte comreconnect_account_id.max_calendarsna subconta limita contas.
3c · Agendamento com lembretes e ferramentas para agentes
Recursos (profissional, sala) e serviços (duração, folga, antecedência, lembretes) em cima da agenda;
a reserva vira compromisso na agenda do recurso e os lembretes saem pelo WhatsApp como template com botões
Confirmar / Remarcar / Cancelar. O clique do cliente chega como message.received e o hub age
na reserva (booking.confirmed, booking.reschedule_requested, booking.cancelled).
POST /v1/calendar-resources {"name": "Dra. Ana", "calendar_id": "…", "working_hours": {"mon": [{"start": "09:00", "end": "18:00"}]}}
POST /v1/calendar-services {"name": "Consulta", "duration_min": 30, "buffer_min": 10, "min_notice_min": 120,
"reminders": [{"offset_min": 1440, "template": "lembrete_24h"}, {"offset_min": 60, "template": "lembrete_1h"}]}
GET /v1/bookings/availability?resource_id&service_id&from&to → {"slots": [...]}
POST /v1/bookings {"resource_id", "service_id", "start", "contact_id", "notes"} → 201 (409 booking_slot_taken)
POST /v1/bookings/{id}/reschedule {"start"} · POST /v1/bookings/{id}/cancel {"reason"}
GET /v1/agent-tools/calendar → 6 ferramentas com JSON-schema para o seu LLM
POST /v1/agent-tools/calendar/{tool} → {"ok": true, "result"} | {"ok": false, "error": {"code"}} (sempre HTTP 200)
POST /mcp/calendar → servidor MCP remoto (JSON-RPC 2.0), mesma API key
4 · Enviar
POST /v1/messages (X-Subaccount-Id, Idempotency-Key recomendado)
{"channel_id": "…", "to": "5511999990000", "type": "text", "text": {"body": "Olá!"}}
→ 202 {"message_id": "…", "status": "queued"}
Tipos: text, media (url pública + kind) e template
(só WhatsApp, única forma de iniciar conversa fora da janela). O 202 significa enfileirado;
a confirmação vem pelo webhook message.status (sent → delivered → read | failed).
| code | HTTP | Significado | O que fazer |
|---|---|---|---|
window_closed | 422 | Janela de 24h fechada | WhatsApp: enviar por template. IG/Messenger: aguardar o cliente escrever |
channel_disconnected | 409 | Canal desconectado | Oferecer reconexão (nova connect session) |
rate_limited | 429 | Cota de requisições | Respeitar retry-after |
account_suspended | 403 | Conta (ou a mãe) suspensa | Mostrar ao cliente; nada a repetir |
Ramifique sempre em code: ele é estável. error e detail são texto.
5 · Receber (webhooks)
Registre seu endpoint por subconta (o secret é devolvido uma única vez). Você pode usar
uma URL só para todos os clientes: o envelope traz subaccount_id, então o receptor
localiza o secret daquela conta e valida a assinatura com ele.
POST /v1/webhooks (X-Subaccount-Id)
{"url": "https://seuapp.com/hub-events", "events": ["*"]}
→ 201 {"id": "…", "secret": "whsec_…", …} // guarde o secret por subconta
POST /v1/webhooks/{id}/test → 202 {"delivery_id": "…"} // ping assinado pela fila real
GET /v1/webhooks/{id}/deliveries?status=dead → entregas que esgotaram as 8 tentativas
POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliver → reenvia (mesmo id, mesmo data)
PATCH /v1/webhooks/{id} {"active": false} // pausa; POST /{id}/rotate-secret troca o secret
Cada entrega traz webhook-id, webhook-timestamp e
webhook-signature = "v1," + base64(HMAC-SHA256(secret, id + "." + timestamp + "." + corpo)).
Valide antes de processar e seja idempotente pelo webhook-id (entrega at-least-once,
8 tentativas com backoff a partir de 5 s):
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(headers, rawBody, secret) {
const { 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sig } = headers
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const mac = createHmac('sha256', secret).update(`${id}.${ts}.${rawBody}`).digest('base64')
const a = Buffer.from(String(sig)), b = Buffer.from(`v1,${mac}`)
return a.length === b.length && timingSafeEqual(a, b)
}
| Evento | Quando | data (campos principais) |
|---|---|---|
message.received | Cliente final escreveu | message_id, channel, channel_id, conversation_id, contact_id, from, type, content, media[] |
message.status | Status de envio mudou | message_id, status (sent/delivered/read/failed), error. Podem chegar fora de ordem: trate como monotônico |
message.echo | Seu cliente respondeu pelo app do celular (coexistência) | como received + source: "business_app". Renderize como enviada, não notifique |
channel.connected / .reconnected / .disconnected / .health_changed | Canal mudou de estado ou de saúde | channel_id, type, display_name, reason, initiated_by, health |
template.status / .category_changed | Meta aprovou, rejeitou ou recategorizou template | name, language, status, reason / from, to, effective_at |
history.completed | Histórico da coexistência terminou de chegar | channel_id, sync |
calendar.connected / .reconnected / .disconnected | Google Agenda conectada, reautorizada ou desconectada (por você ou por token morto) | calendar_account_id, email, calendars[] / reason (user | invalid_grant) |
calendar.event.created / .updated / .cancelled | Compromisso criado, remarcado ou cancelado, pela API ou direto no Google | calendar_id, event (id, external_id, title, start, end, status, contact_id), changed_by (hub | external) |
booking.created / .rescheduled / .cancelled / .confirmed / .reschedule_requested | Reserva criada, remarcada, cancelada, confirmada pelo cliente ou com pedido de remarcação (pela API, pelo botão ou pelo Google) | booking, source (api | contact | calendar), previous_start, reason, via |
booking.reminder_sent / .reminder_failed | Lembrete enviado ou não (sem canal, sem WhatsApp do contato, opt-out) | booking_id, offset_min, template, message_id / reason |
webhook.test | Você pediu um ping | subscription_id, message: "ping" |
Evento desconhecido: responda 2xx e ignore. Novos tipos são mudança aditiva. Atenção às unidades:
created_at do envelope em segundos, data.timestamp em milissegundos.
6 · Recuperação e auditoria
Ficou fora do ar? Os eventos entregues ficam consultáveis por 90 dias, com o mesmo
data do envelope:
GET /v1/events?since=2026-09-01T00:00:00Z&event=message.received (X-Subaccount-Id)
→ {"data": [{"id": "…", "event": "message.received", "data": {…}, "expired": false}], "has_more": true, "next_before": "…"}
GET /v1/events/{id} → o evento + o estado das entregas dele
GET /v1/audit → quem criou/revogou key, mexeu em webhook, suspendeu subconta (não expira)
7 · Mídia recebida
O evento traz media[] com um id estável do hub (med_…). Baixe quando
quiser dentro da retenção com GET /v1/media/{media_id} (streaming, content-type
original). Messenger/Instagram também entregam a URL do CDN no payload; ela expira.
8 · Limites e regras
- Janela de 24h: WhatsApp destrava com template; IG/Messenger bloqueiam (422); Telegram não tem janela.
- Instagram: 2 msg/s por conta (o hub enfileira e regula). Coexistência WhatsApp: 20 msg/s fixo da Meta.
- API: 30 req/s por conta, ajustável por contrato (
429+retry-afteracima disso). Com a key do tenant, as subcontas dividem a cota; uma key de subconta tem a sua. O valor efetivo está emGET /v1/me. - Grupos, listas de transmissão e mensagens temporárias não passam pela API da Meta.
- Toda resposta traz
X-Hub-Contract-Version. Registre e alerte se mudar: é o sinal de quebra de contrato.
9 · SDK oficial (Node/TypeScript)
npm i @sociosai/hub-sdk
import { HubClient, parseWebhook } from '@sociosai/hub-sdk'
const hub = new HubClient({ apiKey: process.env.HUB_API_KEY, expectedContractVersion: '1.3' })
const sub = await hub.subaccounts.create({ name: 'Clínica Sorriso', external_ref: 'empresa-8812' })
await hub.as(sub.id).messages.send({ channel_id, to, type: 'text', text: { body: 'Olá!' } }, { idempotencyKey: 'msg-1' })
// receptor com uma URL só: resolve o secret pela subconta do envelope
const event = await parseWebhook({ headers, rawBody, resolveSecret: (env) => secrets.get(env.subaccount_id) })
O SDK cobre toda a API, lança HubError com code e retryable, e
avisa quando a versão do contrato muda. Sem dependências.
