---
name: jungle-api-integracao
description: >
  Integra o backend de um seller à API de pagamentos da Jungle Pagamentos: criar cobrança PIX
  (POST /gateway/charges), criar sessão de checkout hospedado (POST /checkout/sessions),
  receber e verificar webhooks assinados (transaction.*, withdrawal.*), reconciliar
  transações e sacar via API (POST /gateway/withdrawals) com assinatura HMAC e
  Idempotency-Key. Use sempre que o usuário pedir para conectar loja, sistema, ERP, bot ou
  app à Jungle, gerar PIX, tratar webhook da Jungle, conciliar pagamentos ou automatizar saque.
---

# Integração com a API do Seller — Jungle Pagamentos

Você está integrando um sistema à API REST da Jungle Pagamentos. Siga este documento à risca:
ele é o contrato público em produção. **Não invente campos, headers, status ou eventos** que
não estejam aqui. Documentação navegável: https://jungle-docs.pages.dev

## 0. Regras que você nunca pode quebrar

1. **Base URL:** `https://app.junglepagamentos.com/api` (produção). Use o host que o seller
   informar, se for outro.
2. **Valores monetários em REAIS** (número decimal, ex.: `149.90`) no request, na resposta e
   nos webhooks. Não existe centavos na API do seller.
3. **Segredos no servidor.** `X-Api-Key` e `Webhook Secret` vivem em variável de ambiente do
   backend. Nunca em frontend, app mobile, repositório ou log.
4. **Envelope fixo:** sucesso `{ "success": true, "data": … }`; erro
   `{ "success": false, "error": "<mensagem>" }`. Sempre cheque `success` antes de ler `data`.
5. **Status em MAIÚSCULO, comparação exata:** `PAID`, `PENDING`, `FAILED`, `EXPIRED`, `MED`
   (transação); `PENDING`, `ENVIADO`, `RECUSADO`, `CANCELADO`, `BLOQUEADO` (saque).
6. **Só libere pedido em `transaction.paid` com `data.status === "PAID"`**, depois de validar
   o `X-Signature`. Em dúvida, confirme com `GET /gateway/transactions`.
7. **Webhook é idempotente:** o mesmo evento pode chegar mais de uma vez. Deduplique por
   `transactionId` (ou `withdrawalId`) + `event`.

## 1. Credenciais

O próprio seller gera no painel, por dashboard (workspace), em **Conta → API**:

| Credencial | Onde vai | Para quê |
|---|---|---|
| **API Key** | header `X-Api-Key` em toda requisição | identifica o workspace |
| **Webhook Secret** | nunca trafega; só assina | verificar `X-Signature` dos webhooks e **assinar saques** |

Respostas de autenticação:

| HTTP | `error` | Causa |
|---|---|---|
| 401 | `X-Api-Key header required` | header ausente |
| 401 | `Invalid API key` | chave inexistente/revogada |
| 403 | `Workspace is not active` | dashboard desativado |
| 403 | `Company account is not active` | conta inativa |

## 2. Criar cobrança PIX — `POST /gateway/charges`

```bash
curl -X POST 'https://app.junglepagamentos.com/api/gateway/charges' \
  -H 'X-Api-Key: SUA_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "product":  { "name": "Plano Pro", "value": 149.90 },
    "customer": {
      "name": "João Silva",
      "email": "joao@exemplo.com",
      "doc": "11122233344",
      "docType": "cpf",
      "phone": "11912345678"
    },
    "expiracaoSegundos": 3600,
    "callbackUrl": "https://seusite.com/webhooks/jungle",
    "metadata": { "pedido_id": "A-1001" }
  }'
```

| Campo | Tipo | Obrigatório | Observação |
|---|---|---|---|
| `product.name` | string | sim | nome do produto/serviço |
| `product.value` | number (reais) | sim | `> 0`, ex.: `149.90` |
| `customer.name` | string | sim | |
| `customer.email` | string (e-mail) | sim | |
| `customer.doc` | string | sim | CPF/CNPJ, mín. 11 caracteres |
| `customer.docType` | `cpf` \| `cnpj` | não (default `cpf`) | |
| `customer.phone` / `zip` / `street` / `number` / `district` / `city` / `state` | string | não | endereço opcional |
| `customer.ip` | string | não | IP do **comprador** (nunca o do seu servidor) — melhora atribuição de anúncio |
| `expiracaoSegundos` | number | não (default `3600`) | validade do QR |
| `callbackUrl` | string (URL) | não | recebe os webhooks `transaction.*` desta cobrança |
| `metadata` | object | não | objeto livre, ecoado nos webhooks — use para casar com seu pedido |

Resposta `201`:

```json
{
  "success": true,
  "data": {
    "transactionId": "b3f1…",
    "pixCode": "00020126580014br.gov.bcb.pix…",
    "expiresAt": "2026-07-23T13:00:00.000Z",
    "amount": 149.90,
    "feeAmount": 5.25,
    "netAmount": 144.65
  }
}
```

- `pixCode` é o **copia-e-cola** (EMV). Gere o QR Code a partir dele no seu front.
- Guarde `transactionId` junto do seu pedido — ele é a chave de deduplicação e reconciliação.
- Erro de validação (Zod) → `400`.

### 2.1 Rastreio de anúncio (opcional) — identificadores de clique no `metadata`

Chaves opcionais, todas string, lidas do `metadata` (forma plana **ou** aninhada em
`metadata.trackingParameters`). Quando a venda é paga, a plataforma envia a conversão pelo
servidor para os destinos que o seller configurou (GA4, Meta CAPI, TikTok Events API), usando
`transactionId` como `transaction_id`/`event_id` de deduplicação.

| Chave | Origem |
|---|---|
| `gclid`, `gbraid`, `wbraid` | parâmetros da URL do anúncio Google |
| `fbclid` | parâmetro da URL (Meta) |
| `fbp` | cookie `_fbp` — valor **cru** |
| `fbc` | cookie `_fbc` — valor **cru**; sem cookie, mande só `fbclid` (+ `clickAt`) |
| `ttclid` | parâmetro da URL (TikTok) |
| `ttp` | cookie `_ttp` |
| `gaClientId` | cookie `_ga` (ex.: `GA1.1.111.222`) |
| `clickAt` | ISO-8601 UTC da primeira captura do clique |
| `userAgent` (ou `user_agent`) | User-Agent do navegador do **comprador** |
| `sourceUrl` (ou `source_url`, `pageUrl`, `page_url`) | URL da página da compra |

Regras:
- **Nunca** hasheie, monte ou invente `fbp`/`fbc`. Valor cru ou nada.
- Se você também dispara a compra pelo pixel do navegador, use o **mesmo** `transactionId`
  como `event_id` — senão a venda conta duas vezes.
- Não duplique valor, moeda, e-mail, telefone ou documento no `metadata`; isso já vem em
  `product`/`customer`.
- `checkoutUtmifyToken` é chave reservada: se enviada, é descartada.

## 3. Consultar transações — `GET /gateway/transactions`

```bash
curl 'https://app.junglepagamentos.com/api/gateway/transactions?page=1&limit=20' \
  -H 'X-Api-Key: SUA_API_KEY'
```

Query: `page` (default `1`), `limit` (default `20`, máx. `100`). Retorna apenas as transações
do workspace da API Key.

```json
{
  "success": true,
  "data": [
    {
      "id": "b3f1…",
      "acquirerTxId": "abc123",
      "endToEndId": "E1234567890202607231240abcdef123",
      "status": "PAID",
      "productName": "Plano Pro",
      "productValue": 149.90,
      "amount": 149.90,
      "feeRate": 3.5,
      "feeAmount": 5.25,
      "netAmount": 144.65,
      "pixCode": "00020126580014br.gov.bcb.pix…",
      "pixExpiresAt": "2026-07-23T13:00:00.000Z",
      "paidAt": "2026-07-23T12:40:00.000Z",
      "customer": { "name": "João Silva", "email": "joao@exemplo.com", "doc": "11122233344", "phone": "11912345678" },
      "metadata": { "pedido_id": "A-1001" },
      "createdAt": "2026-07-23T12:00:00.000Z",
      "updatedAt": "2026-07-23T12:40:00.000Z"
    }
  ],
  "meta": { "total": 137, "page": 1, "limit": 20, "pages": 7 }
}
```

- `status`: `PENDING`, `PAID`, `FAILED`, `EXPIRED`, `MED`. `MED` = pago e depois contestado
  (devolvido) — trate como **não pago**.
- Cobrança vencida (`pixExpiresAt` no passado) aparece como `EXPIRED`.
- Boleto (emitido pelo checkout hospedado/painel) aparece com `pixCode: null` e o vencimento em
  `pixExpiresAt`. Trate `pixCode` nulo.

## 4. Checkout hospedado — `POST /checkout/sessions`

Cria uma sessão de pagamento numa página de checkout já montada pelo seller no painel
(**Vendas → Checkouts**; o `checkoutId` vem de lá). Mesma `X-Api-Key`.

```bash
curl -X POST 'https://app.junglepagamentos.com/api/checkout/sessions' \
  -H 'X-Api-Key: SUA_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "checkoutId": "d78a3545-0920-41a9-a0e9-5d2a8d89946c",
    "products": [
      { "name": "Camiseta", "value": 89.90, "quantity": 2,
        "image": "https://cdn.sualoja.com/p/1.webp", "variant": "Tamanho M" }
    ],
    "externalRef": "PEDIDO-A1001"
  }'
```

| Campo | Tipo | Obrigatório | Observação |
|---|---|---|---|
| `checkoutId` | string | sim | id do checkout no painel |
| `products[]` | array (mín. 1) | sim | itens do carrinho |
| `products[].name` | string | sim | |
| `products[].value` | number (reais) | sim | preço **unitário** |
| `products[].quantity` | integer | não (default `1`) | |
| `products[].image` | string (URL) | não | o nome é `image` — `imageUrl`/`photo` são descartados em silêncio |
| `products[].variant` | string | não | tamanho, cor etc. |
| `customer` | object | não | pré-preenche o comprador: `name`, `email`, `phone`, `docType`, `doc` |
| `externalRef` | string | não | 1–128 chars `A-Z a-z 0-9 . _ : -`; fora do formato é ignorado (a sessão é criada igual) |

Resposta `201`:

```json
{ "success": true, "data": { "sessionId": "…", "amount": 179.80, "checkoutUrl": "https://SEU-CHECKOUT/c/…" } }
```

- Redirecione o comprador para `checkoutUrl` **exatamente como veio**. O host pode ser o
  domínio próprio do seller ou um domínio da plataforma e pode mudar — nunca monte a URL.
- Também é possível anexar `?external_ref=PEDIDO-A1001` ao `checkoutUrl`.
- `externalRef`: a primeira referência gravada vence; não vai para webhooks; não coloque PII.
- Link não pago expira após 1 dia: crie uma sessão nova para vender de novo.

`GET /checkout/sessions/{sessionId}` (público, sem API Key) devolve a sessão como a página do
comprador a vê. Use para conferir o que foi gravado. O campo `expired: true` indica link
vencido. `checkout.theme` é apresentação — não baseie lógica nele.

O pagamento feito no checkout hospedado vira uma transação do workspace e aparece em
`GET /gateway/transactions`. O `metadata` dessas vendas (na listagem e em webhooks) traz
`checkoutId`, `sessionId`, `products`, `bumps`, `coupon`, `giftSelection`, `shipping` — útil
para fulfillment. Campos internos da plataforma nunca aparecem ali.

## 5. Webhooks de transação

Se a cobrança tiver `callbackUrl`, a Jungle faz `POST` nessa URL a cada mudança de status.

Headers: `Content-Type: application/json`,
`User-Agent: JunglePagamentos-Webhook-Dispatcher/1.0`, `X-Signature: <hmac hex>`.

```json
{
  "event": "transaction.paid",
  "data": {
    "transactionId": "b3f1…",
    "acquirerTxId": "abc123",
    "endToEndId": "E1234567890202607231240abcdef123",
    "status": "PAID",
    "amount": 149.90,
    "feeAmount": 5.25,
    "netAmount": 144.65,
    "productName": "Plano Pro",
    "customer": { "name": "João Silva", "email": "joao@exemplo.com", "doc": "11122233344", "phone": "11912345678" },
    "metadata": { "pedido_id": "A-1001" },
    "paidAt": "2026-07-23T12:40:00.000Z",
    "createdAt": "2026-07-23T12:00:00.000Z"
  }
}
```

Eventos: `transaction.created`, `transaction.paid`, `transaction.failed`,
`transaction.expired` (minúsculo, com ponto). `endToEndId` (E2E do Banco Central) pode vir
`null` antes do pagamento — guarde-o para conciliação bancária.

### 5.1 Verificar `X-Signature` (obrigatório no seu endpoint)

`X-Signature = hex(HMAC-SHA256(chave = Webhook Secret, mensagem = corpo BRUTO))`.
Leia o corpo cru (string/bytes) **antes** de parsear; re-serializar o JSON quebra a assinatura.
Compare em tempo constante.

Node.js (Express):

```ts
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const app = express()
const SECRET = process.env.JUNGLE_WEBHOOK_SECRET!

app.post('/webhooks/jungle', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body as Buffer
  const expected = createHmac('sha256', SECRET).update(raw).digest('hex')
  const got = String(req.header('x-signature') ?? '')
  const ok = got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected))
  if (!ok) return res.status(401).end()

  const evt = JSON.parse(raw.toString('utf8'))
  res.status(200).end()                 // responda rápido
  queue.push(evt)                       // processe de forma assíncrona e idempotente
})
```

Edge / Web Crypto (Cloudflare Workers, Deno, Bun, Next.js edge):

```ts
async function verify(raw: string, sig: string, secret: string) {
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(raw)))
  const hex = [...mac].map(b => b.toString(16).padStart(2, '0')).join('')
  if (hex.length !== sig.length) return false
  let diff = 0
  for (let i = 0; i < hex.length; i++) diff |= hex.charCodeAt(i) ^ sig.charCodeAt(i)
  return diff === 0
}
```

Python (Flask):

```python
import hmac, hashlib, os
from flask import request, abort

SECRET = os.environ["JUNGLE_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/jungle")
def jungle_webhook():
    raw = request.get_data()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Signature", "")):
        abort(401)
    evt = request.get_json()
    # enfileire e responda 200
    return "", 200
```

PHP:

```php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, getenv('JUNGLE_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
$evt = json_decode($raw, true);
http_response_code(200);
```

Checklist do endpoint: verificar assinatura → responder `200` rápido → processar em fila →
idempotente por `transactionId`+`event` → liberar só em `transaction.paid`/`PAID` → casar
pelo seu `metadata` (ex.: `pedido_id`).

## 6. Assinatura anti-replay das requisições (opt-in do workspace)

Se o workspace tiver "exigir assinatura" ligado, toda requisição a `/gateway/*` precisa de:

```
X-Signature: <hmac hex>
X-Timestamp: <epoch em ms>    # janela de ±5 min
X-Nonce: <uuid único>         # nunca reutilize
```

```
canonical   = METHOD + "\n" + path + "\n" + body + "\n" + timestamp + "\n" + nonce
X-Signature = hex(HMAC-SHA256(chave = API Key, mensagem = canonical))
# path = pathname sem query, com o prefixo /api (ex.: /api/gateway/charges); body = "" em GET
```

Assine exatamente a string que vai no corpo (serialize uma vez, envie a mesma string).
Erros: `401 Signature required for this workspace (…)`, `401 Missing X-Timestamp or X-Nonce`,
`401 Timestamp outside allowed window (±5min)`, `401 Replay detected (nonce reused)`,
`401 Invalid signature`. Sem a flag ligada, enviar assinatura é opcional (se enviada, é validada).

```ts
import { createHmac, randomUUID } from 'node:crypto'
const API = 'https://app.junglepagamentos.com/api'
const KEY = process.env.JUNGLE_API_KEY!

export async function jungle(method: 'GET' | 'POST', route: string, body?: unknown) {
  const path = new URL(API + route).pathname          // ex.: /api/gateway/charges
  const raw = body === undefined ? '' : JSON.stringify(body)
  const ts = Date.now().toString()
  const nonce = randomUUID()
  const sig = createHmac('sha256', KEY).update(`${method}\n${path}\n${raw}\n${ts}\n${nonce}`).digest('hex')
  const res = await fetch(API + route, {
    method,
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': KEY,
               'X-Signature': sig, 'X-Timestamp': ts, 'X-Nonce': nonce },
    body: method === 'GET' ? undefined : raw,
  })
  const json = await res.json()
  if (!json.success) throw new Error(`${res.status} ${json.error}`)
  return json
}
```

## 7. Saque via API — `POST /gateway/withdrawals`

Saca do saldo disponível para uma chave PIX. `X-Api-Key` sozinha **nunca** saca. Quatro camadas:

1. `X-Api-Key`.
2. Allowlist de IP (opt-in, cadastrada pelo seller no painel). Fora da lista → `403 IP não autorizado`.
3. Assinatura HMAC **com o Webhook Secret** (não a API Key), sempre obrigatória.
4. Header `Idempotency-Key` obrigatório. Reenviar a mesma key devolve o mesmo saque (`200`)
   sem debitar de novo.

```
canonical   = METHOD\npath\nbody\ntimestamp\nnonce\nidempotencyKey
X-Signature = hex(HMAC-SHA256(chave = Webhook Secret, mensagem = canonical))
```

Body (reais):

```json
{ "amount": 100.00, "callbackUrl": "https://sua-loja.com/wh/saque", "pixKey": "loja@exemplo.com", "pixKeyType": "email" }
```

- `amount` obrigatório, `> 0`. Pode existir mínimo configurado para a conta
  (`400 Valor abaixo do mínimo permitido para saque (R$ X)`).
- `pixKey` + `pixKeyType`: ambos ou nenhum. Sem eles, usa a chave PIX cadastrada no painel.
  `pixKeyType` ∈ `cpf | cnpj | email | phone | random` (aliases: `evp`, `aleatoria`,
  `chave_aleatoria` → `random`; `telefone`, `celular` → `phone`; `e-mail` → `email`).
- O saque **nasce `PENDING`**. Confirmação por webhook `withdrawal.paid` / `withdrawal.failed`
  ou por `GET /gateway/withdrawals/{id}`.

```ts
import { createHmac, randomUUID } from 'node:crypto'
const API = 'https://app.junglepagamentos.com/api'

export async function sacar(amount: number, idempotencyKey = randomUUID(),
                            dest?: { pixKey: string; pixKeyType: string }) {
  const path = '/api/gateway/withdrawals'
  const raw = JSON.stringify({ amount, ...dest })
  const ts = Date.now().toString()
  const nonce = randomUUID()
  const sig = createHmac('sha256', process.env.JUNGLE_WEBHOOK_SECRET!)
    .update(`POST\n${path}\n${raw}\n${ts}\n${nonce}\n${idempotencyKey}`).digest('hex')
  const res = await fetch(`${API}/gateway/withdrawals`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Api-Key': process.env.JUNGLE_API_KEY!,
      'X-Signature': sig, 'X-Timestamp': ts, 'X-Nonce': nonce, 'Idempotency-Key': idempotencyKey },
    body: raw,
  })
  const json = await res.json()
  if (!json.success) throw new Error(`${res.status} ${json.error}`)
  return json.data // { withdrawalId, status: 'PENDING', amount, feeAmount, netAmount, totalAmount }
}
```

Guarde o `idempotencyKey` junto do pedido de saque e **reuse-o em retries** (timeout, 5xx).
Gere um novo só para um saque novo.

Taxa de saque (quando configurada): `amount` = o que cai no PIX; `feeAmount` = taxa;
`netAmount` = `amount`; `totalAmount` = `amount + feeAmount` (o que sai do saldo). O saldo
precisa cobrir `totalAmount`, senão `400 Saldo insuficiente`.

Erros comuns: `400 Idempotency-Key header is required`, `400 Saldo insuficiente`,
`400 Informe pixKey/pixKeyType no body ou cadastre uma chave PIX no painel antes de sacar via API`,
`401` (assinatura), `403 IP não autorizado`,
`403 Saques bloqueados por determinação administrativa.`

`GET /gateway/withdrawals/{id}` (só `X-Api-Key`) →
`{ withdrawalId, status, amount, feeAmount, netAmount, totalAmount, pixKeyType, createdAt }`;
`404 Withdrawal not found`. Status: `PENDING | ENVIADO | RECUSADO | CANCELADO | BLOQUEADO`.

Webhooks do saque (se `callbackUrl`): `withdrawal.created`, `withdrawal.paid`,
`withdrawal.failed`, assinados em `X-Signature` com o Webhook Secret (verifique como na 5.1).

```json
{
  "event": "withdrawal.paid",
  "data": {
    "withdrawalId": "c9a2…", "amount": 100.00, "feeAmount": 0, "netAmount": 100.00,
    "totalAmount": 100.00, "status": "ENVIADO", "pixKeyType": "email",
    "pixKeyMasked": "lo***@exemplo.com", "failureReason": null,
    "createdAt": "2026-07-23T12:00:00.000Z", "updatedAt": "2026-07-23T12:05:00.000Z"
  }
}
```

`failureReason` só vem em `withdrawal.failed` (o saldo volta ao disponível).

## 8. Checklist final (confira antes de entregar)

- [ ] API Key e Webhook Secret em variáveis de ambiente do servidor.
- [ ] Valores em reais; nenhuma conversão para centavos no boundary.
- [ ] `success` checado em toda resposta; `error` logado sem vazar segredo.
- [ ] Endpoint de webhook lê corpo bruto, valida `X-Signature`, responde `200` rápido,
      é idempotente e só libera em `transaction.paid`/`PAID`.
- [ ] `MED` tratado como não pago; `pixCode` nulo tratado.
- [ ] `transactionId` e `endToEndId` persistidos para conciliação.
- [ ] Checkout hospedado: redireciona para `checkoutUrl` sem reconstruir o host.
- [ ] Saque: assinatura com Webhook Secret + `Idempotency-Key` reusada no retry; trata
      `PENDING` como estado inicial.
- [ ] Se "exigir assinatura" estiver ligado: `X-Signature`/`X-Timestamp`/`X-Nonce` em toda chamada.
