https://n7pay.com/api/v1
Documentação da API N7 Pay
O N7 Pay é uma plataforma global de orquestração de pagamentos. Esta API REST permite criar links de pagamento, gerenciar clientes, consultar transações e receber notificações em tempo real via webhooks.
Todos os endpoints retornam JSON. Datas seguem ISO 8601 (UTC). Valores monetários são strings decimais (ex: "299.90") para evitar erros de ponto flutuante.
Gateways suportados: Asaas (PIX, boleto, cartão em BRL) e Stripe (cartão internacional em USD, EUR e BRL). O roteamento é automático via Smart Routing.
Autenticação
Toda requisição deve incluir o header Authorization com sua chave de API:
Authorization: Bearer n7_sk_sand_SuaChaveAqui
Idempotency-Key
Envie o header Idempotency-Key
em POST /charges
e POST /charges/international
para que uma requisição repetida
devolva a mesma cobrança
em vez de criar outra. É opcional,
e sem ele o comportamento é o de sempre.
Idempotency-Key: 8f14e45f-ea0c-4b2e-9f5a-1c3d7b920a11
Use um valor por tentativa de pagamento, estável entre recargas da página: se o pagador recarregar enquanto a cobrança está sendo criada, é isso que impede a segunda cobrança. Gere um novo valor só quando for de fato um novo pedido.
| Situação | Resposta |
|---|---|
| Chave nova | A cobrança é criada normalmente |
| Mesma chave, mesmo corpo, já concluída |
A resposta original, idêntica, com o header
idempotency-replayed: true
|
| Mesma chave, primeira ainda em andamento |
409 request_in_progress. Aguarde e consulte o resultado;
não crie outra cobrança
|
| Mesma chave, corpo diferente | 422 idempotency_key_reused. Gere uma chave nova |
Uma cobrança que falha devolve a chave: você pode repetir a mesma requisição com a mesma chave. A chave vale por 24 horas e é escopada por empresa e por ambiente, então o mesmo valor pode ser usado uma vez em sandbox e uma vez em produção.
POST /fx/quotes o mesmo header tem outro comportamento.
Lá a chave não guarda uma resposta: ela fica presa à cotação,
e sem janela de 24 horas. Reusar a chave depois que a cotação
venceu devolve a mesma cotação vencida, não abre outra: vem com
expires_in: 0
e, por herança do registro, status: "active", que nesse caso não quer
dizer utilizável. Cobrar com ela falha em 422 quote_expired. Use uma chave nova a cada tentativa de cotação, por exemplo
versionando o valor. A cotação é um preço com validade curta, e repetir uma chave
ali pede de volta um preço que já passou.
| Endpoint | A chave guarda | Validade | Reuso depois |
|---|---|---|---|
POST /charges e /charges/international
|
A resposta inteira | 24 horas |
Repete a resposta com idempotency-replayed: true. Passadas as
24h, o mesmo valor pode ser usado de novo
|
POST /fx/quotes |
A cotação emitida | Sem prazo |
Devolve sempre aquela cotação, mesmo vencida. Confira expires_in
antes de cobrar
|
| Prefixo | Ambiente | Uso |
|---|---|---|
| n7_sk_sand_… | Sandbox | Secreta, no seu servidor. Testes: nenhuma cobrança real |
| n7_sk_live_… | Live | Secreta, no seu servidor. Produção: cobranças reais |
| n7_pk_sand_… e n7_pk_live_… | Os dois |
Publicável, pode ir no navegador. Só serve para POST /tokens; em qualquer outro endpoint a resposta é
403 publishable_key_not_allowed
|
A base URL é a mesma nos dois ambientes. Quem decide o ambiente é a chave, e
tudo o que ela cria fica naquele ambiente: um cliente, uma cobrança ou um token criados
com a chave sandbox não existem para a chave live, e vice-versa. Consultar com a chave
de um ambiente o id criado no outro responde 404.
Authorization. Versões anteriores desta página descreviam um JWT
emitido em POST /api/v1/auth/token, mas nenhum endpoint o aceita: use a
chave secreta no servidor e a publicável no navegador.
Ambiente Sandbox
O sandbox usa a infraestrutura de testes dos gateways. Nenhuma cobrança real é processada, e o formato de toda resposta e de todo webhook é o mesmo de produção.
n7_sk_sand_. Toda chamada com ela vai para o sandbox do gateway, mesmo
na base URL de produção. Os endpoints de webhook também são por ambiente: cadastre no
painel, com o ambiente em Sandbox, o endereço que vai receber os eventos de
teste.
| Comportamento | Sandbox | Live |
|---|---|---|
| Cobranças reais | Não | Sim |
| Webhooks | Entregues aos endpoints sandbox, no mesmo formato e com a mesma assinatura | Entregues aos endpoints live |
| Pix pago | Pelo simulador | Quando o pagador paga |
| Rate limit | 100 req/min | 1000 req/min |
| Dados | Só do sandbox | Só de produção |
Cartões de teste
No sandbox o resultado depende do número do cartão. Verificado contra o sandbox do Asaas em outubro de 2026, pelo mesmo caminho da API da N7:
| Cartão | Resultado |
|---|---|
4444 4444 4444 4444, ou qualquer número válido, validade futura,
CVV 123
|
Aprovado. O token é emitido e a cobrança volta 201
com status: "paid"
|
5184 0197 4037 3151 (Mastercard) |
Recusado. POST /tokens
responde 422 tokenization_failed
e POST /charges
com o cartão
inline responde 422 charge_declined, os dois com
invalid_action
e "Transação não autorizada" em details
|
4916 5613 5824 0741 (Visa) |
Recusado, igual ao anterior |
Simulando pago, recusado e contestado
No sandbox ninguém paga um Pix, nenhum banco abre contestação e a análise de risco não
reprova cartão. Para o seu sistema receber esses eventos antes de produção, a N7
aplica o desfecho que você pedir numa cobrança sandbox, pelo mesmo caminho de um
desfecho real: o status muda, o paid_at
é gravado quando for o caso, e o webhook sai para os seus endpoints sandbox com o
formato e a assinatura de produção.
n7_sk_sand_. Aceita o id da N7 ou o external_id.
event |
Parte de | Resultado |
|---|---|---|
| payment.paid | pending |
paid
e webhook payment.paid. Num Pix, a cobrança
também é confirmada no sandbox do Asaas, então um estorno depois funciona de
ponta a ponta; gateway_synced
na resposta diz se isso aconteceu.
Espere um ou dois minutos antes de estornar: logo depois do pagamento o Asaas
recusa o estorno, e a resposta é 422 refund_declined
pedindo para tentar de novo em alguns instantes
|
| payment.denied | pending |
denied e webhook payment.denied: é a recusa
assíncrona, a da análise de risco
|
| payment.chargeback | paid |
chargeback e webhook payment.chargeback
|
curl -X POST https://n7pay.com/api/v1/sandbox/charges/5fac4bcb-4e84-4a90-babf-195419aca231/simulate \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui" \
-H "Content-Type: application/json" \
-d '{"event": "payment.paid"}'
# Resposta 200: a transação, como em GET /transactions/:id, mais
# "simulated_event": "payment.paid" e "gateway_synced": true
Estorno não passa pelo simulador: use o endpoint real, POST /charges/:id/refund, que funciona no sandbox e dispara payment.refunded. Com a chave live o simulador responde 403 sandbox_only; um evento fora da tabela, 422 invalid_event; e uma cobrança fora do status de partida, 422 invalid_transition.
Roteiro de testes
A mesma sequência serve para os dois ambientes, trocando a chave. No sandbox ela não move dinheiro; em produção, use um cartão seu e um valor baixo, e estorne no fim.
-
No painel, com o ambiente certo selecionado, crie a chave secreta e cadastre o
endpoint de webhook em API & Webhooks. O segredo de assinatura é gerado
pela N7, ou você cola o que o seu servidor já usa. Clique em Testar
e confira que o seu servidor validou a assinatura do
test.pinge respondeu2xx. -
POST /customers
com nome, CPF, e-mail, telefone,
postal_codeeaddress_number. Guarde oid. -
POST /tokens com esse
customer_ide o cartão. Guarde ocard_token. -
POST /charges
com
method: "card_1x", ocard_token, o mesmocustomer_id,amount: "5.00"e umaIdempotency-Key. Espere201comstatus: "paid"e, em seguida, o webhookpayment.paid. -
Repita a cobrança sem
ip, que é o caso da cobrança feita sem o pagador presente. -
POST /charges/:id/refund
em cada cobrança. Espere
201e o webhookpayment.refunded. -
Pix:
POST /chargescommethod: "pix". No sandbox, pague pelo simulador; em produção, pague o QR. Esperepayment.paid. -
Só no sandbox: um cartão de recusa
para ver o
422, e o simulador compayment.deniedepayment.chargebackpara ver os webhooks que produção só manda quando acontecem. -
Concilie por GET /transactions, filtrando por
external_referenceoustatus.
Erros
Erros retornam JSON com campo error
(código) e message
(descrição legível):
{
"error": "unauthorized",
"message": "API key inválida ou revogada."
}
O campo description em cobrança no Brasil
Numa cobrança em BRL, a descrição não pode conter emoji nem qualquer
outro caractere acima de U+FFFF, que em UTF-8 são as sequências de
quatro bytes. É uma restrição do Asaas, e a N7 recusa antes de chamar o gateway
para você receber um erro que nomeia o caractere em vez de um parse_error
que fala em "caracteres especiais" sem dizer quais.
Acento, cedilha, travessão, cifrão, pontuação e ideograma passam.
Só a faixa acima de U+FFFF
é recusada. Em cobrança USD
pelo Stripe a restrição não existe: ela é do gateway, e a N7 não
a impõe a quem não a tem.
// recusado
{ "amount": "50.00", "currency": "BRL", "method": "pix",
"description": "Ingresso Arena 🎟" }
// resposta 422
{
"error": "invalid_params",
"details": {
"description": "não pode conter 🎟. O Asaas recusa emoji e outros caracteres acima de U+FFFF. Acento, cedilha e pontuação são aceitos."
}
}
// aceito
{ "amount": "50.00", "currency": "BRL", "method": "pix",
"description": "Ingresso Arena · São Paulo · lote 1" }
| HTTP | Código | Descrição |
|---|---|---|
| 422 | invalid_params |
Parâmetros ausentes ou inválidos, listados em details. Em BRL, inclui
description
com emoji ou caractere acima de U+FFFF
|
| 404 | customer_not_found |
O customer_id
enviado em POST /charges
ou POST /tokens
não existe nesta empresa, ou foi criado com a chave do outro ambiente
|
| 422 | charge_declined |
O gateway recusou a cobrança. Vale para o cartão negado pelo emissor e também
para dado que o gateway não aceitou, como um token usado em outro cliente. O
motivo dele vem em details, com code
e message
|
| 422 | tokenization_failed |
O gateway não emitiu o token: cartão recusado na validação ou tokenização não
liberada. O motivo vem em details
|
| 401 | unauthorized | API key ausente, inválida ou revogada |
| 409 | request_in_progress |
Outra requisição com a mesma Idempotency-Key ainda está sendo
processada. Aguarde e consulte o resultado; criar outra cobrança duplica o
pedido
|
| 422 | idempotency_key_reused |
A Idempotency-Key já foi usada com outro corpo. Devolver a
cobrança anterior entregaria um valor que não foi pedido: gere uma chave nova
|
| 422 | card_token_required |
Cobrança com cartão sem card_token nem o objeto card
|
| 422 | charge_failed | O gateway não respondeu, ou respondeu algo que não é uma recusa. É raro, e é o único erro em que a cobrança pode ter sido criada no gateway sem a N7 registrar: num cartão, não repita sozinho, fale com a N7 antes |
| 422 | token_gateway_unavailable |
O card_token
pertence a um gateway que não atende este método e moeda. Tokenização não é
portátil: emita o token no provedor certo, ou envie card
e deixe o roteamento livre
|
| 503 | gateway_unavailable |
O gateway roteado para esta moeda não está configurado neste ambiente. Diferente
de no_gateway_available, em que nenhum gateway atende a combinação
de método e moeda
|
| 403 | publishable_key_not_allowed |
Chave publicável usada fora de POST /tokens
|
| 404 | not_found | Recurso não encontrado |
| 422 | validation_failed |
Cadastro de cliente inválido, inclusive CPF/CNPJ já cadastrado no mesmo
ambiente. Os campos vêm em details
|
| 429 | rate_limit_exceeded | Limite de requisições atingido |
| 500 | internal_error | Erro interno: entre em contato |
| 503 | no_gateway_available | Nenhum gateway atende essa combinação de método e moeda |
Rate Limit
Limites por chave de API: 1000 requisições por minuto em live e 100 em sandbox, em janela deslizante.
X-RateLimit-Limit
nem equivalentes, então não construa contador em cima
deles. Ao estourar, a resposta é 429 rate_limit_exceeded
com retry-after: 60, e é esse header que você deve respeitar.
Links de Pagamento
Links são URLs que permitem cobranças sem integração de checkout customizado. Suportam PIX, boleto, cartão (BRL/USD). Podem ter URL personalizada com o slug da empresa: https://n7pay.com/c/sua-empresa/pay/:slug.
status
e limit, com teto
de 200. Não há paginação por page
nem filtro por currency
ainda.
Parâmetros de POST /payment-links
| Campo | Tipo | Descrição | |
|---|---|---|---|
| title | string | obrigatório | Título na página de pagamento |
| amount | decimal | obrigatório | Valor: ex: "299.90" |
| currency | string | opcional | BRL (padrão), USD ou EUR |
| methods | array | opcional | ["pix"], ["card_1x"], ["boleto"] etc. (padrão: pix) |
| fee_strategy | string | opcional | "absorb" = merchant paga · "pass_on" = cliente paga |
| expires_in | string | opcional |
"1h", "24h", "3d", "7d". Omitir = sem expiração. Alternativa:
expires_at
com timestamp ISO 8601.
|
| customer_id | uuid | opcional | ID de cliente N7 para pré-preencher dados |
| description | string | opcional | Texto auxiliar exibido abaixo do título |
| settlement_type | string | opcional | "d0", "d1" (padrão) ou "d30" |
| max_installments | integer | opcional |
Teto de parcelas. Omitido, é deduzido do método: card_7_12x
→ 12, card_2_6x
→ 6, senão 1.
|
POST /charges
aceita card_1x, card_2_6x
e card_7_12x
direto,
dentro da sua UI. O link de pagamento serve quando você prefere não hospedar o
checkout. O ambiente (sandbox ou live) é herdado da chave de API usada nos dois casos.
curl -X POST https://n7pay.com/api/v1/payment-links \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui" \
-H "Content-Type: application/json" \
-d '{
"title": "Plano Pro · Mensal",
"amount": "99.90",
"currency": "BRL",
"methods": ["pix", "card_1x"],
"fee_strategy": "absorb"
}'
# Resposta 201
{
"data": {
"id": "9f1c2d3e-4b5a-6789-abcd-ef0123456789",
"slug": "abc123def",
"pay_url": "https://n7pay.com/pay/abc123def",
"title": "Plano Pro · Mensal",
"description": null,
"amount": "99.90",
"currency": "BRL",
"status": "active",
"methods": ["pix", "card_1x"],
"fee_strategy": "absorb",
"settlement_type": "d1",
"max_installments": 1,
"expires_at": null,
"environment": "sandbox",
"inserted_at": "2026-06-08T12:00:00"
}
}
Transações
Transações são criadas automaticamente quando um pagador conclui o fluxo em um link de pagamento ou via API de cobrança direta.
| Parâmetro | Descrição |
|---|---|
| limit | De 1 a 200. Padrão 50 |
| status |
pending, paid, denied,
refunded
ou chargeback. Outro valor responde 422 invalid_params
|
| external_reference | O seu id do pedido, exatamente como foi enviado na cobrança |
| starting_after | Id da última transação da página anterior. A próxima página começa depois dela |
# Pagas de um pedido
curl "https://n7pay.com/api/v1/transactions?external_reference=order_1042&status=paid" \
-H "Authorization: Bearer n7_sk_live_SuaChaveAqui"
# Resposta 200
{
"data": [ { "id": "5fac4bcb-...", "status": "paid", "paid_at": "2026-10-07T14:03:11Z", ... } ],
"has_more": false
}
Para percorrer tudo, repita a chamada com starting_after
igual ao id
do último item enquanto has_more
for true. A página segue a ordem de criação, então uma venda nova
entrando no meio da leitura não faz nenhuma transação aparecer duas vezes nem sumir.
external_id
do gateway (o pay_...
do Asaas), no ambiente da chave. É também onde o pix_qr_code
aparece quando a cobrança voltou com pix_qr_status: "pending":
consulte aqui, e não crie outra cobrança. Traz pix_qr_code, pix_qr_image,
pix_qr_image_url
e pix_qr_status
com o mesmo significado da resposta de POST /charges: pare quando
virar ready, ou quando virar unavailable.
Datas da transação
| Campo | O que é |
|---|---|
| inserted_at | Criação da cobrança na N7 |
| paid_at |
Hora do pagamento, em UTC. No cartão aprovado é a hora da própria cobrança; no
Pix, a hora em que o banco confirmou. null
enquanto não pagou. É o campo a usar como "pago em"
|
| settled_at |
Liquidação do valor para você, não pagamento. Hoje vem sempre null: não use como data do pagamento
|
Status de transação
| Status | Descrição |
|---|---|
| pending | Aguardando pagamento (PIX/boleto gerado) |
| processing | Pagamento detectado, aguardando confirmação |
| paid | Confirmado pelo gateway |
| denied | Recusado pelo gateway ou pela análise de risco |
| refunded | Estornado |
| chargeback | Disputa aberta pelo titular do cartão |
Cobranças diretas: PIX e cartão inline
Use cobranças diretas quando você quer cobrar dentro da sua própria UI, sem redirecionar o pagador para o checkout hospedado. O mesmo endpoint atende PIX e cartão, mudando apenas o method.
https://n7pay.com/pay/:slug. Cobranças diretas devolvem tudo na resposta: PIX retorna
pix_qr_code
(copia e cola) e pix_qr_image
(PNG base64); cartão retorna o status
final já processado. Confira sempre pix_qr_status: em pending
a adquirente ainda não gerou o QR, e ele chega em GET /api/v1/transactions/{id},
que devolve o mesmo campo: a espera termina em ready, ou em
unavailable
se a adquirente não gerar.
card_token). Resposta síncrona.
Métodos aceitos e parcelamento
O número de parcelas está codificado no nome do método. Não existe card_12x, porque a faixa é que define o teto:
method |
Tipo | Parcelas |
|---|---|---|
| pix | PIX | à vista: retorna QR code |
| card_1x | Cartão de crédito | 1× (à vista) |
| card_2_6x | Cartão de crédito | 2 a 6×: padrão 6 se installments for omitido |
| card_7_12x | Cartão de crédito | 7 a 12×: padrão 12 se installments for omitido |
| usd | Cartão internacional | à vista, roteado para o Stripe quando a moeda não é BRL |
POST /charges
devolve 422 unsupported_method
para boleto. Emita boleto por POST /payment-links
com methods: ["boleto"].
installments explicitamente para parcelar.
O método define só o teto da faixa. Com method: "card_7_12x"
e installments: 10, cobra em 10×. Omitindo installments, o teto da faixa é usado (12×). Os mesmos nomes valem no campo
methods
de payment links, onde o campo equivalente é max_installments.
A moeda decide o gateway
Você não escolhe o gateway. Envia currency e a N7 roteia. O mesmo endpoint
serve real e dólar, e a forma da resposta muda conforme quem atendeu.
| currency | Gateway | Métodos | O que volta |
|---|---|---|---|
| BRL | Asaas | pix, card_1x, card_2_6x, card_7_12x |
pix_qr_code, pix_qr_image, pix_qr_image_url
e pix_qr_status
no PIX
|
| USD, EUR | Stripe | usd, card_1x, card_2_6x | client_secret para confirmar no navegador |
customer.document
continua obrigatório
em real. O Stripe não exige, e um comprador estrangeiro não tem CPF, então o campo é
opcional nas demais moedas. O valor mínimo também acompanha a moeda: R$ 5,00 em real,
US$ 0.50 em dólar, € 0,50 em euro.
card_token, a cobrança em dólar já volta paga.
A N7 confirma no servidor e a resposta vem com status: "paid"
e gateway_status: "succeeded", mais card_brand
e card_last4. Sem token, a cobrança fica pending
e você
confirma no navegador com o client_secret, que é o formato certo para
checkout hospedado. Aceita um PaymentMethod (pm_...) ou um token de
cartão (tok_...).
Se nenhum gateway ativo da sua conta declarar a moeda pedida, a resposta é 503 no_gateway_available. Se um gateway for roteado mas ainda não atender
cobrança direta, é 422 gateway_not_supported, que é diferente de unsupported_method: o método estava certo, o gateway é que não atende.
Parâmetros de POST /charges
| Campo | Tipo | Descrição | |
|---|---|---|---|
| amount | decimal | obrigatório | Valor em BRL: ex: "49.90". Mínimo R$ 5,00 (limite do Asaas); abaixo disso a resposta é 422 imediato. |
| method | string | opcional | "pix" (padrão), "card_1x", "card_2_6x", "card_7_12x" ou "usd": ver tabela acima |
| installments | integer | opcional | Número de parcelas. Só vale para métodos de cartão; deve caber na faixa do método. |
| card_token | string | cartão* |
O formato depende da moeda.
Em BRL, o token gerado em POST /tokens.
Em moeda estrangeira, um PaymentMethod do Stripe (pm_...) ou um
token de cartão (tok_...), obtido no navegador pelo Stripe.js. Os
dois não são intercambiáveis, e mandar um no lugar do outro devolve
invalid_params
dizendo qual era o esperado. Enviar um token restringe o Smart Routing ao
gateway que o emitiu; o objeto
card
abaixo mantém o roteamento livre. *Um dos dois é obrigatório em cartão.
|
| card | object | cartão* |
Cartão inline: holder_name, number, exp_month, exp_year, cvv. Use quando não houver card_token.
|
| holder | object | com card |
Dados do portador exigidos pelo antifraude: document, postal_code, address_number, phone. Herdados do objeto
customer
quando presentes lá.
|
| currency | string | opcional | "BRL" (padrão) |
| description | string | opcional | Descrição exibida no comprovante |
| external_reference | string | opcional | Seu ID interno para reconciliação |
| ip | string | recomendado | IP do pagador. Usado pelo antifraude no cartão: sem ele a taxa de recusa sobe. |
| customer_id | uuid | opcional |
Cliente já cadastrado em POST /customers, no mesmo
ambiente da chave. Dispensa o objeto customer: nome, e-mail e
documento vêm do cadastro. Use sempre que cobrar um cartão salvo: o
token só vale no cliente em que foi gerado
|
| customer.name | string | obrigatório* | Nome do pagador. *Dispensado com customer_id |
| customer.document | string | obrigatório* |
CPF (11 dígitos) ou CNPJ (14 dígitos), com ou sem máscara: exigido pelo Asaas
em toda cobrança em real. *Dispensado com customer_id
|
| customer.email | string | opcional | E-mail para notificações |
customer_id, aquele
cliente; um id de outra empresa ou do outro ambiente responde 404 customer_not_found. Sem ele, em real, o cliente com o mesmo CPF/CNPJ
no ambiente da chave; não havendo, um novo é criado. O e-mail não identifica ninguém
em real: dois cadastros com o mesmo e-mail e CPFs diferentes continuam sendo duas
pessoas, e a cobrança vai para a do documento enviado.
curl -X POST https://n7pay.com/api/v1/charges \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui" \
-H "Content-Type: application/json" \
-d '{
"amount": "49.90",
"method": "pix",
"description": "Pedido #1042",
"external_reference": "order_1042",
"customer": {
"name": "João da Silva",
"email": "joao@exemplo.com",
"document": "123.456.789-00"
}
}'
# Resposta 201
{
"data": {
"id": "5fac4bcb-4e84-4a90-babf-195419aca231",
"status": "pending",
"amount": "49.90",
"currency": "BRL",
"method": "pix",
"external_id": "pay_8fHtX3kZq",
"pix_qr_code": "00020126580014br.gov.bcb.pix0136...",
"pix_qr_image": "iVBORw0KGgoAAAANSUhEUgAAAcIAAAHCAQ...",
"pix_qr_image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAcIAAAHCAQ...",
"pix_qr_status": "ready",
"expires_at": "2026-06-08T16:30:00",
"inserted_at": "2026-06-08T16:00:00Z"
}
}
Campos da resposta
| Campo | Tipo | Descrição |
|---|---|---|
| id | string |
ID da transação no N7 Pay: use para consultar status via
GET /transactions/:id
|
| status | string | pending: aguardando pagamento do PIX |
| pix_qr_code | string | Payload EMV (copia e cola): exiba em um campo copiável |
| pix_qr_image | string |
PNG em base64 cru, sem o prefixo data:.
Use para gravar em arquivo ou reprocessar a imagem. Para renderizar,
prefira pix_qr_image_url: jogar este campo direto num
src
resulta em imagem quebrada
|
| pix_qr_image_url | string |
O mesmo PNG já embrulhado em data URI, pronto para
<img src="{pix_qr_image_url}">
|
| pix_qr_status | string |
ready: o QR veio nos campos acima. pending: a adquirente ainda não terminou de gerar. A cobrança existe e é válida; consulte
GET /api/v1/transactions/{id}
até virar ready, e não crie outra cobrança,
isso duplica o pedido. unavailable: a adquirente não gerou e não vai gerar; pare de
consultar e ofereça o authorization_url. null
fora do PIX.
O campo vem igual nas duas respostas, então o mesmo código serve para criar
e para consultar
|
| client_secret | string | Presente quando o Stripe atendeu a cobrança (moeda diferente de BRL). Confirme o pagamento no navegador com ele. É o equivalente ao QR do PIX |
| stripe_publishable_key | string |
Vem junto do client_secret, e é a chave que o Stripe.js usa para
confirmá-lo. Já corresponde ao ambiente da cobrança, então não há risco de
confirmar um client_secret de teste com uma chave de produção. É
pública por definição, pode ir para o HTML
|
| expires_at | datetime | O QR expira 30 minutos após a criação |
| external_id | string | ID da cobrança no gateway (Asaas) |
| customer_id | uuid | Cliente da N7 em que a cobrança saiu. É o que você envia de volta para cobrar o cartão salvo no mesmo cliente |
| external_reference | string | O seu id do pedido, como foi enviado |
| paid_at | datetime |
Hora do pagamento, em UTC. Já vem preenchido no cartão aprovado; no Pix,
null
até o banco confirmar
|
| authorization_url | string |
Só no Pix: a fatura do gateway, plano B quando o QR não vier. No cartão vem null, porque o cartão em real não passa por 3DS na API
|
pending
não significa que o PIX foi pago. Configure o webhook de saída no seu dashboard para receber o evento
payment.paid
quando o pagamento for confirmado pelo Banco Central.
Consultando o status da cobrança
Use o id
retornado na criação para consultar o status em qualquer momento. A resposta de
GET /transactions/:id
inclui os mesmos campos de QR enquanto o PIX estiver pendente.
curl https://n7pay.com/api/v1/transactions/5fac4bcb-4e84-4a90-babf-195419aca231 \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui"
Confirmando uma cobrança do Stripe no navegador
Fora de BRL quem atende é o Stripe, e existem dois caminhos. Eles são exclusivos:
escolha um. Em POST /charges/international
o card_token
é opcional, então não enviá-lo coloca você no segundo caminho.
Com card_token |
Sem card_token |
|
|---|---|---|
| Quem confirma | A N7, no servidor | O navegador do pagador, com o Stripe.js |
| Status na resposta | paid |
pending |
| Campos que voltam | card_brand, card_last4 |
client_secret, stripe_publishable_key
|
| O que falta fazer | Nada | Confirmar no navegador passando o cartão |
client_secret não carrega o cartão.
Ele identifica a cobrança, não o meio de pagamento. Confirmar só com ele, sem passar
o cartão, faz o Stripe responder:
A payment method of type card was expected to be present, but this PaymentIntent
does not have a payment method and none was provided. Try again providing either the
payment_method or payment_method_data parameters.
A mensagem é do Stripe e aparece na tela do pagador. Não é recusa do cartão nem erro da N7: é confirmação sem cartão. O cartão entra no segundo argumento de
confirmCardPayment, como abaixo.
// 1. Monte o campo de cartão com a chave que veio na cobrança
const stripe = Stripe(cobranca.stripe_publishable_key);
const cardElement = stripe.elements().create("card");
cardElement.mount("#card-element");
// 2. Confirme passando o client_secret E o cartão
const { error, paymentIntent } = await stripe.confirmCardPayment(
cobranca.client_secret,
{
payment_method: {
card: cardElement,
billing_details: { name: "Maria Silva", email: "maria@email.com" }
}
}
);
if (error) {
// cartão recusado, ou 3DS cancelado pelo pagador
mostrarErro(error.message);
} else if (paymentIntent.status === "succeeded") {
// autorizado. O status na N7 chega pelo webhook payment.paid
aguardarConfirmacao(cobranca.id);
}
422 token_gateway_unavailable. Para usar o caminho com
card_token
fora de BRL, o token tem que nascer no Stripe.js do seu navegador, como
pm_...
ou tok_..., usando a stripe_publishable_key
que a cobrança devolve.
succeeded no navegador ainda não é paid na N7.
O navegador sabe que o cartão foi autorizado; a transação na N7 muda de
pending
para paid
quando o webhook do Stripe chega. Até lá, consulte GET /api/v1/transactions/{id}. Não crie outra cobrança enquanto
espera: isso abre um segundo PaymentIntent para o mesmo pedido, e os dois podem ser
pagos.
Tokenização de cartão
card_token, o Smart Routing deixa de escolher entre os gateways
disponíveis e fica restrito ao dono do token. Se ele não puder atender aquela
cobrança, a resposta é 422 token_gateway_unavailable
em vez de uma recusa sem explicação na adquirente. Enviar o cartão em
card
no próprio POST /charges
mantém o roteamento livre, e é o caminho que aproveita o Smart Routing por inteiro. O
preço é ficar no escopo de PCI. Tokenizar tira você desse escopo e custa a liberdade
de roteamento. Os dois são legítimos; a escolha é sua, e é bom que seja consciente.
Cobrança com cartão nunca precisa trafegar o número do cartão pelo seu servidor: ele
pode ser trocado antes por um card_token
em POST /tokens. Você escolhe onde essa troca acontece, e a escolha tem consequência de compliance.
Dois caminhos
| Do navegador | Do seu servidor | |
|---|---|---|
| Chave | n7_pk_… (publicável) |
n7_sk_… (secreta, a que você já tem) |
| Quem vê o cartão | Só o navegador do pagador e a N7 | Também os seus servidores |
| Escopo PCI-DSS | SAQ A-EP (reduzido) | SAQ D (completo: auditoria anual, pentest) |
| Variável de ambiente | Precisa de uma nova | Nenhuma: reusa a que existe |
Caminho A: do navegador (recomendado)
-
Seu servidor
cria (ou busca) o cliente em POST /customers
e devolve o
customer_idpara a sua página. -
O navegador
do pagador chama
POST /tokenscom a chave publicável e os dados do cartão, e recebecard_token. - Sua página envia apenas o
card_tokenao seu servidor. -
Seu servidor
chama POST /charges
com a chave secreta, o
card_token, omethode asinstallments.
A chave publicável é gerada no dashboard em Chaves API → Tipo → Publicável. Ela pode ficar visível no código da sua página: só tokeniza cartão, não cria cobrança nem lê dados. Isso é imposto pela API, não é convenção: usar uma chave publicável em qualquer outro endpoint devolve 403 publishable_key_not_allowed. O endpoint responde com CORS, então o
fetch
cross-origin funciona.
Caminho B: do seu servidor
Se o formulário de cartão já é processado no seu backend, chame
POST /tokens
de lá com a mesma chave secreta
que você usa em /charges, sem precisar criar chave nem variável nova. O corpo é idêntico ao do caminho A.
Caminho C: cartão inline, roteamento livre
Envie o cartão direto no POST /charges, no objeto card, e
omita o card_token. É o único caminho em que a N7 escolhe o gateway pelo
custo no momento da cobrança, porque nada foi decidido antes. Também é o caminho a
usar quando a tokenização ainda não estiver liberada na sua conta pelo gateway.
Em troca, o número do cartão passa pelo seu servidor, e isso coloca você no escopo de PCI DSS. Se esse escopo for um problema, use o caminho A e aceite a restrição de roteamento.
curl -X POST https://n7pay.com/api/v1/charges \
-H "Authorization: Bearer n7_sk_live_SuaChaveAqui" \
-H "Content-Type: application/json" \
-d '{
"amount": "297.00",
"method": "card_7_12x",
"installments": 12,
"ip": "189.10.20.30",
"customer": {
"name": "Maria Souza",
"email": "maria@example.com",
"document": "982.671.040-70",
"phone": "61992746281",
"postal_code": "71735103",
"address_number": "17"
},
"card": {
"holder_name": "MARIA S SOUZA",
"number": "5162306219378829",
"exp_month": "05",
"exp_year": "2031",
"cvv": "318"
}
}'
Os dados do portador saem do objeto customer; se o titular do cartão for outra pessoa, mande um objeto
holder
separado. Campos faltando voltam como 422
listando exatamente quais, antes de qualquer chamada ao gateway.
O efeito em compliance é o mesmo do caminho B, porque o cartão passa pelo seu servidor.
fetch
em JS direto para a N7, e só o token pode ser enviado ao seu backend.
Parâmetros de POST /tokens
| Campo | Tipo | Descrição | |
|---|---|---|---|
| customer_id | uuid | obrigatório | Cliente N7 dono do cartão, criado com a chave do mesmo ambiente. O token fica preso a ele |
| card.holder_name | string | obrigatório | Nome impresso no cartão |
| card.number | string | obrigatório | Número do cartão (com ou sem espaços) |
| card.exp_month | string | obrigatório | "05" ou "5" |
| card.exp_year | string | obrigatório | "2031" ou "31": aceitamos os dois |
| card.cvv | string | obrigatório | Código de segurança |
| holder.document | string | obrigatório* |
CPF/CNPJ do portador. *Todos os campos holder
são herdados do cliente quando ele já os tem: document, phone,
postal_code
e address_number
enviados em POST /customers. Mande holder
só quando o titular do cartão for outra pessoa
|
| holder.postal_code | string | obrigatório* | CEP do portador: exigido pelo antifraude |
| holder.address_number | string | obrigatório* | Número do endereço |
| holder.phone | string | obrigatório* | Telefone com DDD |
| ip | string | opcional | IP do pagador. Detectado automaticamente se omitido. |
// Caminho A · no navegador do pagador, com a chave publicável.
// No caminho B, a mesma chamada sai do seu servidor com a chave n7_sk_.
const res = await fetch("https://n7pay.com/api/v1/tokens", {
method: "POST",
headers: {
"Authorization": "Bearer n7_pk_sand_SuaChavePublicavel",
"Content-Type": "application/json"
},
body: JSON.stringify({
customer_id: "44dcf7a7-ebc3-4ad7-b122-01f404cda545",
card: {
holder_name: "MARIA S SOUZA",
number: "5162306219378829",
exp_month: "05",
exp_year: "2031",
cvv: "318"
},
holder: {
document: "982.671.040-70",
postal_code: "71735103",
address_number: "17",
phone: "61992746281"
}
})
});
// Resposta 201
{
"data": {
"card_token": "n7tok_asaas_8a1b2c3d-...",
"gateway": "asaas",
"brand": "MASTERCARD",
"last4": "8829",
"customer_id": "44dcf7a7-ebc3-4ad7-b122-01f404cda545"
}
}
Guarde o card_token
inteiro, com o prefixo: ele diz a que gateway o token pertence. É ele que permite
cobrar de novo sem pedir o cartão ao pagador.
422 charge_declined
com "CreditCardToken não encontrado". Por isso a cobrança pelo cartão salvo leva o
mesmo customer_id
usado em POST /tokens. Sem customer_id, a N7 usa o cliente
com o mesmo CPF/CNPJ, o que também funciona desde que o documento enviado seja o
mesmo do cadastro.
Cobrando o cartão tokenizado
# No seu servidor · chave secreta
curl -X POST https://n7pay.com/api/v1/charges \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-1042-tentativa-1" \
-d '{
"amount": "297.00",
"method": "card_7_12x",
"installments": 12,
"customer_id": "44dcf7a7-ebc3-4ad7-b122-01f404cda545",
"card_token": "n7tok_asaas_8a1b2c3d-...",
"external_reference": "payment_123",
"ip": "189.10.20.30"
}'
# Resposta 201 · cartão é síncrono, o status já é final
{
"data": {
"id": "uuid",
"status": "paid",
"method": "card_7_12x",
"installments": 12,
"amount": "297.00",
"external_id": "pay_yxvvt5xg2tlzaong",
"gateway_status": "CONFIRMED",
"card_brand": "MASTERCARD",
"card_last4": "8829",
"customer_id": "44dcf7a7-ebc3-4ad7-b122-01f404cda545",
"external_reference": "payment_123",
"paid_at": "2026-10-07T14:03:11Z",
"authorization_url": null,
"environment": "sandbox"
}
}
| Pergunta | Resposta |
|---|---|
| Precisa de CVV? |
Não. O CVV entra só em POST /tokens; a cobrança pelo token não o
pede
|
| E sem o pagador presente? |
Funciona. Sem ip
a cobrança é aceita (verificado no sandbox). Quando o pagador estiver na sua
página, mande o IP dele: o antifraude de produção usa o IP, e sem ele a chance de
recusa sobe. Nunca mande o IP do seu servidor
|
| Vai marcada como recorrente? | Não. É uma cobrança de cartão comum, sem marcação de recorrência |
| Pode pedir 3DS? |
Não. O cartão em real é aprovado ou recusado na própria chamada, e
authorization_url
vem null
|
Aprovação e recusa
status: "paid", sem tela de "processando".
Diferente do PIX, que nasce pending
e só vira pago pelo webhook, o cartão traz o veredito do emissor na própria resposta. Se a sua UI mostra "aguardando confirmação" depois de um 201 de cartão, ela está esperando um evento que já aconteceu.
Recusa não é 201.
Quando o emissor nega, a resposta é 422
com o motivo dele, e não um 201 com status negado. Trate os dois caminhos separadamente:
# 422 · recusado
{
"error": "charge_declined",
"details": [
{
"code": "invalid_action",
"message": "Transação não autorizada. Verifique os dados do cartão de crédito e tente novamente."
}
]
}
A recusa na chamada não cria transação nem dispara webhook: a cobrança
não chegou a existir, e o 422
é o único aviso. charge_declined
cobre toda recusa do gateway, a do emissor e a de dado que ele não aceitou, como um
token usado em outro cliente; a diferença está em details. Mostre ao
pagador a message, e ofereça outro cartão.
Os status possíveis num 201:
status |
Significado |
|---|---|
| paid |
Aprovado e capturado: pode liberar o produto. O webhook payment.paid
também sai, uma vez, para o caso de a resposta se perder no caminho
|
| pending |
Foi para análise manual de risco do Asaas. Não há nada para o pagador fazer:
o desfecho chega por webhook, payment.paid
ou payment.denied
|
payment.denied existe só para esse caso, a recusa que chega depois. Em
sandbox, exercite os dois caminhos com os cartões de recusa, que
produzem o 422, e com o simulador, que
produz o payment.denied.
Clientes
Clientes representam os pagadores da sua empresa. Ao criar um cliente, o N7 Pay sincroniza automaticamente com os gateways configurados (Asaas, Stripe), armazenando os IDs remotos para acelerar cobranças futuras e habilitar split de receita.
document
(CPF/CNPJ) para PIX. Stripe exige email
+ endereço de cobrança para cartões internacionais. Sempre capture o máximo de dados para cobrir ambos os casos.
Isolamento entre empresas
Seus pagadores são seus. Mesmo com várias empresas usando a plataforma, o cadastro de um CPF que compra de você é separado do cadastro do mesmo CPF comprando de outra empresa. Não há reuso de cliente entre contas, nem no N7 nem no gateway.
O identificador remoto fica em gateway_customer_ids
e é reaproveitado nas cobranças seguintes, o que deixa a segunda compra do mesmo pagador mais rápida. Você não precisa gerenciar esse campo: ele é preenchido sozinho.
Todo cliente pertence ao ambiente da chave que o criou. A listagem, a consulta e a atualização só enxergam o ambiente da chave usada: o mesmo CPF pode ter um cadastro no sandbox e outro em produção, e cada chave vê só o seu. É o que garante que um cartão salvo em produção nunca seja cobrado num cliente de teste.
document
(com ou sem máscara), email, country
e limit
(até 200).
gateway_customer_ids mapeados.
Parâmetros de POST /customers
| Campo | Tipo | Descrição | |
|---|---|---|---|
| name | string | obrigatório | Nome completo ou razão social |
| string | opcional* | *Obrigatório para Stripe | |
| phone | string | opcional | Com DDI, ex: +5511999999999 |
| document_type | string | opcional | "cpf", "cnpj", "vat", "ssn", "passport" |
| document | string | opcional* | *Obrigatório para PIX (CPF/CNPJ) |
| country | string | opcional | ISO 3166: ex: "BR", "US", "PT" (padrão: "BR") |
| address_line1 | string | opcional | Logradouro. Obrigatório para Stripe. |
| city | string | opcional | Cidade |
| state | string | opcional | Estado / UF |
| zip_code | string | opcional |
CEP ou postal code. postal_code, o nome usado no resto da API, é
aceito como sinônimo
|
| address_number | string | opcional |
Número do endereço. Fica em metadata.address_number
e é herdado por POST /tokens, junto do CEP e do telefone,
para você não precisar mandar holder
|
| timezone | string | opcional | IANA: ex: "America/Sao_Paulo" (padrão) |
# Criar cliente (PIX + cartão internacional)
curl -X POST https://n7pay.com/api/v1/customers \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui" \
-H "Content-Type: application/json" \
-d '{
"name": "João da Silva",
"email": "joao@exemplo.com",
"phone": "+5511999999999",
"document_type": "cpf",
"document": "123.456.789-00",
"country": "BR",
"address_line1": "Rua das Flores, 100",
"city": "São Paulo",
"state": "SP",
"zip_code": "01310-100",
"address_number": "100"
}'
# Resposta 201
{
"data": {
"id": "44dcf7a7-ebc3-4ad7-b122-01f404cda545",
"name": "João da Silva",
"email": "joao@exemplo.com",
"document": "123.456.789-00",
"country": "BR",
"zip_code": "01310-100",
"environment": "sandbox",
"gateway_customer_ids": {},
"metadata": { "address_number": "100" },
"created_at": "2026-06-08T15:00:00"
}
}
gateway_customer_ids
nasce vazio e é preenchido em segundo plano, segundos depois, ou na primeira
tokenização ou cobrança, o que vier antes. Você não precisa esperar por ele: o
id
da N7 já serve para POST /tokens
e POST /charges.
Webhooks de saída (N7 para o seu servidor)
Configure endpoints HTTPS no seu dashboard para receber o desfecho de cada cobrança.
É por aqui que você sabe que um PIX foi pago: a resposta de POST /charges
devolve pending, e a confirmação chega depois.
1. você POST /charges → recebe id e QR, status pending
2. comprador paga o PIX no banco dele
3. Asaas avisa a N7 ← webhook da N7, já configurado
4. N7 marca a transação como paid
5. N7 POST no SEU endpoint → payment.paid, assinado
6. você emite o ingresso
Você cadastra só o endereço do passo 5, no seu dashboard. O passo 3 é entre a N7 e a adquirente, na conta da N7, e já está de pé: você não abre conta no Asaas nem no Stripe, não cadastra webhook lá e não recebe token deles. Se algum material pedir que você configure algo no painel de uma adquirente, ele está errado.
Enquanto não montar o passo 5, dá para trabalhar consultando GET /transactions/:id. O webhook evita a consulta em laço, não substitui
uma coisa que não existe.
O que você configura
Só duas coisas, no painel da N7: a URL que recebe os eventos e o segredo que assina cada entrega. Nada no Asaas, nada no Stripe.
- No topo do painel, escolha o ambiente: Sandbox ou Produção. O endpoint é cadastrado no ambiente que estiver selecionado, e só recebe eventos daquele ambiente.
- Em API & Webhooks, clique em Adicionar endpoint. Informe a URL (HTTPS, pública) e, se quiser, os eventos; vazio assina todos.
-
No campo Segredo de assinatura, cole o segredo que o seu servidor já usa
para validar o
X-N7-Signature, com pelo menos 16 caracteres, ou deixe em branco para a N7 gerar um. O segredo aparece na lista de endpoints; clique nele para copiar. -
Clique em Testar. A N7 envia um
test.pingassinado com o mesmo segredo e os mesmos cabeçalhos de um evento real, e mostra o HTTP que o seu servidor respondeu.
Para receber sandbox e produção na mesma URL, cadastre o endpoint duas vezes, uma em
cada ambiente, com o mesmo segredo ou com segredos diferentes. O cabeçalho
X-N7-Environment
e o campo environment
do corpo dizem de qual ambiente veio cada evento.
Headers de cada entrega
| Header | Conteúdo |
|---|---|
| X-N7-Signature | HMAC-SHA256 em hexadecimal, com o prefixo sha256= |
| X-N7-Timestamp | Unix em segundos. Entra no cálculo da assinatura |
| X-N7-Event | Nome do evento, o mesmo do campo event |
| X-N7-Delivery | UUID da entrega, o mesmo em todas as tentativas dela. Use para descartar reentregas |
| X-N7-Environment | sandbox ou live |
"{timestamp}.{corpo}", e o header vem com o prefixo sha256=, que precisa ser removido antes de comparar. Assinar só o corpo,
ou comparar com o prefixo, faz toda verificação falhar.
Verificando a assinatura
// Node.js · Express, com o corpo cru preservado
// app.use("/webhooks/n7", express.raw({ type: "application/json" }));
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
const received = String(headers["x-n7-signature"] || "").replace(/^sha256=/, "");
const timestamp = String(headers["x-n7-timestamp"] || "");
// rejeita entregas antigas, contra replay
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
# Elixir
def valid_signature?(raw_body, headers, secret) do
received = headers |> Map.get("x-n7-signature", "") |> String.replace_prefix("sha256=", "")
timestamp = Map.get(headers, "x-n7-timestamp", "")
expected =
:crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{raw_body}")
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(expected, received)
end
# Ruby on Rails · o corpo cru vem de request.raw_post, nunca de params.to_json
def n7_signature_valid?(request, secret)
received = request.headers["X-N7-Signature"].to_s.delete_prefix("sha256=")
timestamp = request.headers["X-N7-Timestamp"].to_s
return false if (Time.now.to_i - timestamp.to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{request.raw_post}")
ActiveSupport::SecurityUtils.secure_compare(expected, received)
end
Formato do payload
{
"event": "payment.paid",
"environment": "live",
"occurred_at": "2026-10-07T14:03:12",
"data": {
"transaction_id": "5fac4bcb-4e84-4a90-babf-195419aca231",
"payment_link_id": null,
"external_id": "pay_yxvvt5xg2tlzaong",
"external_reference": "order_99001",
"status": "paid",
"method": "card_1x",
"currency": "BRL",
"amount": "5.00",
"fee_amount": "0.40",
"net_amount": "4.60",
"installments": 1,
"paid_at": "2026-10-07T14:03:11Z",
"gateway": "Asaas",
"customer": {
"id": "44dcf7a7-ebc3-4ad7-b122-01f404cda545",
"name": "Maria Souza",
"email": "maria@example.com",
"document_type": "cpf"
},
"metadata": {
"external_reference": "order_99001",
"card_brand": "VISA",
"card_last4": "4444",
"paid_at": "2026-10-07T14:03:11Z"
}
}
}
external_reference ou pelo transaction_id.
O external_reference
vem em data.external_reference
(e, como sempre veio, em data.metadata.external_reference) e liga o evento
ao pedido do seu lado. O transaction_id
é o id
devolvido em POST /charges, um UUID. O data.status
é o que o evento anuncia; para saber o estado atual, consulte GET /transactions/:id.
Eventos disponíveis
Assinar uma lista vazia significa receber todos. Hoje apenas quatro chegam a ser
entregues, porque só eles representam mudança de desfecho de uma transação. Cada um
sai no máximo uma vez por transação, mesmo quando o gateway avisa a
N7 mais de uma vez: o cartão, por exemplo, é confirmado na aprovação e de novo quando
o dinheiro cai, e você recebe um payment.paid só.
| Evento | Entregue? | Disparado quando |
|---|---|---|
| payment.paid | sim |
Pagamento confirmado. No Pix e no boleto, quando o banco confirma; no cartão,
logo depois do 201 aprovado, ou quando a análise de risco aprova
|
| payment.denied | sim |
Cartão que estava pending
em análise de risco e foi reprovado. A recusa na própria chamada é o 422, e não gera webhook
|
| payment.refunded | sim | Estorno que zerou o saldo da venda, feito pela API, pelo painel ou pelo gateway |
| payment.chargeback | sim | Disputa aberta pelo titular do cartão |
| payment.partially_refunded | aceito |
Reservado, ainda não entregue. O estorno parcial não muda o desfecho: a venda
continua paid, com saldo estornável menor
|
| payment.created | aceito | Reservado, ainda não entregue |
| payment.updated | aceito | Reservado, ainda não entregue |
| payment.overdue | aceito | Reservado, ainda não entregue |
| transfer.completed | aceito | Reservado, ainda não entregue |
| transfer.failed | aceito | Reservado, ainda não entregue |
Reentrega
Uma entrega conta como recebida quando o seu servidor responde 2xx
em até 10 segundos. Qualquer outra resposta, timeout ou erro de conexão é repetido,
com o mesmo corpo e o mesmo X-N7-Delivery, até oito tentativas: na hora,
e depois de 30 segundos, 2 minutos, 10 minutos, 30 minutos, 1 hora, 2 horas e 4
horas. A última sai umas oito horas depois da primeira. Cada tentativa aparece no
painel, com o HTTP que o seu servidor respondeu.
2xx antes de processar, e trate repetição.
Grave o evento, responda, e processe depois. Descarte o X-N7-Delivery
já visto, e trate o mesmo evento da mesma transação como já processado. A ordem de
chegada não é garantida entre eventos diferentes; na dúvida, o estado atual está em GET /transactions/:id. Um endpoint pausado ou removido no painel deixa
de receber, inclusive as reentregas pendentes.
test.ping do painel não é um evento de pagamento.
Ele chega com event: "test.ping"
e data.message, assinado como os demais. Responda 2xx
a eventos que você não trata, em vez de recusá-los: um 4xx
vira falha no painel.
Cotação de câmbio
A cotação é um recurso persistido, não um cálculo na hora: ela é travada, tem validade curta e é de uso único. Sem isso a taxa muda entre a tela que o pagador viu e a cobrança que ele recebeu, e a diferença sai do bolso de alguém.
GET /cotacao.json, público e sem chave.
Ele devolve referência, não trava taxa, e não serve para cobrar.
status
e expires_in
imediatamente antes de cobrar.
Parâmetros
| Campo | Tipo | Obrigatoriedade | Descrição |
|---|---|---|---|
| from_currency | string | obrigatório | O que o pagador entrega |
| to_currency | string | obrigatório | O que a venda vale |
| amount | decimal | obrigatório | Valor, na moeda que o side fixa |
| side | string | opcional |
target
(padrão) fixa quanto você recebe · source
fixa quanto o pagador entrega
|
| fee_strategy | string | opcional |
absorb
(padrão) você paga a tarifa · pass_on
o pagador paga
|
| method | string | opcional | Método da cobrança futura, para o breakdown trazer a taxa certa |
| ttl_seconds | integer | opcional | Validade. Padrão 120, mínimo 30, máximo 900 |
O header Idempotency-Key é honrado: a mesma chave com o mesmo corpo
devolve a mesma cotação, em vez de abrir posição cambial duas vezes. A mesma chave
com corpo diferente é recusada com 422 idempotency_key_reused.
POST /charges: a chave fica ligada àquela
cotação para sempre. Reusá-la depois que a cotação venceu devolve a mesma cotação vencida, não emite outra. Ela volta com
expires_in: 0
e status: "active"
(o status é o do registro, não uma promessa de que ainda dá para usar), e cobrar
com ela falha em 422 quote_expired.
Por isso a chave aqui é por tentativa de cotação, e não por
pedido. Um pedido vive minutos ou horas; uma cotação vive ttl_seconds, por padrão bem menos. Versione o valor
(pedido_88213.v1, .v2) ou use um UUID novo a cada
tentativa, e confira expires_in antes de cobrar.
POST /api/v1/fx/quotes
Authorization: Bearer n7_sk_sand_…
Idempotency-Key: pedido_88213.v1
{
"from_currency": "BRL",
"to_currency": "USD",
"amount": "50.00",
"side": "target",
"method": "pix",
"ttl_seconds": 120
}
# Resposta 201
{
"data": {
"id": "24575497-3b27-4ded-9ceb-7c475a051ae8",
"status": "active",
"amount": "50.00",
"quoted_amount": "261.49",
"base_rate": "0.19503442",
"spread_pct": "2.00",
"effective_rate": "0.19121022",
"expires_in": 119,
"rate": { "source": "ptax", "observed_at": "2026-09-02T13:02:37Z", "stale": false },
"breakdown": {
"base_rate": "0.19503442",
"rate_source": "ptax",
"spread_pct": "2.00",
"spread_amount": "5.12",
"amount_without_spread": "256.37",
"charged_amount": "261.49",
"gateway_fee": "2.39",
"iof": null,
"iof_note": "Não há operação de câmbio nesta cobrança: o valor entra e sai na mesma moeda."
}
}
}
Duas coisas decidem quem paga o quê
side
decide quem come o spread. Em target
o pagador entrega mais, em source
você recebe menos. O spread é receita
da N7 nos dois casos.
fee_strategy decide quem paga a tarifa, exatamente como
no link de pagamento. Com pass_on e side: target, você
recebe exatamente o valor que pediu.
| side | fee_strategy | Pagador entrega | Você recebe |
|---|---|---|---|
| target | pass_on | R$ 278,81 | US$ 50,00, exatamente o pedido |
| target | absorb | R$ 276,42 | US$ 49,56 |
| source | pass_on | R$ 273,39 | US$ 49,02 |
| source | absorb | R$ 271,00 | US$ 48,58 |
Exemplo com US$ 50,00 a R$ 5,42, spread de 2% e tarifa Pix de R$ 2,39.
quoted_amount troca de moeda conforme o side.
Em target
ele é o que o pagador entrega, em source
é o que
você recebe. Por isso a resposta traz charge
e settlement
com valor e moeda próprios. Use esses dois se você suporta os dois sentidos.
Erros
A regra que amarra todos: nunca recotar em silêncio.
| HTTP | Código | Quando | O que fazer |
|---|---|---|---|
| 422 | unsupported_currency_pair | Par não habilitado. Hoje só pares com BRL | Não retentar, é configuração |
| 422 | amount_below_minimum | Abaixo do piso da moeda | Ajustar. A mensagem traz o mínimo |
| 422 | amount_above_maximum | Acima do teto de exposição cambial | Dividir ou falar com o comercial |
| 422 | idempotency_key_reused | Mesma chave, corpo diferente | Gerar chave nova |
| 422 | invalid_amount | Valor não numérico ou menor que zero | Corrigir |
| 503 | fx_provider_unavailable | Fonte fora do ar e sem taxa recente em cache | Backoff. Nunca assumir uma taxa |
201, com
rate.stale: true
e a validade cortada pela metade. Não é erro, e você
decide se aceita. Ignorar esse campo é aceitar preço de mercado defasado sem saber.
rate.observed_at
viaja junto para você julgar a defasagem. Em fim de
semana e feriado vale a última publicação.
Cobrança internacional
Cobra em real uma venda precificada em moeda estrangeira, consumindo uma cotação
travada. É aqui que o quote_id vira cobrança.
POST /charges de sempre.
Lá o campo currency
acumula três papéis: chave de roteamento, valor persistido e
moeda implícita do piso. Numa cobrança internacional os três divergem, e enfiar isso
no fluxo existente faria o caminho que já funciona compartilhar validação e cálculo
de tarifa com um caminho novo.
| Campo | Tipo | Obrigatoriedade | Descrição |
|---|---|---|---|
| quote_id | string | obrigatório | Id de POST /api/v1/fx/quotes |
| amount | decimal | recomendado |
Confere contra a cotação. Divergência devolve quote_mismatch
|
| currency | string | recomendado | Moeda da venda. Confere contra a cotação |
| method | string | opcional | Padrão: o método da cotação |
| customer | object | obrigatório |
Como em /charges. CPF exigido porque a cobrança sai em real, pelo Asaas
|
POST /api/v1/charges/international
{
"quote_id": "867e72a5-330b-4267-8d45-c1162392cf0a",
"amount": "50.00",
"currency": "USD",
"method": "pix",
"external_reference": "order_arena_1",
"customer": { "name": "Ana Souza", "email": "ana@exemplo.com", "document": "12345678909" }
}
# Resposta 201
{
"data": {
"status": "pending",
"amount": "263.88",
"currency": "BRL",
"fee_amount": "2.39",
"fee_pct": "0.00",
"fee_fixed": "2.39",
"pix_qr_code": "00020101021226820014br.gov.bcb.pix…",
"pix_qr_image_url": "data:image/png;base64,iVBORw0KGgo…",
"pix_qr_status": "ready",
"fx": {
"original_amount": "50.0000",
"original_currency": "USD",
"rate": "0.19503442",
"effective_rate": "0.19121022",
"spread_pct": "2.00",
"settlement_currency": "BRL",
"breakdown": {
"charge_currency": "BRL",
"charge_amount": "263.88",
"settlement_currency": "USD",
"settlement_amount": "50.00",
"spread_amount": "5.15",
"gateway_fee": "2.39"
}
}
}
}
O objeto fx
| Campo | Descrição |
|---|---|
| original_amount |
O valor que você precificou, no original_currency. É o que a
cotação travou
|
| original_currency | A moeda em que você precificou. Nesta rota, nunca BRL |
| rate | A taxa de mercado observada, antes do spread |
| effective_rate |
A taxa que de fato produziu o amount, já com o spread aplicado.
É esta que reconcilia
|
| spread_pct | O spread N7 aplicado sobre a taxa de mercado, em porcento |
| settlement_currency |
A moeda em que VOCÊ é liquidado.
Nesta rota é sempre BRL, porque a cobrança sai
em real: o pagador paga um PIX em reais e é em reais que o dinheiro entra na
sua conta. Nenhum dólar se move. Veja
Em que moeda você recebe
|
| breakdown | A conta item a item, para você mostrar ao pagador de onde vem cada centavo |
settlement_currency aparece duas vezes, com sentidos opostos.
O fx.settlement_currency
é a moeda em que você recebe: BRL. O
fx.breakdown.settlement_currency
é o lado alvo do câmbio, ou seja, a moeda que você precificou: USD. O
breakdown
é a memória de cálculo da cotação e fala a língua do câmbio; o campo de fora fala
da sua liquidação. Para saber em que moeda o dinheiro chega até você, leia sempre o de fora.
fee_pct
e fee_fixed
são a regra que produziu o fee_amount, congelada no instante da cobrança.
O tarifário muda quando o gateway reajusta, e sem esses dois campos não haveria como
responder a que taxa uma venda de três meses atrás saiu. Eles também aparecem em
GET /transactions
e GET /transactions/:id.
amount e currency continuam sendo o que foi
cobrado, em real. A venda em dólar fica em fx, então nenhuma
agregação que você já faz muda de sentido.
Erros do fluxo internacional
| HTTP | Código | Quando |
|---|---|---|
| 404 | quote_not_found |
Cotação inexistente, de outra empresa, ou quote_id ausente
|
| 422 | quote_expired | Venceu. Peça outra, nunca reutilize a taxa |
| 422 | quote_already_consumed | Cada cotação vale uma cobrança |
| 422 | quote_mismatch | Valor ou moeda diferentes dos cotados |
active e você tenta de novo com o mesmo
preço, em vez de mostrar outro ao pagador.
Estornos
Estorna uma cobrança paga, total ou parcial. Parciais podem ser repetidos até somar o valor da venda. Funciona nos dois gateways.
external_id do gateway.
| Campo | Tipo | Obrigatoriedade | Descrição |
|---|---|---|---|
| amount | decimal | opcional | Omitir estorna todo o saldo ainda não estornado |
| reason | string | opcional | Motivo livre, guardado no registro do estorno e devolvido na resposta |
netValue 96,52
depois do estorno. Numa venda estornada, o custo do gateway é perdido por alguém.
Quem fica com a taxa
Por padrão a N7 devolve a própria margem e retém apenas o custo do gateway. A N7 empata, você perde só o custo real da transação, e ninguém lucra com um estorno. A resposta mostra os três números para você conseguir explicar a diferença ao seu cliente e lançar na contabilidade.
# Venda de US$ 120,00, tarifa N7 de US$ 4,98, taxa do gateway de US$ 3,78
# Estorno de US$ 80,00
POST /api/v1/charges/14ac03e6-d431-411b-b732-beedc125826c/refund
{ "amount": "80.00", "reason": "requested_by_customer" }
# Resposta 201
{
"data": {
"id": "7c31703b-c5b1-42e0-aaea-d8eaacda632e",
"transaction_id": "14ac03e6-d431-411b-b732-beedc125826c",
"status": "done",
"amount": "80.00",
"currency": "USD",
"gateway_refund_id": "re_3UBHe6EWFlHBWTiI0pja5fzG",
"gateway_fee_retained": "2.52",
"n7_fee_refunded": "0.80",
"net_returned_to_merchant": "77.48",
"refundable_balance": "0.00"
}
}
| Campo | O que significa |
|---|---|
| gateway_fee_retained | Custo que o gateway ficou, rateado pela fração estornada. Não volta para ninguém |
| n7_fee_refunded | Quanto da tarifa N7 voltou para você |
| net_returned_to_merchant | Quanto saiu do seu saldo de fato. É o valor do estorno menos o custo do gateway |
| refundable_balance | Quanto ainda pode ser estornado nesta cobrança |
Erros
| HTTP | Código | Quando |
|---|---|---|
| 404 | not_found | A cobrança não existe, é de outra empresa, ou é de outro ambiente |
| 422 | not_paid_yet | A cobrança ainda está pendente. Um PIX não escaneado não tem o que estornar |
| 422 | already_refunded | Já foi estornada integralmente |
| 422 | exceeds_refundable_balance | Valor acima do saldo. A mensagem traz o saldo disponível |
| 422 | invalid_amount | Valor zero ou negativo |
| 422 | refund_declined |
O gateway recusou. O motivo dele vem em details. Um Pix pago há
poucos segundos ainda não pode ser estornado: o Asaas pede para tentar de novo
em alguns instantes, e passados um ou dois minutos o mesmo pedido funciona
|
payment.refunded
na hora, junto da resposta síncrona, e não sai de novo quando o gateway confirmar
pelo lado dele. Estorno feito fora da API, pelo painel ou pelo gateway, também chega
por ele. Estorno parcial não dispara webhook: a venda continua paid.
No cartão em real o estorno é imediato, e funciona no sandbox.
Gateways: o que você não precisa fazer
No modelo padrão você não abre conta, não configura webhook e não guarda credencial em gateway nenhum. A N7 Pay opera as contas de adquirência e cuida de toda essa camada; a sua integração termina na API do N7.
Existe um segundo modelo para quem já tem contrato próprio com uma adquirente: a conta continua sendo sua, o dinheiro não passa pela N7, e a N7 orquestra roteamento, conciliação e webhooks por cima dela. Nesse caso a credencial é cadastrada uma vez no painel, fica guardada cifrada, e a cobrança falha com
424 missing_merchant_credentials
enquanto ela não existir. Não há queda silenciosa para a conta da N7: uma venda sua nunca é processada na conta de outro.
Qual dos dois vale é definido por gateway, não por integração, e a resposta da cobrança não muda em nenhum dos casos. Se você não pediu o segundo, está no primeiro.
| Responsabilidade | De quem é |
|---|---|
| Conta no gateway | N7 Pay |
| Webhook do gateway | N7 Pay |
| Credenciais de adquirência | N7 Pay, ou você quando usa conta própria |
| Conciliação de eventos | N7 Pay |
| Receber webhooks de saída do N7 | Você |
Quando o pagamento é confirmado na ponta, o N7 normaliza o evento e entrega para você num formato único, o mesmo payload independente de qual gateway processou. É o que está descrito em Webhooks de saída, e é o único que você precisa consumir.
Smart Routing
O Smart Routing seleciona automaticamente o melhor gateway para cada transação com base em:
- Menor custo: taxa percentual + fixa combinada (peso 45%)
- Taxa de aprovação 24h: histórico recente de cada gateway (peso 35%)
- Latência p50: tempo de resposta mediano (peso 15%)
- Failover automático: se um gateway falhar, tenta o próximo (peso 5%)
Critérios de roteamento por método
| Método | Gateway principal | Fallback |
|---|---|---|
| pix | Asaas (melhor custo BRL) | Outro gateway com suporte PIX |
| boleto | Asaas | não se aplica |
| card_1x · card_2_6x · card_7_12x | Asaas (BRL) / Stripe (USD, EUR) | Gateway com maior aprovação 24h |
| usd / wire | Stripe | não se aplica |
Split de Receita
Com o split ativo, o valor líquido de cada cobrança, ou seja, o total menos a taxa N7 conforme o seu plano de tarifas, cai direto na sua carteira, e só a taxa fica retida pela N7. Não há repasse manual nem espera por transferência.
Nada muda na sua integração: você não envia parâmetro nenhum a mais. O
net_amount
devolvido em cada cobrança é exatamente o que vai para você.
O provisionamento da sua carteira é feito pela N7 durante o onboarding, a partir dos dados cadastrais da sua empresa. Se o split ainda não estiver ativo na sua conta, fale com o time N7, porque não há nada a configurar por API.
Em que moeda você recebe
A resposta depende da rota, e não da moeda em que você precificou. O que decide é a moeda em que a cobrança sai, porque é ela que o pagador paga e é ela que entra.
| Rota | Cobrança sai em | Você é liquidado em | Como o dinheiro chega |
|---|---|---|---|
POST /charges com currency: "BRL"
|
BRL | BRL |
Split automático: o net_amount
cai na sua carteira na adquirente. Sem carteira provisionada, fica retido na
N7 para repasse
|
POST /charges/international
|
BRL | BRL |
Igual à linha acima: é uma venda em real como qualquer outra. Você precificou
em dólar, o pagador pagou um PIX em reais, e nenhum dólar se moveu. É por
isso que o fx.settlement_currency vem BRL
|
POST /charges com currency: "USD" ou "EUR"
|
USD / EUR | USD / EUR | Sem split automático. O valor cai integralmente na conta da N7 na adquirente internacional, e o repasse é combinado com o time N7. Não há carteira sua do outro lado |
POST /charges/international:
ela trava a cotação, cobra um PIX em reais e liquida em reais. Já
POST /charges
com currency: "USD"
cobra o pagador em dólar de verdade e deixa você com saldo em dólar, que não é
convertido nem repassado automaticamente. São operações diferentes, e a escolha
da rota é a escolha da moeda em que você recebe.
O saldo é por moeda, nunca somado: uma venda de US$ 50 entra no
saldo em dólar, e não como 50 no saldo em reais. O painel mostra um cartão por
moeda. Não existe endpoint de saldo nem de saque na API:
consulte o painel, ou some o net_amount
das transações paid
por currency
em GET /transactions.
Split Payment (CBS e IBS)
Não confunda com o split de receita acima. Aquele divide o dinheiro entre você e a N7. Este separa o tributo da reforma tributária no momento da liquidação e o encaminha para a Receita Federal (CBS) e para o Comitê Gestor do IBS.
fiscal
retorna 422
com split_payment_unavailable, sem quebrar a cobrança.
Como funciona
Preencher os campos fiscais é o próprio ato de optar pelo split. Se você não enviar o bloco fiscal, a cobrança segue exatamente como hoje. Se enviar, o CBS e o IBS são separados do valor pago antes de o líquido chegar até você.
Escopo por método
| Método | Arranjo | Split |
|---|---|---|
| pix | Pix dinâmico | na primeira fase |
| boleto | Boleto | na primeira fase |
| card_1x, card_2_6x, card_7_12x | Cartão | fora da primeira fase |
| usd / wire / cripto | Stripe e cripto | fora do escopo |
Enviar fiscal
em um método fora de escopo retorna 422
com split_not_eligible_for_method.
Parâmetros de POST /charges, bloco fiscal
| Campo | Tipo | Obrigatoriedade | Descrição |
|---|---|---|---|
| fiscal.cbs | decimal | obrigatório com ibs | Valor de CBS informado. Zero é válido |
| fiscal.ibs | decimal | obrigatório com cbs | Valor de IBS informado. Zero é válido |
| fiscal.documento_fiscal | string | opcional | Chave de acesso do documento fiscal. Pode ser vinculada depois |
| fiscal.recebedor_cnpj | string | obrigatório | CNPJ de quem recebe. É sempre o seu, nunca o da N7 |
422
com split_rejected_syntactic
antes de a cobrança chegar ao gateway.
Campos da resposta
| Campo | Descrição |
|---|---|
| fiscal.cbs_informado | O que você declarou na criação |
| fiscal.cbs_corrigido | Valor que o governo devolveu quando há divergência com o documento fiscal |
| fiscal.cbs_em_aberto | Teto do que ainda pode ser recolhido, quando parte do débito já foi extinta |
| fiscal.cbs_segregado | O que efetivamente saiu na liquidação |
| fiscal.valor_original | Valor da cobrança, sem multa e sem juros |
| fiscal.valor_pago | Valor efetivamente pago |
| fiscal.valor_liquido | Valor pago menos CBS e IBS segregados, antes da taxa N7 |
| fiscal.txid | Identificador do arranjo, usado na conciliação |
Os campos de IBS seguem exatamente a mesma estrutura. CBS e IBS são calculados de forma independente e nunca se compensam entre si.
Como o valor segregado é calculado
Em boleto, Pix dinâmico e Pix automático o valor pago pode diferir do valor original, então entra proporcionalidade:
segregado = mín( (valor_pago / valor_original) * C , C , A )
C = cbs_corrigido quando existir, senão cbs_informado
A = cbs_em_aberto quando existir
# truncado para baixo em duas casas, sem arredondamento estatístico
# R$ 12,35954 vira R$ 12,35
Em Pix estático, TED e TEF não há proporcionalidade: o segregado é igual ao informado.
Eventos de webhook
| Evento | Quando dispara |
|---|---|
| split.super_inteligente.corrected | O governo corrigiu o valor do tributo antes da liquidação |
| split.super_inteligente.em_aberto | O teto a recolher foi reduzido porque parte do débito já foi extinta |
| split.segregated | O tributo foi separado. É o evento que explica por que o líquido caiu |
| split.remitted | O repasse ao governo foi confirmado, em lote e por arranjo |
| payment.under_review | A liquidação foi retida por análise de risco e o tributo ainda não foi separado |
Suporte Internacional
O N7 Pay suporta clientes e cobranças globais. Cada gateway tem requisitos diferentes:
| Campo do cliente | Asaas (PIX/Boleto) | Stripe (Cartão) |
|---|---|---|
| name | obrigatório | obrigatório |
| document (CPF/CNPJ) | obrigatório | não usado |
| recomendado | obrigatório | |
| address_line1 | opcional | obrigatório |
| country | BR implícito | obrigatório |
| zip_code | opcional | obrigatório |
Exemplos cURL
Listar links
curl https://n7pay.com/api/v1/payment-links \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui"
Criar link USD (Stripe)
curl -X POST https://n7pay.com/api/v1/payment-links \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui" \
-H "Content-Type: application/json" \
-d '{
"title": "International Plan",
"amount": "29.00",
"currency": "USD",
"methods": ["card_1x"]
}'
Consultar transação
curl https://n7pay.com/api/v1/transactions/142 \
-H "Authorization: Bearer n7_sk_sand_SuaChaveAqui"
Exemplos JavaScript / TypeScript
Criar link de pagamento
const res = await fetch("https://n7pay.com/api/v1/payment-links", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.N7_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Plano Mensal",
amount: "99.90",
currency: "BRL",
methods: ["pix"],
}),
});
const link = await res.json();
console.log(link.url); // https://n7pay.com/pay/abc123
Verificar assinatura de webhook
import crypto from "node:crypto";
// signature: o header X-N7-Signature ("sha256=..."); timestamp: o X-N7-Timestamp.
// O valor assinado é "{timestamp}.{corpo cru}", nunca o corpo sozinho.
function verifyWebhook(
rawBody: Buffer,
signature: string,
timestamp: string,
secret: string
): boolean {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const received = signature.replace(/^sha256=/, "");
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}