Entrega autenticada opcional de eventos Meta
Modo direto como padrão, adesão por integração e Meta Access Token preservado para chamadas diretas.
Versão: 2026-10-24. Produto: HookCloud Partner. Referência online: https://developers.hookcloud.app/authenticated-webhooks.html
Escolha o transporte por integração
O modo direto continua sendo o padrão: a Meta entrega os eventos elegíveis ao callback aplicado ao número. Contratar um pacote, conectar um número ou salvar um webhook global não habilita automaticamente a entrega autenticada.
No serviço opcional, a HookCloud valida a assinatura original da Meta antes de processar o corpo, confere o vínculo da integração, armazena temporariamente o envelope cifrado e entrega os mesmos bytes ao receptor com uma assinatura exclusiva. Contratação com pagamento confirmado (ou concessão interna expressa) e preparação do receptor são obrigatórias. O Meta Access Token da instância continua disponível para chamadas diretas à Graph API; este serviço não transforma a HookCloud em uma API de envio de mensagens.
Contratação, franquia e pagamento
Webhook Autenticado + Fila custa R$ 99,90 por mês por conta Partner, com 100.000 unidades por ciclo mensal. O excedente custa R$ 0,90 a cada 1.000 unidades, proporcionalmente: 1.500 unidades excedentes custam R$ 1,35. A cobrança não arredonda o consumo para blocos inteiros de mil. As tarifas de mensagens da Meta são separadas.
A franquia é compartilhada entre todas as integrações autenticadas da conta, dentro da capacidade de números já contratada. Não há uma mensalidade deste adicional por número. Não há trial nem acúmulo de unidades não utilizadas. A primeira mensalidade é antecipada e o serviço só é liberado após confirmação do pagamento pela Stripe; o excedente é apurado após o ciclo. O mês comercial segue o início e o fim retornados em billing.period, e não necessariamente o primeiro dia do mês do calendário.
No Portal, o owner acessa Endpoints → Webhook Autenticado + Fila → Contratar. A tela informa preço, franquia e excedente e pede concordância antes de abrir o checkout da Stripe. Voltar do checkout não confirma pagamento. O Portal consulta o estado no servidor, mostra a contratação confirmada e permite preparar e ativar individualmente as instâncias. O Admin apenas acompanha o estado comercial; uma habilitação técnica não substitui uma contratação paga. Contas internas de teste identificadas como internal são exceções expressas, sem cobrança automática.
O aceite inclui as Condições específicas do adicional, versão 2026-10-25. O checkout registra responsável, conta, versão, data e condições aceitas. A versão é um documento conservado em URL própria, não uma autorização para mudar retroativamente o preço contratado.
A barra mostra unidades consumidas e restantes, excedente estimado, período contratado e data da medição. Há avisos a partir de 80% e 100%. A barra é informativa: atingir a franquia inicia o excedente, não interrompe automaticamente as entregas. Os dados são coletados periodicamente, sem promessa de atualização instantânea. Confira usage_updated_at e summary_partial; integrações sem coleta não equivalem a consumo zero. A fatura final pode incluir admissões coletadas após sua última consulta.
Resposta de get-authenticated-webhook-billing | Significado |
|---|---|
billing.status | not_subscribed sem contratação; pending_payment aguardando pagamento; active contratado; past_due pagamento pendente; canceled encerrado; internal piloto interno sem cobrança. |
billing.enabled / checkout_available | Permissão comercial atual e disponibilidade de iniciar checkout. Não substituem as verificações técnicas de cada integração. |
billing.base_amount_cents / currency | Mensalidade em centavos e moeda. R$ 99,90 é 9990, moeda brl. |
billing.included_units / unit_bytes | Franquia por ciclo e tamanho do bloco: 100000 unidades e 65536 bytes. |
billing.excess_per_1000_cents | Valor do excedente: 90 centavos por mil unidades, aplicado proporcionalmente. |
billing.period.id, start, end | Identificador e limites exatos do ciclo contratado. Não derive o ciclo comercial das datas do relatório técnico diário. |
billing.period.used_units, remaining_units, excess_units | Unidades do ciclo, saldo de franquia e excedente já observado. |
billing.period.estimated_excess_cents / percent_used | Estimativa financeira e percentual consumido; a coleta final pode alterar o total. |
billing.cancel_at_period_end | Renovação cancelada para o fim do período; não cancela pacotes de números. |
billing.breakdown[] / breakdown_limit | Atribuição de unidades por customer_id e instance_id; esta consulta mostra até 100 grupos. Os totais do ciclo consideram todos os grupos. O filtro local do Portal pesquisa apenas os grupos exibidos. |
billing.usage_updated_at / summary_partial | Data da medição e indicação de cobertura parcial. Não trate dados ausentes como zero. |
billing.renewal_processing / renewal_processing_grace_until | Conciliação da renovação de um ciclo anteriormente pago e seu limite de continuidade temporária, de até 24 horas. Não se aplica à primeira ativação nem à renovação cancelada e não se estende por novas tentativas. |
billing.billing_attention | Conferência interna do fechamento: requires_operator_review indica atenção da equipe; export_count e invoice_count contam registros de consumo e faturas a conferir. codes contém somente relay_billing_export_uncertain ou relay_billing_invoice_requires_review. Não significa pendência do cliente ou da Meta e não altera, por si só, enabled. |
Se billing_attention.requires_operator_review vier como true, apresente: “Estamos conferindo o fechamento do consumo. Não é necessário contratar novamente; o suporte pode acompanhar esta conferência.” Continue respeitando billing.enabled e o estado técnico da integração. Não gere outro checkout, não repita uma cobrança e não mude o transporte para entrega sem assinatura por causa desse aviso. O suporte confere o resultado financeiro antes de qualquer repetição.
O GET comercial pode ser consultado com webhook.read ou pela sessão autorizada do Portal. Checkout e cancelamento exigem sessão JWT do owner; a Partner API Key não autoriza compras. create-authenticated-webhook-checkout exige terms_accepted: true e terms_version: '2026-10-25' e devolve checkout_url. cancel-authenticated-webhook-subscription recebe cancel_at_period_end: true; envie false para manter a renovação enquanto isso ainda for permitido. Todas as mutações exigem Idempotency-Key e não devem ser repetidas cegamente após timeout.
Somente na renovação de um ciclo antes pago, pode existir continuidade temporária de até 24 horas para processamento da cobrança, com limite fixo devolvido pelo servidor. Não é teste gratuito nem prazo que se renova a cada tentativa. A primeira ativação continua exigindo pagamento confirmado e a renovação já cancelada não recebe essa continuidade. O consumo admitido no intervalo integra a apuração do ciclo correspondente. Depois do limite, a falta de confirmação pode suspender novas admissões; não transforme a suspensão em entrega sem assinatura. Eventos já aceitos permanecem sujeitos à fila e aos limites técnicos.
Cancelar a renovação encerra as renovações deste adicional, preserva o período já contratado e não elimina excedentes consumidos. Não cancela os pacotes de números nem revoga o token Meta. O retorno ao modo direto é uma decisão explícita e exige drenagem segura em cada integração. Falha de cobrança ou fim da contratação nunca significam autorização para enviar automaticamente POSTs sem assinatura. Mantenha o receptor enquanto houver eventos pendentes e confira o estado retornado antes de alterar sua infraestrutura.
O que conta como unidade
Uma unidade corresponde a um webhook único, autenticado e admitido de forma durável, com até 64 KiB (65.536 bytes originais). Acima disso, cada bloco adicional iniciado de 64 KiB é uma unidade: um corpo de 65.537 bytes conta duas. O cálculo é feito individualmente na admissão de cada envelope: ceil(bytes_originais / 65536). Não calcule unidades dividindo a soma mensal de bytes, pois isso produz outro resultado.
Uma unidade não é uma mensagem WhatsApp: o envelope pode conter uma mensagem, várias atualizações de status ou lotes de histórico. Duplicidades reconhecidas e tentativas adicionais de uma mesma entrega não contam novamente. Requisições inválidas ou recusadas antes da admissão não contam. Eventos aceitos contam mesmo quando o receptor fica indisponível e o prazo ou o limite de tentativas termina. Consumo anterior à contratação não é cobrado retroativamente.
Há três contratos diferentes de POST:
| Fluxo | Identificação | Verificador |
|---|---|---|
| Meta direta | object, entry, changes; X-Hub-Signature-256 | App Secret do aplicativo emissor. Não é o Meta Access Token. O App Secret da HookCloud não é compartilhado. |
| Meta pela entrega autenticada opcional | Mesmo envelope Meta, X-HookCloud-Delivery-Id, X-HookCloud-Integration-Id, X-HookCloud-Key-Id, X-HookCloud-Timestamp, X-HookCloud-Signature | Segredo exclusivo da integração e contrato do verificador fornecido nesta referência. |
| Eventos de aplicação HookCloud | X-HookCloud-Event, X-HookCloud-Delivery, assinatura sha256=... | Segredo do endpoint dos eventos de aplicação; cálculo timestamp + '.' + corpo_bruto. Não usar o segredo do transporte Meta. |
Não escolha um verificador apenas pela presença de X-HookCloud-Signature: os dois contratos HookCloud usam formatos e credenciais diferentes. Não faça fallback para aceitar o mesmo POST sem autenticação quando a assinatura do modo esperado falhar. A configuração no servidor deve estabelecer qual contrato e integração são aceitos em cada rota.
Preparar e ativar
- Configure a rota HTTPS final do seu receptor, com certificado válido, sem redirecionamento. A HookCloud bloqueia destinos privados e não autorizados; não use URLs com credenciais ou segredos na query string.
- Implemente o GET de verificação no padrão Meta. Compare
hub.verify_tokencom o token do seu servidor e, parahub.mode=subscribe, devolva somentehub.challengeem HTTP 200. O GET confirma o destino; não autentica POSTs. - Consulte
get-authenticated-webhook-service. O retorno informa a habilitação da sua conta, integrações e limites. A autorização real é feita no backend. - Chame
provision-authenticated-webhookpara uma instância sua, informando o destino validável e seu verify token. Guarde ointegration_idretornado. O callback atual ainda não é substituído pela preparação. - A resposta de preparação apresenta o segredo uma única vez. Salve
signing_secretekey_idno gerenciador de segredos do seu backend, associados à integração correta. Não registre resposta integral, token ou corpo em logs/APM. Não coloque o segredo na IA, no front-end, em URLs ou no repositório. - Configure o receptor com o verificador do exemplo. O receptor precisa aceitar o corpo bruto, conferir timestamp/assinatura/integração, deduplicar e persistir antes do 2xx.
- Somente após configurar o receptor, chame
activate-authenticated-webhookcomreceiver_ready: true. Esse campo é uma confirmação explícita, não um substituto da implementação. - Consulte o estado. Uma resposta de preparação ou início de ativação não prova que a mudança de callback terminou. Se houver resposta ambígua, use
reconcile-authenticated-webhooke leia o estado; não envie outra mutação com outra chave.
As mutações exigem Idempotency-Key. Use um UUID por intenção, guarde a intenção no seu backend e não reutilize a chave com outro corpo. Uma rede interrompida após POST pode esconder um sucesso remoto; não repita automaticamente. O retorno de uma consulta posterior não revela novamente o segredo.
As três consultas GET — get-authenticated-webhook-service, get-authenticated-webhook-usage e get-authenticated-webhook-billing — exigem escopo webhook.read quando feitas com Partner API Key. As mutações técnicas de preparação, ativação, reconciliação, drenagem e rotação exigem webhook.write. Essas consultas e mutações técnicas também aceitam sessão JWT de membro owner/admin. A exceção são as operações comerciais create-authenticated-webhook-checkout e cancel-authenticated-webhook-subscription: elas exigem sessão JWT do owner e não aceitam Partner API Key. As operações administrativas de habilitação não fazem parte da API do assinante. Na preparação, você pode informar uma URL HTTPS própria com seu token de verificação, ou selecionar um endpoint_id cadastrado do mesmo parceiro. Se enviar também uma URL com esse ID, ela precisa coincidir com o destino cadastrado.
No Portal: Endpoints → Webhook Autenticado + Fila. Depois da contratação confirmada, prepare, guarde o segredo, configure o servidor e ative individualmente a instância. Contas sem habilitação continuam no modo direto. Preparar uma instância não inicia por si só uma nova compra: contratação e ativação técnica são etapas diferentes.
Validar o POST e persistir
Use o exemplo de receptor Node.js publicado junto desta referência e valide sua implementação conforme a seção “Critérios de teste da sua implementação” deste guia. A assinatura v1=HEX usa HMAC-SHA256 com o segredo de 32 bytes decodificado de hexadecimal, sobre o prefixo UTF-8 v1\n{delivery_id}\n{timestamp}\n{integration_id}\n seguido dos bytes originais do corpo. Não altere delimitadores, ordem ou codificação. O X-HookCloud-Key-Id seleciona uma das chaves configuradas para a mesma integração; nunca busque segredos arbitrários apenas pelo header recebido.
- Capture o corpo antes de qualquer parser JSON. Em Express, monte
express.raw({ type: 'application/json', limit: '2mb', inflate: false })na rota antes deexpress.json(). - Não use
JSON.stringify(req.body)para tentar reconstruir os bytes assinados. - Limite o corpo recebido. O limite de envelope do serviço é 2 MiB; payloads maiores são recusados antes da aceitação. Isso é diferente do limite total da fila.
- Exija exatamente um valor para cada cabeçalho de assinatura. Rejeite headers duplicados ou malformados.
- Compare
integration_idcom a configuração privada do servidor; não use um ID enviado no corpo para escolher o segredo de outro cliente. - Verifique o timestamp em segundos com tolerância máxima de cinco minutos, mantendo o relógio do servidor sincronizado. O timestamp é da tentativa atual, não da criação da mensagem. Reentregas podem trazer timestamp/assinatura novos para o mesmo delivery ID.
- Confira o HMAC em tempo constante antes de interpretar o JSON ou produzir efeitos.
- Verifique também os IDs esperados de WABA/número/geração no contexto mantido pelo backend. Um payload válido não pode atualizar outra empresa do seu SaaS.
- Em uma única transação durável, reserve a chave
(integration_id, delivery_id)e grave o trabalho/efeito. Retorne 2xx somente após commit. Um registro ainda pendente não é duplicata concluída. - Deduplique também o evento de negócio: mensagens pelo identificador WhatsApp e status pelo identificador/estado do fato. Um evento lógico pode vir reagrupado pela Meta em outro envelope, com outro delivery ID.
- Em falha de persistência, devolva erro para permitir recuperação. Não responda sucesso e depois descarte o corpo, nem espere o chatbot/IA terminar para confirmar.
O serviço não oferece execução exatamente uma vez nem ordem global. A fila do receptor pode ser uma tabela transacional com processamento de jobs; não exige um produto externo específico. Ela continua sendo necessária para separar a confirmação HTTP do processamento de atendimento, IA, mídias e campanhas.
Fila, recuperação e limites
O prazo máximo é de até 24 horas desde a primeira aceitação durável, limitado também a até 32 tentativas. Repetições não reiniciam esse prazo. As esperas crescem com backoff e pequena variação aleatória; não assuma horários exatos. O prazo pode vencer antes da tentativa 32.
Leia a capacidade efetiva retornada para sua integração. Há limite de eventos pendentes e bytes cifrados. 24 horas não significam capacidade ilimitada para todo o tráfego de um dia. Ao atingir capacidade antes de aceitar um novo envelope, a HookCloud retorna falha transitória à origem em vez de declarar que armazenou algo que não armazenou.
O corpo fica cifrado na fila temporária. Após confirmação 2xx do receptor, é removido da fila ativa. No esgotamento ou encerramento autorizado, o corpo também é removido e permanece somente o resultado técnico. Recibos sem conteúdo podem permanecer por sete dias para deduplicação.
As 24 horas são o limite de retenção operacional da aplicação, não uma garantia de apagamento físico de todas as cópias de infraestrutura nesse prazo. A infraestrutura SQLite de Durable Objects oferece recuperação de versões anteriores por até 30 dias, conforme a documentação oficial da Cloudflare sobre recuperação no tempo (PITR). Assim, versões cifradas anteriores podem permanecer nesse ciclo técnico de recuperação, mesmo após remoção da fila ativa. Elas não formam um histórico de mensagens consultável pelo Portal ou pela API do Partner.
Não existe replay de corpos já removidos. Seu SaaS é responsável por histórico, retenção de conversas, backups e política de tratamento dos dados. Não derive janelas de atendimento ou aprovação de templates do tempo de retenção da fila.
Trocar segredo e retornar ao modo direto
A rotação tem duas etapas: rotate-authenticated-webhook-secret com phase: 'prepare' gera uma nova chave exibida uma única vez; salve o rotation_id. Configure a nova chave no receptor mantendo a anterior. Depois confirme com phase: 'confirm' e o identificador da rotação. Observe o key_id e a expiração da chave anterior retornados pelo serviço. Não remova a chave anterior antes do prazo de transição e não registre os segredos.
A preparação expira após 24 horas ou quando substituída por outra preparação. Depois da confirmação, mantenha a chave anterior por até 60 minutos, conforme accept_previous_until. O serviço bloqueia outra rotação enquanto essa transição estiver ativa para não invalidar uma chave ainda necessária.
Para cancelar o transporte opcional, use drain-authenticated-webhook. A HookCloud coordena a restauração do callback direto verificado e a conclusão das entregas já aceitas no destino original. Acompanhe os estados em get-authenticated-webhook-service e use reconciliação quando indicada. Enquanto houver fila anterior, mantenha o receptor e a verificação de assinatura funcionando. Não troque uma flag local para fingir que a drenagem acabou.
A mudança de configuração pode levar até 15 segundos para se propagar, e uma requisição HTTP já iniciada pode ainda chegar ao receptor. Depois de restaurar o destino direto, há uma margem mínima de 30 segundos antes de declarar o serviço encerrado, além do término da fila anterior. Por isso, draining pode continuar mesmo com pending: 0. Cancelar ou revogar não desfaz um POST já enviado; confirme o estado final antes de desligar o receptor.
Cancelar este recurso não desconecta o WhatsApp, não revoga o Meta Access Token e não cancela a assinatura do cliente. Revogação de segurança ou perda de propriedade pode interromper entregas ao vínculo revogado; esse caso é diferente do cancelamento planejado. Corpos pendentes nunca devem ser enviados automaticamente a um novo proprietário ou outro cliente.
Consumo por cliente e período
Consulte get-authenticated-webhook-usage com from e to em datas UTC, ambas inclusivas, em intervalos de até 31 dias. Por exemplo, 2026-10-01 até 2026-10-31 cobre outubro. Não consulte datas futuras. Filtre por customer_id ou instance_id quando necessário e percorra limit/offset; a resposta informa pagination.total. As consultas não chamam a Meta.
Interprete os campos separadamente:
| Campo | Significado |
|---|---|
ingested_unique | Envelopes efetivamente admitidos uma vez na fila. Não é quantidade de mensagens WhatsApp. |
payload_bytes | Bytes brutos admitidos uma vez, antes da criptografia. |
duplicates | Repetições reconhecidas; não são novas admissões. |
delivery_attempts | Tentativas assumidas pelo processador; podem incluir falha anterior ao HTTP. |
retry_attempts | Tentativas adicionais do mesmo envelope. |
delivered | Entregas confirmadas com 2xx. Não prova que a IA respondeu ou que o software processou corretamente depois. |
expired / failed | Encerramento pelo prazo ou pelo limite de tentativas. |
connection_closed / configuration_changed / operator_canceled | Encerramentos por vínculo, configuração ou cancelamento; não são entregas. |
queue_rejected | Recusas de capacidade antes da aceitação. |
O relatório técnico usa period_basis: accepted_utc_day: resultados e retentativas continuam atribuídos ao dia UTC da admissão, mesmo quando ocorrem depois. Uma entrega admitida em outubro e concluída em novembro atualiza o registro de outubro; não crie outra admissão. collection: periodic e last_collected_at indicam que a leitura é uma fotografia periódica. Pendências e bytes ativos no status são um retrato da fila. Confira início da medição, cobertura e data de atualização. Ausência de observação não significa consumo zero. Não trate um contador cumulativo como fatura fechada. Para franquia e excedente comercial use get-authenticated-webhook-billing, com o ciclo exato; não some dias UTC como se os limites comerciais sempre fossem meia-noite.
coverage: post_upgrade_admissions_only informa que os registros abrangem somente envelopes admitidos após o início da medição atual da integração, exposto como usage_collection_started_at na consulta do serviço. O tráfego antigo não é reconstruído a partir de conversas nem de recibos expirados. Uma ausência de registro anterior a esse início não representa zero consumo histórico.
A atribuição segue parceiro → cliente → instância → integração → geração. Ela preserva o cliente da admissão; mover a instância depois não transfere o consumo anterior. O parceiro pode usar os agregados para criar sua própria política de cobrança por cliente no seu sistema, com preço, contrato e transparência próprios. A API não cobra o cliente final nem determina valor de revenda.
Critérios de teste da sua implementação
Teste dois clientes separados; corpo adulterado; assinatura inválida; timestamp vencido; chave errada; duplicidade simultânea; mesmo evento em outro envelope; indisponibilidade do banco; 503/timeout do receptor; retomada após falha; transição de chave; cancelamento com fila pendente; reconexão com nova geração; consulta indevida ao cliente de outro parceiro; virada de período. Nunca envie mensagens reais nem altere callbacks de terceiros sem autorização.
Referências: configuração inicial do webhook, eventos de aplicação HookCloud, campos Meta, referência oficial Meta.
Exemplo de receptor e downloads
O exemplo Node.js exige um adaptador de persistência transacional do seu sistema. Ele não simula sucesso nem guarda mensagens em memória. Configure signingKeys: [{ id, secret }] para a chave atual; durante rotação, adicione a anterior com acceptUntil. A propriedade legada signingSecret reconhece somente key_id=v1.
Baixar receptor Node.js Baixar guia para sua IA
Ver código completo do receptor
// Standalone Node.js 22+ example. TLS termination and a DURABLE transactional
// commitProcessing adapter are supplied by the subscriber's own application.
// No payload, URL/query string, signing key or Meta token is logged here.
import { createServer } from 'node:http';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export const MAX_WEBHOOK_BYTES = 2 * 1024 * 1024;
const MAX_CLOCK_SKEW_SECONDS = 300;
const HEADERS = Object.freeze({
integration: 'x-hookcloud-integration-id',
delivery: 'x-hookcloud-delivery-id',
timestamp: 'x-hookcloud-timestamp',
signature: 'x-hookcloud-signature',
});
const validId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,160}$/.test(value);
const validKeyId = value => value === 'v1' || typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
function secretBytes(value) {
if (value instanceof Uint8Array && value.byteLength === 32) return Buffer.from(value);
if (typeof value === 'string' && /^[a-fA-F0-9]{64}$/.test(value)) return Buffer.from(value, 'hex');
if (typeof value === 'string' && /^[A-Za-z0-9+/]{43}=$/.test(value)) {
const decoded = Buffer.from(value, 'base64');
if (decoded.byteLength === 32 && decoded.toString('base64') === value) return decoded;
}
throw new Error('invalid_receiver_configuration');
}
function parseHeaders(headers, expectedIntegrationId, nowSeconds) {
const integrationId = headers[HEADERS.integration];
const deliveryId = headers[HEADERS.delivery];
const timestampText = headers[HEADERS.timestamp];
const signature = headers[HEADERS.signature];
if (!validId(integrationId) || integrationId !== expectedIntegrationId || !validId(deliveryId) ||
typeof timestampText !== 'string' || !/^[1-9][0-9]{0,12}$/.test(timestampText) ||
typeof signature !== 'string' || !/^v1=[a-fA-F0-9]{64}$/.test(signature)) return null;
const timestamp = Number(timestampText);
if (!Number.isSafeInteger(nowSeconds) || Math.abs(nowSeconds - timestamp) > MAX_CLOCK_SKEW_SECONDS) return null;
return { integrationId, deliveryId, timestamp, signature };
}
function signingKeyRegistry({signingSecret,signingKeys}) {
if (signingKeys === undefined) return {legacy:true,keys:[{id:'v1',secret:secretBytes(signingSecret)}]};
if (signingSecret !== undefined || !Array.isArray(signingKeys) || signingKeys.length<1 || signingKeys.length>2) throw new Error('invalid_receiver_configuration');
const ids=new Set();
const keys=signingKeys.map(key=>{
if (!key || !validKeyId(key.id) || ids.has(key.id)) throw new Error('invalid_receiver_configuration');
ids.add(key.id);
const expires=key.acceptUntil===undefined?null:Date.parse(key.acceptUntil);
if (expires!==null&&!Number.isFinite(expires)) throw new Error('invalid_receiver_configuration');
return {id:key.id,secret:secretBytes(key.secret),expires};
});
// Only the current key has no expiry. Install the new key first, then request
// rotation; retain the previous key only until the API's overlap deadline.
if (keys.filter(key=>key.expires===null).length!==1) throw new Error('invalid_receiver_configuration');
return {legacy:false,keys};
}
function selectedSigningKey(registry, headers, nowSeconds) {
const supplied=headers['x-hookcloud-key-id'];
const id=registry.legacy&&supplied===undefined?'v1':supplied;
if (!validKeyId(id)) return null;
const key=registry.keys.find(key=>key.id===id);
if (!key || key.expires!=null&&nowSeconds*1000>=key.expires) return null;
return key.secret;
}
/** Verify before JSON.parse. expectedIntegrationId comes from server config. */
export function verifyHookCloudDelivery(rawBody, headers, {
integrationId, signingSecret, signingKeys, nowSeconds = Math.floor(Date.now() / 1000),
}) {
if (!(rawBody instanceof Uint8Array) || rawBody.byteLength > MAX_WEBHOOK_BYTES || !validId(integrationId)) return null;
const parsed = parseHeaders(headers, integrationId, nowSeconds);
if (!parsed) return null;
const key=selectedSigningKey(signingKeyRegistry({signingSecret,signingKeys}),headers,nowSeconds);
if (!key) return null;
const expected = createHmac('sha256', key)
.update(`v1\n${parsed.deliveryId}\n${parsed.timestamp}\n${parsed.integrationId}\n`, 'utf8')
.update(rawBody).digest();
const received = Buffer.from(parsed.signature.slice(3), 'hex');
if (!timingSafeEqual(expected, received)) return null;
return { integrationId: parsed.integrationId, deliveryId: parsed.deliveryId, timestamp: parsed.timestamp };
}
async function readRawBody(request) {
const declaredLength = request.headers['content-length'];
if (declaredLength !== undefined && (typeof declaredLength !== 'string' || !/^(0|[1-9][0-9]*)$/.test(declaredLength))) {
throw new Error('invalid_body');
}
if (Number(declaredLength) > MAX_WEBHOOK_BYTES) throw new Error('payload_too_large');
const chunks = [];
let total = 0;
for await (const chunk of request) {
if (!(chunk instanceof Uint8Array)) throw new Error('invalid_body');
total += chunk.byteLength;
if (total > MAX_WEBHOOK_BYTES) throw new Error('payload_too_large');
chunks.push(chunk);
}
return Buffer.concat(chunks, total);
}
function respond(response, status, message) {
if (response.destroyed || response.writableEnded) return;
response.writeHead(status, {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
...(status >= 400 ? { connection: 'close' } : {}),
});
response.end(message);
}
function hasSingleSignatureHeaders(request) {
for (const name of Object.values(HEADERS)) {
let count = 0;
for (let index = 0; index < request.rawHeaders.length; index += 2) {
if (request.rawHeaders[index].toLowerCase() === name) count += 1;
}
if (count !== 1) return false;
}
let keyHeaders=0;
for(let index=0;index<request.rawHeaders.length;index+=2)if(request.rawHeaders[index].toLowerCase()==='x-hookcloud-key-id')keyHeaders++;
if(keyHeaders>1)return false;
return true;
}
/**
* commitProcessing({ integrationId, deliveryId, payload }) MUST:
* - atomically lock/insert a unique (integrationId, deliveryId) receipt;
* - in the SAME durable transaction, apply business effects or enqueue local work;
* - perform business deduplication as well (message id; status id + status);
* - return 'committed' only after commit, or 'duplicate' after checking a committed
* receipt. Roll back and throw on failure. A pending receipt is NOT a duplicate.
*
* Do not replace the adapter with a Map/Set or return before durable commit.
* This sample deliberately has no message database and no success stub.
*/
export function createAuthenticatedWebhookHandler({
integrationId, signingSecret, signingKeys, verifyToken,
path = '/webhooks/hookcloud', commitProcessing,
}) {
if (!validId(integrationId) || typeof commitProcessing !== 'function' ||
typeof path !== 'string' || !/^\/[A-Za-z0-9/_-]{1,200}$/.test(path) ||
(verifyToken !== undefined && (typeof verifyToken !== 'string' || verifyToken.length < 1 || verifyToken.length > 512))) {
throw new Error('invalid_receiver_configuration');
}
const registry = signingKeyRegistry({signingSecret,signingKeys});
const verificationDigest = verifyToken === undefined ? null : createHash('sha256').update(verifyToken).digest();
return async function authenticatedWebhook(request, response) {
try {
const url = new URL(request.url, 'https://receiver.invalid');
if (url.pathname !== path) return respond(response, 404, 'Not found');
// Optional initial GET challenge, separate from POST delivery signatures.
if (request.method === 'GET' && verificationDigest) {
const mode = url.searchParams.get('hub.mode');
const supplied = url.searchParams.get('hub.verify_token');
const challenge = url.searchParams.get('hub.challenge');
if (['hub.mode', 'hub.verify_token', 'hub.challenge'].some(name => url.searchParams.getAll(name).length !== 1) ||
mode !== 'subscribe' || typeof supplied !== 'string' || supplied.length > 512 ||
typeof challenge !== 'string' || challenge.length < 1 || challenge.length > 512 ||
!timingSafeEqual(verificationDigest, createHash('sha256').update(supplied).digest())) return respond(response, 403, 'Forbidden');
return respond(response, 200, challenge);
}
if (request.method !== 'POST') return respond(response, 405, 'Method not allowed');
if (request.headers['content-encoding'] !== undefined && request.headers['content-encoding'] !== 'identity') {
return respond(response, 415, 'Unsupported encoding');
}
if (!hasSingleSignatureHeaders(request) || !parseHeaders(request.headers, integrationId, Math.floor(Date.now() / 1000))) {
return respond(response, 401, 'Unauthorized');
}
let rawBody;
try { rawBody = await readRawBody(request); }
catch (error) { return respond(response, error.message === 'payload_too_large' ? 413 : 400, 'Request rejected'); }
const signingKey=selectedSigningKey(registry,request.headers,Math.floor(Date.now()/1000));
if (!signingKey) return respond(response,401,'Unauthorized');
// Use the already selected key with its exact header ID; do not try all
// secrets on a mismatch, which would bypass the rotation key identifier.
const verificationHeaders=registry.legacy&&request.headers['x-hookcloud-key-id']===undefined
? {...request.headers,'x-hookcloud-key-id':'v1'} : request.headers;
const verified = verifyHookCloudDelivery(rawBody, verificationHeaders, { integrationId,
signingKeys:[{id:request.headers['x-hookcloud-key-id']||'v1',secret:signingKey}] });
if (!verified) return respond(response, 401, 'Unauthorized');
let payload;
try { payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody)); }
catch { return respond(response, 400, 'Request rejected'); }
if (!payload || Array.isArray(payload) || payload.object !== 'whatsapp_business_account' || !Array.isArray(payload.entry)) {
return respond(response, 400, 'Request rejected');
}
try {
const result = await commitProcessing({ integrationId, deliveryId: verified.deliveryId, payload });
if (result !== 'committed' && result !== 'duplicate') throw new Error('uncommitted_delivery');
} catch { return respond(response, 503, 'Processing unavailable'); }
return respond(response, 204, '');
} catch { return respond(response, 400, 'Request rejected'); }
};
}
export function createAuthenticatedWebhookServer(options) {
const handler = createAuthenticatedWebhookHandler(options);
const server = createServer({ requestTimeout: 30000, headersTimeout: 10000, maxHeaderSize: 16384 }, (request, response) => {
void handler(request, response);
});
server.maxHeadersCount = 40;
server.maxRequestsPerSocket = 100;
return server;
}
// Express adaptation: mount express.raw({ type: 'application/json', limit: '2mb',
// inflate: false }) ON THIS ROUTE BEFORE any express.json() middleware. Verify
// req.body (Buffer) with verifyHookCloudDelivery, then parse and commit durably.
// Never use JSON.stringify(req.body) to recreate the signed bytes.
Consultar os campos de cada endpoint do serviço · Kit completo de integração
