Hubby Sócios AI

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)

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

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

codeHTTPSignificadoO que fazer
window_closed422Janela de 24h fechadaWhatsApp: enviar por template. IG/Messenger: aguardar o cliente escrever
channel_disconnected409Canal desconectadoOferecer reconexão (nova connect session)
rate_limited429Cota de requisiçõesRespeitar retry-after
account_suspended403Conta (ou a mãe) suspensaMostrar 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)
}
EventoQuandodata (campos principais)
message.receivedCliente final escreveumessage_id, channel, channel_id, conversation_id, contact_id, from, type, content, media[]
message.statusStatus de envio mudoumessage_id, status (sent/delivered/read/failed), error. Podem chegar fora de ordem: trate como monotônico
message.echoSeu cliente respondeu pelo app do celular (coexistência)como received + source: "business_app". Renderize como enviada, não notifique
channel.connected / .reconnected / .disconnected / .health_changedCanal mudou de estado ou de saúdechannel_id, type, display_name, reason, initiated_by, health
template.status / .category_changedMeta aprovou, rejeitou ou recategorizou templatename, language, status, reason / from, to, effective_at
history.completedHistórico da coexistência terminou de chegarchannel_id, sync
calendar.connected / .reconnected / .disconnectedGoogle Agenda conectada, reautorizada ou desconectada (por você ou por token morto)calendar_account_id, email, calendars[] / reason (user | invalid_grant)
calendar.event.created / .updated / .cancelledCompromisso criado, remarcado ou cancelado, pela API ou direto no Googlecalendar_id, event (id, external_id, title, start, end, status, contact_id), changed_by (hub | external)
booking.created / .rescheduled / .cancelled / .confirmed / .reschedule_requestedReserva 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_failedLembrete enviado ou não (sem canal, sem WhatsApp do contato, opt-out)booking_id, offset_min, template, message_id / reason
webhook.testVocê pediu um pingsubscription_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

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.

Baixar OpenAPI  Referência com amostras  Suporte