Começo rápido
Do workspace à primeira mensagem enviada, com exemplos em curl e TypeScript.
Este guia leva um app do zero até enviar a primeira mensagem. Os exemplos usam curl e TypeScript com fetch (Node 18+). Pré-requisito: a chave de API, o segredo do webhook e as URLs de retorno, que a equipe Matra entrega (ver Visão geral).
// hub.ts: cliente mínimo, usado nos passos seguintes
const HUB_URL = 'https://api.metahub.matratecnologia.com/v1'
export const hub = async <T>(
path: string,
init: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
): Promise<{ status: number; data: T }> => {
const response = await fetch(`${HUB_URL}${path}`, {
method: init.method ?? 'GET',
headers: {
authorization: `Bearer ${process.env.HUB_API_KEY}`,
...(init.body !== undefined && { 'content-type': 'application/json' }),
...init.headers,
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
})
const data = (await response.json().catch(() => null)) as T
return { status: response.status, data }
}
1. Confira a chave
curl https://api.metahub.matratecnologia.com/v1/me \
-H "Authorization: Bearer $HUB_API_KEY"
Resposta 200: { "app": { "id": "...", "name": "..." } }. Com chave ausente ou inválida, 401 com code: "INVALID_API_KEY".
2. Crie o workspace do cliente
Um workspace por cliente final. O externalId é o id do cliente no seu sistema, e chamar de novo com o mesmo valor devolve o mesmo workspace: pode chamar no cadastro do cliente sem se preocupar com duplicata.
curl -X POST https://api.metahub.matratecnologia.com/v1/workspaces \
-H "Authorization: Bearer $HUB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalId": "cliente-123", "name": "Clínica Exemplo" }'
const { status, data } = await hub<{ workspace: { id: string } }>('/workspaces', {
method: 'POST',
body: { externalId: 'cliente-123', name: 'Clínica Exemplo' },
})
// 201 criou, 200 já existia
const workspaceId = data.workspace.id
Guarde o workspace.id junto do cliente.
3. Abra a sessão de conexão
const session = await hub<{ id: string; url: string; expiresAt: string }>(
`/workspaces/${workspaceId}/connect-sessions`,
{
method: 'POST',
body: { kind: 'whatsapp', returnUrl: 'https://app.exemplo.com/settings/whatsapp' },
},
)
// redirecione o navegador do cliente para session.data.url
- A
urlvale 15 minutos e serve uma única vez. Ela carrega um token: não grave em log. - O
returnUrlprecisa estar na lista cadastrada para o seu app; senão,400 RETURN_URL_NOT_ALLOWED. 503 META_WEBHOOK_NOT_READYindica que o hub ainda não recebe webhooks da Meta: tente de novo em instantes.
4. Trate o retorno do cliente
Quando o cliente termina, a página do hub redireciona para o seu returnUrl:
| Query | O que mostrar |
|---|---|
?status=connected&connectionId=<id> |
"Número conectado, ativando". A conexão ainda está pending |
?status=cancelled |
O cliente desistiu; ofereça tentar de novo |
?status=error&code=<code> |
Falha com o motivo (ver Conexão do WhatsApp) |
O redirect é só uma dica de interface: o navegador pode fechar antes dele. A fonte da verdade é o webhook do passo 5.
5. Receba os webhooks
O hub faz POST no endpoint cadastrado. Confira a assinatura sobre o corpo cru, antes de qualquer JSON.parse, responda 2xx rápido e processe depois.
// verify-hub-webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
export const verifyHubWebhook = (
secret: string,
headers: { timestamp: string; signature: string },
rawBody: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean => {
const timestamp = Number(headers.timestamp)
if (!Number.isInteger(timestamp)) return false
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false
const expected = `sha256=${createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')}`
const received = Buffer.from(headers.signature)
const computed = Buffer.from(expected)
return received.length === computed.length && timingSafeEqual(received, computed)
}
O ponto em que a verificação costuma quebrar é o framework parsear o JSON antes. Pegue o corpo como texto:
Next.js (route handler)
// app/api/hub-webhook/route.ts
import { verifyHubWebhook } from '@/lib/verify-hub-webhook'
export const POST = async (request: Request) => {
const rawBody = await request.text()
const valid = verifyHubWebhook(
process.env.HUB_WEBHOOK_SECRET!,
{
timestamp: request.headers.get('x-hub-timestamp') ?? '',
signature: request.headers.get('x-hub-signature') ?? '',
},
rawBody,
)
if (!valid) return new Response(null, { status: 401 })
const event = JSON.parse(rawBody)
await saveForProcessing(event) // fila ou tabela; deduplique por event.id
return new Response(null, { status: 204 })
}
Express
import express from 'express'
import { verifyHubWebhook } from './verify-hub-webhook'
const app = express()
// express.raw só nesta rota: o corpo chega como Buffer, sem parse
app.post('/hub-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body.toString('utf8')
const valid = verifyHubWebhook(
process.env.HUB_WEBHOOK_SECRET!,
{
timestamp: String(req.header('x-hub-timestamp') ?? ''),
signature: String(req.header('x-hub-signature') ?? ''),
},
rawBody,
)
if (!valid) return res.sendStatus(401)
await saveForProcessing(JSON.parse(rawBody))
res.sendStatus(204)
})
Depois do passo 4 chegam, nesta ordem provável (a ordem não é garantida):
{
"id": "cm...",
"type": "connection.created",
"createdAt": "2026-10-22T15:00:00.000Z",
"workspace": { "id": "...", "externalId": "cliente-123" },
"connectionId": "...",
"data": { "id": "...", "status": "pending", "whatsapp": { "displayPhoneNumber": "+55 43 99999-0000" } },
"meta": null
}
e um connection.updated com data.status: "active" quando a ativação termina. Guarde o connectionId junto do workspace. Nos eventos de conexão, data é a conexão inteira, no formato de GET /v1/connections/:id. O formato completo de cada evento está no catálogo.
6. Envie a primeira mensagem
Mensagem livre (texto, mídia, botões) só sai dentro da janela de atendimento de 24 h, que abre quando o contato escreve. Fora dela, use um template aprovado.
curl -X POST https://api.metahub.matratecnologia.com/v1/connections/$CONNECTION_ID/messages \
-H "Authorization: Bearer $HUB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-8812-aviso-entrega" \
-d '{
"to": { "userId": "BR.13491208655302741918" },
"type": "text",
"text": { "body": "Seu pedido saiu para entrega." }
}'
const sent = await hub<{ message: { id: string; state: string } }>(
`/connections/${connectionId}/messages`,
{
method: 'POST',
headers: { 'idempotency-key': `pedido-8812-aviso-entrega` },
body: {
to: { userId: 'BR.13491208655302741918' },
type: 'text',
text: { body: 'Seu pedido saiu para entrega.' },
},
},
)
// 202: entrou na fila. sent.data.message.state === 'pending'
Idempotency-Keyé obrigatório. Use um valor ligado ao fato de negócio (o id do pedido e o tipo do aviso), não um valor aleatório por tentativa. Se a requisição cair, repita com a mesma chave: o hub devolve200com o envio existente, sem mandar de novo.to.userIdé o BSUID do contato, que chega em toda mensagem recebida (data.message.from.userId). Useto.phone(só dígitos, com DDI) quando tiver apenas o telefone.
7. Acompanhe o resultado
O envio muda de estado e cada mudança chega como whatsapp.message.status:
source: "hub": o estado do envio no hub (accepted,failed,uncertain);source: "meta": a entrega no aparelho (sent,delivered,read,failed).
{
"type": "whatsapp.message.status",
"data": {
"messageId": "...",
"wamid": "wamid.HBgM...",
"source": "meta",
"status": "delivered",
"timestamp": "2026-10-23T15:00:03.000Z"
}
}
Se o webhook atrasar, consulte direto: GET /v1/connections/:connectionId/messages/:messageId. O estado uncertain pede cuidado: não reenvie às cegas (ver Estados do envio).
Checklist antes de ir para produção
- Chave de API e segredo do webhook num cofre, só no backend.
- Receptor verifica a assinatura sobre o corpo cru e recusa timestamp com mais de 5 min de diferença.
- Receptor responde
2xxem menos de 10 s e processa depois. - Eventos deduplicados pelo
iddo envelope. - Estado decidido por
createdAtdo envelope, não pela ordem de chegada. - Todo envio com
Idempotency-Keyderivada do fato de negócio. -
uncertaintratado como "não sei", sem reenvio automático. - Erros tratados pelo
code, com valor desconhecido caindo num erro genérico. - Respostas
429respeitam oRetry-After.