> ## Documentation Index
> Fetch the complete documentation index at: https://docs.chargefy.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Entrega de Webhooks

> Como a Chargefy entrega webhooks: fila, retries, assinatura HMAC e verificação compatível com Standard Webhooks.

Todo webhook da Chargefy é entregue de forma **assíncrona**, **assinado com HMAC-SHA-256** e **reentregue automaticamente** em caso de falha. A assinatura segue o padrão [Standard Webhooks](https://www.standardwebhooks.com/) por completo — inclusive o formato do secret — então você verifica com qualquer biblioteca compatível, sem reimplementar HMAC manual.

<Info>
  **O que você precisa garantir no seu endpoint**

  1. Aceitar `POST` em uma URL **HTTPS**.
  2. Verificar a assinatura **antes** de processar o corpo.
  3. Responder com um status **2xx** em até **20 segundos**.

  O resto — fila, retry, rotação de secret, fan-out — é responsabilidade da Chargefy.
</Info>

## Como a entrega funciona

A entrega é desacoplada do evento. Quando algo acontece (um pagamento confirma, uma assinatura é criada), a Chargefy **grava o evento** e enfileira a entrega; um worker dedicado drena a fila e faz o `POST`. Isso garante que um endpoint lento ou fora do ar nunca trava a operação que originou o evento.

```mermaid theme={}
flowchart LR
  E[Evento ocorre] --> Q[Fila de entrega]
  Q --> W[Worker de entrega]
  W -->|POST assinado| EP[Seu endpoint HTTPS]
  EP -->|2xx| OK[Entrega confirmada]
  EP -->|não-2xx / timeout| R[Reentrega automática]
  R --> W
```

| Etapa                 | O que acontece                                                                                              |
| --------------------- | ----------------------------------------------------------------------------------------------------------- |
| **1. Evento**         | Uma mudança em recurso público gera um evento com `id` (`evt_...`) e payload congelado naquele instante.    |
| **2. Enfileiramento** | O evento é gravado e enfileirado uma vez **por endpoint inscrito** naquele tipo.                            |
| **3. Entrega**        | Um worker drena a fila **a cada minuto**, monta os headers assinados e faz o `POST` para a URL do endpoint. |
| **4. Resultado**      | Resposta **2xx** marca a entrega como concluída. Qualquer outra coisa agenda uma reentrega.                 |

<Note>
  Cada endpoint inscrito recebe seu **próprio** evento, com `id` próprio. Se você tem dois endpoints ouvindo `payment_intent.succeeded`, o mesmo fato gera dois eventos independentes — cada um com seu ciclo de entrega e retry.
</Note>

### Parâmetros de entrega

| Parâmetro             | Valor                           | Origem                                                                                   |
| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- |
| Timeout por tentativa | **20 segundos**                 | Conexão abortada após esse limite.                                                       |
| Critério de sucesso   | **status 2xx** (`200`–`299`)    | `3xx`, `4xx`, `5xx` e timeout contam como falha.                                         |
| Máximo de tentativas  | **10**                          | Inclui a primeira tentativa.                                                             |
| Cadência de reentrega | **\~1 minuto** entre tentativas | O worker roda a cada minuto; um evento que falhou volta a ser elegível no próximo ciclo. |
| Janela total de retry | **\~10 minutos**                | Depois disso a entrega vira falha permanente.                                            |
| Método HTTP           | **`POST`**                      | Sempre.                                                                                  |
| Content-Type          | **`application/json`**          | Corpo é o JSON do evento.                                                                |

<Warning>
  A reentrega usa **cadência fixa de aproximadamente um minuto**, não backoff exponencial de horas. Se o seu endpoint ficar fora do ar por mais de \~10 minutos, o evento atinge o limite de tentativas e é marcado como falha permanente — recupere-o por [reentrega manual](#reentrega-manual) ou pela API depois que o endpoint voltar.
</Warning>

### Retenção do payload

O **payload** do evento é mantido por **30 dias**. Depois disso ele é esvaziado automaticamente (archival), mas os **metadados da entrega** (status, data, HTTP code, corpo de resposta truncado) permanecem para auditoria.

<Note>
  Reentrega manual só é possível **enquanto o payload existe**. Após o archival de 30 dias não há corpo para reenviar — a tentativa retorna erro indicando que o payload foi arquivado.
</Note>

## O envelope do evento

O corpo entregue é sempre o envelope canônico de evento. O recurso afetado vive em `data.object`, no mesmo shape público retornado pela API. As chaves de todo objeto JSON público seguem a ordem canônica: `id`, `object`, depois alfabética.

```json theme={}
{
  "id": "evt_123",
  "object": "event",
  "created_at": "2026-05-16T14:09:27+00:00",
  "data": {
    "object": {
      "id": "pi_123",
      "object": "payment_intent",
      "amount": 9900,
      "currency": "brl",
      "status": "succeeded"
    }
  },
  "livemode": true,
  "organization": "org_123",
  "request": {
    "id": "req_123"
  },
  "type": "payment.intent.succeeded"
}
```

| Campo                      | Tipo      | Descrição                                                                                                                     |
| -------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `id`                       | `string`  | ID único do evento. É **idêntico** ao header `webhook-id` — use-o para [idempotência](#idempotência).                         |
| `object`                   | `string`  | Sempre `"event"`.                                                                                                             |
| `created_at`               | `string`  | ISO 8601 do momento em que o evento foi criado.                                                                               |
| `data.object`              | `object`  | Objeto público completo no estado atual. Mesmo DTO do `GET` single.                                                           |
| `data.previous_attributes` | `object`  | **Só em eventos de atualização.** Contém apenas os campos alterados, com o valor **anterior**. Ausente em eventos de criação. |
| `livemode`                 | `boolean` | `true` em produção, `false` em teste.                                                                                         |
| `organization`             | `string`  | Organização que **originou** o evento. Veja [fan-out de plataforma](#fan-out-para-plataformas).                               |
| `request.id`               | `string`  | Request (`req_*`) que originou o evento, quando disponível.                                                                   |
| `type`                     | `string`  | Tipo do evento (`<recurso>.<ação>`). Catálogo em [Eventos](/integrate/webhooks/events).                                       |

<Tip>
  `data.previous_attributes` traz **só** o diff. Para `customer.updated` onde só o e-mail mudou, você recebe `{ "email": "antigo@email.com" }` — o valor novo já está em `data.object`. A resposta direta da API de update **não** carrega diff; o diff vive apenas no webhook.
</Tip>

## Assinatura

Cada requisição carrega os headers abaixo. A assinatura cobre o `id`, o `timestamp` e o **corpo bruto** — qualquer re-serialização do corpo invalida a assinatura.

| Header              | Descrição                                                                             | Exemplo                  |
| ------------------- | ------------------------------------------------------------------------------------- | ------------------------ |
| `webhook-id`        | ID único do evento. Igual ao `id` do corpo.                                           | `evt_123`                |
| `webhook-timestamp` | Unix timestamp em **segundos** de quando a entrega foi assinada.                      | `1747405767`             |
| `webhook-signature` | Uma ou mais assinaturas, separadas por **espaço**. Cada uma no formato `v1,<base64>`. | `v1,K7gNU3sdo+OL0wNh...` |
| `content-type`      | Sempre `application/json`.                                                            | `application/json`       |
| `user-agent`        | Identifica o emissor.                                                                 | `chargefy.io webhooks`   |

### Formato do secret

Cada endpoint tem um secret no formato canônico:

```text theme={}
whsec_<base64 de 32 bytes aleatórios>
```

Os bytes **decodificados** do base64 (após o prefixo `whsec_`) são a **chave** do HMAC. Esse é exatamente o ponto que garante compatibilidade com as bibliotecas Standard Webhooks: você passa o secret inteiro, com prefixo, e a lib cuida da decodificação.

<Warning>
  **Secrets no formato antigo `chargefy_whs_...` foram descontinuados.** Endpoints que ainda carregam um secret legado falham na assinatura — a entrega é registrada como erro pedindo a rotação. Gere um novo secret em **Configurações → Webhooks → Resetar secret**.
</Warning>

### Algoritmo de assinatura

A string assinada é a concatenação de três partes separadas por ponto:

```text theme={}
assinatura = HMAC_SHA256(
  chave = base64_decode(secret_sem_prefixo_whsec_),
  dados = `${webhook-id}.${webhook-timestamp}.${corpo_bruto}`
)
header  = "v1," + base64(assinatura)
```

| Componente      | Valor                                                                                                            |
| --------------- | ---------------------------------------------------------------------------------------------------------------- |
| Algoritmo       | HMAC com SHA-256.                                                                                                |
| Chave           | Bytes **decodificados** do base64 do secret (não a string crua).                                                 |
| Mensagem        | `${webhook-id}.${webhook-timestamp}.${raw_body}` — IDs e timestamp como strings, corpo exatamente como recebido. |
| Saída no header | `v1,` + assinatura em base64.                                                                                    |

## Verificação

A forma mais simples e segura é usar uma **biblioteca compatível com Standard Webhooks**: ela decodifica o secret, valida a janela de timestamp, faz comparação constant-time e suporta múltiplas assinaturas durante rotação. Existe implementação oficial em Node, Python, Ruby, Go, PHP, Java, C# e Rust.

<CodeGroup>
  ```typescript Node.js / Express theme={}
  import { Webhook } from 'svix';
  import express from 'express';

  const app = express();
  const wh = new Webhook(process.env.CHARGEFY_WEBHOOK_SECRET!); // whsec_...

  // IMPORTANTE: use raw body — qualquer re-serialização quebra a assinatura.
  app.post('/webhooks/chargefy', express.raw({ type: 'application/json' }), (req, res) => {
    let evt: {
      id: string;
      object: 'event';
      type: string;
      organization: string;
      created_at: string;
      livemode: boolean;
      data: { object: unknown; previous_attributes?: Record<string, unknown> };
    };
    try {
      evt = wh.verify(req.body, {
        'webhook-id': req.headers['webhook-id'] as string,
        'webhook-timestamp': req.headers['webhook-timestamp'] as string,
        'webhook-signature': req.headers['webhook-signature'] as string,
      }) as typeof evt;
    } catch {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    switch (evt.type) {
      case 'payment_intent.succeeded':
        handlePaymentIntentSucceeded(evt.data.object);
        break;
      case 'customer.updated':
        handleCustomerUpdated(evt.data.object, evt.data.previous_attributes);
        break;
    }

    res.status(200).json({ received: true });
  });
  ```

  ```typescript Next.js (App Router) theme={}
  import { NextRequest, NextResponse } from 'next/server';
  import { Webhook } from 'svix';

  const wh = new Webhook(process.env.CHARGEFY_WEBHOOK_SECRET!); // whsec_...

  export async function POST(req: NextRequest) {
    const body = await req.text(); // raw — não use req.json()
    let evt: {
      id: string;
      object: 'event';
      type: string;
      organization: string;
      created_at: string;
      livemode: boolean;
      data: { object: unknown; previous_attributes?: Record<string, unknown> };
    };
    try {
      evt = wh.verify(body, {
        'webhook-id': req.headers.get('webhook-id')!,
        'webhook-timestamp': req.headers.get('webhook-timestamp')!,
        'webhook-signature': req.headers.get('webhook-signature')!,
      }) as typeof evt;
    } catch {
      return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
    }

    // ... despacho por evt.type

    return NextResponse.json({ received: true });
  }
  ```

  ```python Python / Flask theme={}
  from svix.webhooks import Webhook, WebhookVerificationError
  from flask import Flask, request, jsonify
  import os

  app = Flask(__name__)
  wh = Webhook(os.environ['CHARGEFY_WEBHOOK_SECRET'])  # whsec_...

  @app.post('/webhooks/chargefy')
  def chargefy_webhook():
      try:
          evt = wh.verify(request.get_data(), dict(request.headers))
      except WebhookVerificationError:
          return jsonify(error='Invalid signature'), 401

      if evt['type'] == 'payment_intent.succeeded':
          # handle...
          pass

      return jsonify(received=True), 200
  ```
</CodeGroup>

<Warning>
  **Sempre receba o corpo bruto** (`express.raw()`, `req.text()`, `request.get_data()`). Se o corpo for parseado como JSON antes da verificação, a serialização pode diferir do que foi assinado e a assinatura não vai bater.
</Warning>

### Verificação manual

Se preferir não usar uma biblioteca, o algoritmo é direto:

<Steps>
  <Step title="Valide a janela de tempo">
    Rejeite se `webhook-timestamp` estiver fora de **±5 minutos** do horário atual. Isso bloqueia replay de requisições antigas capturadas.
  </Step>

  <Step title="Monte a string assinada">
    `signed = ${webhook-id}.${webhook-timestamp}.${raw_body}`, usando o **corpo bruto** exatamente como recebido.
  </Step>

  <Step title="Decodifique o secret">
    Remova o prefixo `whsec_` e decodifique o restante de base64. Esses **bytes** são a chave do HMAC.
  </Step>

  <Step title="Calcule e compare">
    `esperado = base64(HMAC_SHA256(chave, signed))`. Compare (constant-time) contra **cada** assinatura no header `webhook-signature`, ignorando o prefixo `v1,`. Aceite se **qualquer uma** bater.
  </Step>
</Steps>

```typescript Verificação manual (TypeScript / Web Crypto) theme={}
async function verify(rawBody: string, headers: Headers, secret: string) {
  const id = headers.get('webhook-id')!;
  const ts = headers.get('webhook-timestamp')!;
  const sigHeader = headers.get('webhook-signature')!;

  // 1. Janela de ±5 minutos
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  // 2. String assinada
  const signed = `${id}.${ts}.${rawBody}`;

  // 3. Secret → bytes da chave
  const bin = atob(secret.replace(/^whsec_/, ''));
  const keyBytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey(
    'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );

  // 4. HMAC → base64 → comparação contra cada assinatura
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signed));
  const expected = btoa(String.fromCharCode(...new Uint8Array(mac)));
  return sigHeader
    .split(' ')
    .map((part) => part.replace(/^v1,/, ''))
    .some((candidate) => candidate === expected); // use comparação constant-time em produção
}
```

## Rotação de secret e múltiplas assinaturas

O header `webhook-signature` pode conter **mais de uma** assinatura, separadas por espaço:

```text theme={}
webhook-signature: v1,assinatura_secret_antigo v1,assinatura_secret_novo
```

Isso permite **rotacionar o secret sem downtime**: durante a janela de transição, a entrega é assinada com ambos os secrets, e seu endpoint aceita o evento se **qualquer uma** das assinaturas for válida. As bibliotecas compatíveis já fazem essa varredura automaticamente; na verificação manual, itere sobre todas as assinaturas do header.

<Steps>
  <Step title="Gere o novo secret">
    Em **Configurações → Webhooks**, use **Resetar secret**. O endpoint passa a assinar com o novo secret.
  </Step>

  <Step title="Atualize seu servidor">
    Coloque o novo `whsec_...` na configuração do seu endpoint e faça o deploy.
  </Step>

  <Step title="Confirme as entregas">
    Verifique no painel de entregas que os eventos voltaram a chegar com `2xx` usando o novo secret.
  </Step>
</Steps>

## Idempotência

Reentregas e reenvios manuais significam que **o mesmo evento pode chegar mais de uma vez**. Trate a entrega como *at-least-once*: garanta que processar o mesmo evento duas vezes não duplica efeitos no seu sistema.

O campo `id` do envelope (igual ao header `webhook-id`) é estável por evento. Use-o como chave de deduplicação:

<Steps>
  <Step title="Persista o id do evento">
    Ao receber, tente gravar `evt_...` em uma tabela com restrição de unicidade.
  </Step>

  <Step title="Pule duplicatas">
    Se o `id` já existe, responda `200` e **não** reprocesse — a Chargefy já considera a entrega concluída.
  </Step>

  <Step title="Confie no estado, não na ordem">
    A ordem de entrega entre eventos diferentes **não** é garantida. Use sempre `data.object` como verdade do estado atual, em vez de assumir sequência.
  </Step>
</Steps>

## Fan-out para plataformas

Quando uma organização conectada a uma plataforma origina um evento, a entrega vai para:

1. Os endpoints da **própria organização** que originou o evento (se ela tiver algum inscrito).
2. Os endpoints de **cada plataforma ativa** que tem essa organização como conectada.

O payload é **idêntico** nos dois casos — só muda o endpoint destinatário. Em ambos, o campo top-level `organization` aponta para a **organização que originou** o evento (a organização conectada filha), nunca para a organização dona da plataforma.

<Note>
  Isso permite que a plataforma identifique de qual organização conectada o evento veio lendo `organization`, sem precisar de nenhum campo extra de relacionamento. Cada fan-out gera um evento com `id` próprio, então a [idempotência](#idempotência) continua valendo por destinatário.
</Note>

## Reentrega manual

Se um endpoint ficou fora do ar e o evento esgotou as tentativas automáticas — ou se você só quer reprocessar um evento — é possível **reenviar manualmente** pelo dashboard, em **Configurações → Webhooks → Entregas**.

| Característica    | Comportamento                                                                                        |
| ----------------- | ---------------------------------------------------------------------------------------------------- |
| Disponibilidade   | Enquanto o payload existir (até **30 dias** após o evento).                                          |
| Eventos elegíveis | Tanto os que falharam quanto os que já tiveram sucesso.                                              |
| Endpoint removido | Não é possível reenviar — não há destino.                                                            |
| Histórico         | Um reenvio que falha **não** apaga um sucesso anterior; cada tentativa registra sua própria entrega. |

<Tip>
  Para validar seu endpoint contra o **shape mais recente** de um recurso sem esperar atividade real, use o reenvio — o payload é reconstruído a partir do estado atual no momento do reenvio.
</Tip>

## Desenvolvimento local

Para testar webhooks na sua máquina, exponha a porta local com um tunnel HTTPS e cadastre a URL gerada como endpoint:

<CodeGroup>
  ```bash ngrok theme={}
  npm install -g ngrok
  ngrok http 3000
  # Use a URL https://<sub>.ngrok.io/webhooks/chargefy como endpoint
  ```

  ```bash localtunnel theme={}
  npm install -g localtunnel
  lt --port 3000
  # Use a URL gerada como endpoint
  ```
</CodeGroup>

## Boas práticas

<AccordionGroup>
  <Accordion title="Responda rápido (menos de 20s)">
    Verifique a assinatura, persista o evento e responda `200` imediatamente. Processe a lógica pesada em background. Acima de 20 segundos a entrega é considerada falha e reentregue.
  </Accordion>

  <Accordion title="Verifique a assinatura sempre">
    Nunca processe um webhook sem validar a assinatura HMAC. Sem isso, qualquer um que conheça sua URL pode forjar eventos.
  </Accordion>

  <Accordion title="Dedupe pelo id do evento">
    Grave o `id` (`evt_...`) e ignore repetições. Reentregas e reenvios manuais tornam a entrega at-least-once.
  </Accordion>

  <Accordion title="Use HTTPS">
    URLs de webhook devem usar HTTPS. Endpoints HTTP não são aceitos.
  </Accordion>

  <Accordion title="Monitore as entregas">
    Acompanhe o status em **Configurações → Webhooks → Entregas** e investigue falhas persistentes antes que esgotem as tentativas.
  </Accordion>
</AccordionGroup>

## Solução de problemas

| Sintoma                                | Causa provável                                                     | O que fazer                                                                             |
| -------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| Você responde `401`/erro de assinatura | Secret errado, corpo já parseado, ou secret legado `chargefy_whs_` | Use o secret `whsec_...` correto, receba o corpo **bruto** e rotacione secrets antigos. |
| Eventos param de chegar                | URL inválida, não-HTTPS, firewall, ou endpoint removido            | Confira a URL (HTTPS) e o status do endpoint no dashboard.                              |
| Tudo chega como timeout                | Handler lento (> 20s)                                              | Responda `200` na hora e processe em background.                                        |
| Evento recebido mais de uma vez        | Reentrega após falha ou reenvio manual                             | Dedupe pelo `id` do evento.                                                             |
| Falha permanente após \~10 min         | Endpoint indisponível além da janela de retry                      | Resolva o endpoint e use a [reentrega manual](#reentrega-manual).                       |

## Próximos passos

<CardGroup cols={2}>
  <Card title="Eventos disponíveis" icon="bell" href="/integrate/webhooks/events">
    Catálogo completo de eventos e o shape de cada `data.object`.
  </Card>

  <Card title="API Reference" icon="code" href="/api-reference/introduction">
    Recursos públicos, autenticação e convenções da API.
  </Card>
</CardGroup>
