Autenticação 3D Secure (3DS)
Como o SDK LegacyPay autentica o portador do cartão automaticamente durante a tokenização, sem que você precise lidar com SDKs externos.
Pré-requisito: Este guia assume que você já carregou o
legacy-pay.jse inicializou oclient. Se ainda não fez isso, veja Tokenização de Cartões.
O 3D Secure (3DS) adiciona uma camada de autenticação do portador do cartão — geralmente um SMS, biometria ou aprovação no app do banco — antes da transação ser processada. Ele reduz chargebacks e aumenta a taxa de aprovação.
Você carrega apenas o legacy-pay.js. Quando o seu perfil exige um SDK 3DS próprio, o legacy-pay.js carrega esse SDK sozinho a partir do sdkUrl de getConfig(). Você não monta payloads diferentes para bancos diferentes.
⚠️ Existem DOIS momentos em que o desafio pode acontecer
Esta é a parte que mais causa venda perdida. Qual dos dois vale para a sua loja é decidido pelo
getConfig(), não por você:
| Pré-ordem | Pós-ordem (flow: "post-order") | |
|---|---|---|
| Quando o desafio ocorre | dentro do prepareCardPayment() | depois que o /payin responde |
| O que você precisa fazer | nada | chamar handlePendingThreeDS() |
| Se você não fizer | — | a venda fica pendurada e nunca é aprovada |
var config = await client.getConfig();
config.capabilities.threeDS.flow; // "post-order" → você PRECISA tratar o desafio
No fluxo pós-ordem, terminar noprepareCardPayment()é o erro mais caro.O
/payinresponde201comstatus: "PENDING"ethreeDSecurePending: true— parece sucesso.
Se o seu checkout redirecionar para a tela de "pedido recebido" nesse ponto, o comprador vê
"pagamento em análise" para sempre e a venda morre quando a sessão do desafio expira (15 min).
Não é hipotético: é o erro mais comum que encontramos em integrações de parceiro.
CSP: como olegacy-pay.jsinjeta o SDK do provider no browser, sua Content-Security-Policy precisa permitir o host dosdkUrlretornado emgetConfig().capabilities.threeDS— emscript-src,connect-srceframe-src. Leia esse valor em runtime e libere o host correspondente. Sem isso, o script é bloqueado e o 3DS falha.O desafio também carrega, sob demanda, o script do fornecedor de 3DS que a adquirente indica
(Cardinal, ACS do emissor, etc.). Se a sua CSP for restrita a uma lista fixa de hosts, ela vai
barrar esses domínios — que mudam por emissor e não são conhecidos de antemão.
Sucesso do 3DS ≠ venda aprovadaQuando
prepareCardPayment()resolve com sucesso (ouprepared.threeDS.status === "authenticated"), isso significa apenas que o portador foi autenticado pelo banco — não que a venda foi aprovada. A aprovação depende da adquirente/antifraude e acontece depois, de forma assíncrona. Nunca exiba "pagamento aprovado" com base no retorno do SDK.Confirme o status real da venda por um destes caminhos:
- Webhook (recomendado): aguarde o evento com
status: "APPROVED"— veja Webhooks.- Polling: consulte
GET /payin/{id}até o status sair dePENDING.
Verificar se 3DS está ativo
Cada loja é configurada com seu próprio perfil de risco. Consulte:
var config = await client.getConfig();
// config.capabilities.threeDS = {
// enabled: true // ou false
// }Quando enabled: false, o prepareCardPayment() não dispara desafios — o token é gerado direto.
Como funciona pra quem integra
var client = LegacyPay.init({ publicKey: "pk_live_...", apiBaseUrl: "..." });
try {
var prepared = await client.prepareCardPayment({
amount: 9900,
referenceId: "pedido-123",
installments: 1,
card: { holderName, number, expirationMonth, expirationYear, cvv },
customer: { name, email, document, phone, address }
});
// prepared = {
// payinCard: {
// token: "cvt_live_...", // PAN nunca sai do SDK
// installments: 1,
// // fluxo pre-order por sessão: o id da sessão (operation_session_id). Outros fluxos
// // podem trazer { cavv, eci }. Repasse o que vier — não dependa de cavv/eci no browser.
// threeDSecure: { referenceId: "..." }
// },
// antifraud: { skipped: false, sessionId: "mfp-..." },
// card: { token: "cvt_live_...", brand: "visa", last4: "1111" },
// threeDS: { skipped: false, status: "authenticated" }
// }
// buildPayinPayload() mescla os campos base com prepared.payinCard e antifraud.
var payload = client.buildPayinPayload({
amount: 9900,
referenceId: "pedido-123",
payerIp: ipDoComprador,
isPhysicalProduct: false,
customer: { name, email, document, phone, address },
items: [{ title: "Produto", quantity: 1, unitPrice: 9900 }]
}, prepared);
// payload = {
// paymentMethod: "CREDIT_CARD",
// amount: 9900,
// referenceId: "pedido-123",
// payerIp: "...",
// isPhysicalProduct: false,
// customer: { ... },
// items: [ ... ],
// card: {
// token: "cvt_live_...",
// installments: 1,
// threeDSecure: { referenceId: "..." } // o backend resolve cavv/eci a partir do referenceId
// },
// antifraud: { sessionId: "mfp-..." } // omitido se antifraude não rodou
// }
// Envie payload para o SEU backend — substitua a URL pelo seu endpoint real.
// Nunca chame /payin direto do browser (exige sk_live).
await fetch("/api/pagamento", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
} catch (err) {
if (err.code === "THREEDS_FAILED") {
// Mostre uma mensagem amigável e peça outro cartão.
}
}Durante o prepareCardPayment(), o SDK pode abrir um modal/iframe com o desafio do banco. Ele aparece sobre a sua página e fecha sozinho quando o comprador confirma. Você não precisa fazer nada — só esperar a Promise resolver.
Nunca faça fallback "tokenizar sem 3DS".Se o
prepareCardPayment()lançar erro (THREEDS_FAILED, etc.), não tente tokenizar o cartão por
conta própria e mandar pro/payinsem o 3DS. Em lojas comthreeDS.enabled, a adquirente recusa
a transação sem o dado de 3DS (você verá422 REJECTED_BY_RISK— ou, em adquirentes que exigem 3DS,
422 INVALID_DATAindicando que ooperation_session_idestá faltando). O caminho certo no erro é
pedir outro cartão.
Fluxo pós-ordem: o desafio depois do /payin
/payinEm adquirentes com flow: "post-order", o emissor só decide se quer desafio depois que a venda
é criada. A resposta do /payin vem assim:
{
"id": "87d03e08-e72c-404a-bb2c-ddccaa2db8bb",
"status": "PENDING",
"threeDSecurePending": true,
"threeDSecureProvider": "zendry-data-only",
"threeDSecureSdkUrl": "https://...",
"threeDSecureAction": {
"type": "three_ds",
"session_id": "a4388edd-52e2-422e-8b5d-ef4a563ad666",
"expires_at": "2026-09-09T15:46:49.350Z"
}
}Você só precisa olhar um campo: threeDSecurePending. Todo o resto é interno ao SDK — o
formato muda por adquirente e pode mudar sem aviso. Não leia, não interprete, não faça switch.
Repasse a resposta INTEIRA do/payinaté ohandlePendingThreeDS.Este é o erro que mais vimos em produção, e ele acontece no backend do lojista, não no front:
o backend chama o nosso/payin, monta um DTO próprio com os campos que ele conhece
(id,status,amount) e devolve isso ao checkout. Os campos do desafio são descartados no
caminho, o front recebethreeDSecurePending: undefined, e a venda morre em silêncio.Se o seu backend precisa remontar a resposta, carregue o objeto do desafio inteiro junto —
não faça whitelist de campos.
Concluir é uma chamada:
if (venda.threeDSecurePending) {
try {
// Passe card/customer SEMPRE: adquirente que precisa deles usa, quem não precisa ignora.
// É isso que faz o MESMO código funcionar em qualquer adquirente.
await client.handlePendingThreeDS(venda, {
card: dadosDoCartao, customer: dadosDoComprador,
amount: valorEmCentavos, installments: parcelas
});
} catch (err) {
if (err.code === "THREEDS_EXPIRED") return pedirNovaTentativa(); // sessão venceu
return mostrarRecusa(); // não autenticou
}
}
redirecionarParaPedido(); // só DEPOIS do desafio
Não desenhe overlay, spinner ou modal por cima durante o desafio.Quem renderiza a tela do banco é o SDK da adquirente, num iframe em tela cheia. Qualquer camada
sua por cima bloqueia o comprador de digitar o código — e falha em silêncio: a tela do banco
fica visível, só não recebe clique. Use texto inline ou desabilite o botão, nunca uma camada.
Não redirecione antes de o desafio terminar.
location.hrefdestrói a página e mata o desafio no meio. Segure a navegação até a Promise
resolver.
Alternativa: deixe o SDK cuidar de tudo
client.processCardPayment() faz prepareCardPayment + /payin + desafio numa chamada só, se você
puder deixar o SDK chamar o seu endpoint de pagamento.
Tentando de novo
Cada tentativa exige uma venda nova — a action é de uso único e não pode ser reaproveitada.
Refaça o prepareCardPayment() + /payin do zero.
O que vem em threeDSecure depende do provider
threeDSecure depende do providerO prepareCardPayment() preenche prepared.payinCard.threeDSecure, mas o formato muda conforme o
seu perfil de processamento — e o buildPayinPayload() repassa isso pro /payin automaticamente. Você não
precisa montar nada à mão; só não assuma cavv/eci no browser:
| Fluxo | payinCard.threeDSecure traz | Quem resolve cavv/eci |
|---|---|---|
| Pre-order por sessão | { referenceId } — o id da sessão 3DS | O backend resolve cavv/eci a partir do referenceId. Não vêm no browser. |
| Autenticação no browser | pode trazer { cavv, eci, ... } | O próprio SDK, no browser |
No fluxo pre-order por sessão, a única coisa que precisa chegar ao
/payiné o
card.threeDSecure.referenceId. Sem ele, a transação é recusada.cavv/ecipodem não ser
produzidos no browser — quem os obtém é o nosso backend.
Alternativa: 3DS na nossa origem mantendo o seu formulário (prepareCardPaymentHosted)
prepareCardPaymentHosted)Se você já tem o seu formulário de cartão e quer evitar mexer no CSP (liberar hosts de adquirente),
use prepareCardPaymentHosted — é igual ao prepareCardPayment (mesma entrada e mesma saída), mas a
tokenização + 3DS rodam num iframe oculto hospedado por nós. Você continua coletando o cartão nos
seus campos; o iframe só aparece (tela cheia) se houver desafio 3DS.
var client = LegacyPay.init({ publicKey: "pk_live_...", apiBaseUrl: "..." });
// Você coleta o cartão no SEU formulário e passa tudo numa única chamada:
var prepared = await client.prepareCardPaymentHosted({
amount: 9900,
installments: 1,
referenceId: "pedido-123",
card: { holderName, number, expirationMonth, expirationYear, cvv },
customer: { name, email, document, phone, address }
});
// Daqui pra frente é idêntico ao fluxo normal:
var payload = client.buildPayinPayload({ amount: 9900, referenceId: "pedido-123", payerIp, isPhysicalProduct: false, customer, items }, prepared);
// Envie payload pro SEU backend → ele chama /payin.- CSP da sua página: basta
frame-src https://checkout.legacyecombrasil.com. Nenhum host de adquirente. - O
prepareCardPaymentself-hosted continua existindo e funcionando —prepareCardPaymentHostedé só
uma alternativa pra quem precisa rodar o 3DS fora do CSP da própria página.
Card Element (iframe) — campos no nosso iframe (PCI reduzido)
Se você quer também tirar os campos de cartão do seu DOM (menor escopo PCI), use o
Card Element: um iframe hospedado por nós que renderiza os campos do cartão e roda tokenização +
3DS na nossa origem. Vantagens:
- Sem CSP de adquirente. O 3DS conecta no provedor a partir do nosso iframe — sua página só
precisa deframe-srcpro nosso domínio de checkout. Nada de liberar hosts de terceiros. - Sem carregar SDK de provider e sem manter versão de SDK no seu front.
- Menor escopo PCI — o número do cartão é digitado no nosso iframe, não no seu DOM.
<script src="https://api.legacyecombrasil.com/checkout/sdk/legacy-pay.js"></script>
<div id="card-container"></div>
<button id="pay">Pagar</button>
<script>
var client = LegacyPay.init({ publicKey: "pk_live_xxx", apiBaseUrl: "https://api.legacyecombrasil.com" });
var element = client.mountCardElement("#card-container", {
amount: 9900, // em centavos
installments: 1,
referenceId: "pedido-123",
maxInstallments: 12,
customer: { name, email, document, phone, address },
onResult: function (r) {
// r = { payinCard, antifraud }. Envie pro SEU backend → ele chama /payin com sk_live.
fetch("/api/pagamento", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ card: r.payinCard, antifraud: r.antifraud })
});
},
onError: function (err) {
// err.code / err.details. Em falha de 3DS, mostre erro e peça outro cartão.
}
});
// Dispare a tokenização + 3DS pelo SEU botão:
document.getElementById("pay").addEventListener("click", function () { element.submit(); });
</script>CSP da sua página: basta frame-src https://checkout.legacyecombrasil.com (seu domínio de checkout).
O fluxo do /payin continua igual: o payinCard vai pro seu backend, que chama o /payin.
Prefira o Card Element ao fluxo manual abaixo sempre que possível — ele elimina os problemas de CSP,
de SDK e de 3DS de uma vez.
Exemplo React/Next.js
Componente de checkout (client component). Carrega o legacy-pay.js, roda prepareCardPayment()
(3DS + tokenização + antifraude) e envia o payload pronto pro seu backend — que chama o /payin
com sk_live. Em erro de 3DS, pede outro cartão (sem fallback).
"use client";
import { useEffect, useRef, useState } from "react";
const API_BASE = "https://api.legacyecombrasil.com";
const PUBLIC_KEY = "pk_live_xxx";
// Carrega o legacy-pay.js uma vez. O legacy-pay.js injeta o SDK 3DS do provider quando
// necessário — garanta o CSP (script-src/connect-src) para o host do sdkUrl de getConfig().
function loadLegacyPay(): Promise<any> {
return new Promise((resolve, reject) => {
if ((window as any).LegacyPay) return resolve((window as any).LegacyPay);
const s = document.createElement("script");
s.src = `${API_BASE}/checkout/sdk/legacy-pay.js`;
s.onload = () => resolve((window as any).LegacyPay);
s.onerror = () => reject(new Error("Falha ao carregar legacy-pay.js"));
document.head.appendChild(s);
});
}
export function CheckoutCard({ amount, referenceId, customer }: {
amount: number; referenceId: string; customer: any;
}) {
const clientRef = useRef<any>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let active = true;
loadLegacyPay()
.then((LegacyPay) => { if (active) clientRef.current = LegacyPay.init({ publicKey: PUBLIC_KEY, apiBaseUrl: API_BASE }); })
.catch((e) => active && setError(e.message));
return () => { active = false; };
}, []);
async function pay(card: { holderName: string; number: string; expiry: string; cvv: string }, installments = 1) {
setError(null);
setLoading(true);
try {
const client = clientRef.current;
if (!client) throw new Error("Checkout não inicializado. Recarregue a página.");
// prepareCardPayment roda antifraude + tokenização + 3DS pré-order.
// Quando há SDK 3DS de provider, isto abre o desafio e devolve payinCard.threeDSecure (ex.: referenceId).
const prepared = await client.prepareCardPayment({
amount, // em centavos
installments,
referenceId,
card: {
holderName: card.holderName,
number: card.number.replace(/\D/g, ""),
expiry: card.expiry, // "MM/AA" (ou expirationMonth/expirationYear)
cvv: card.cvv,
installments,
},
customer,
});
// buildPayinPayload mescla os campos base com prepared.payinCard (token + threeDSecure) e antifraud.
const payload = client.buildPayinPayload(
{ amount, referenceId, payerIp: "", isPhysicalProduct: false, customer, items: [] },
prepared,
);
// Envie pro SEU backend — ele chama /payin com sk_live. NUNCA chame /payin do browser.
const venda = await fetch("/api/pagamento", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).then((r) => r.json());
// ⚠️ PASSO OBRIGATÓRIO em adquirente pós-ordem. Sem ele a venda fica "em análise"
// para sempre. Passe card/customer sempre: quem precisa usa, quem não precisa ignora.
if (venda.threeDSecurePending) {
await client.handlePendingThreeDS(venda, {
card, customer, amount, installments: 1,
});
}
// Sucesso do SDK ≠ venda aprovada — confirme via webhook ou GET /payin/{id}.
// Só redirecione para a tela de pedido DEPOIS daqui.
return venda;
} catch (err: any) {
// EXPIRED é sessão vencida, não recusa: o certo é oferecer nova tentativa.
if (err?.code === "THREEDS_EXPIRED") setError("O tempo para confirmar expirou. Tente novamente.");
// NÃO faça fallback sem 3DS — peça outro cartão.
else if (err?.code === "THREEDS_FAILED") setError("Não foi possível autenticar o cartão. Tente outro cartão.");
else setError(err?.message || "Falha no pagamento. Tente novamente.");
console.error("[checkout] pagamento", err?.code, err?.message);
return null;
} finally {
setLoading(false);
}
}
// ...render do formulário, chamando pay(...) no submit; exibir `error` e `loading`.
}Pontos que evitam os erros mais comuns:
- Não importe nenhum SDK de provider você mesmo — o
legacy-pay.jsinjeta. Só ajuste o CSP. - Sempre logue
err.code/err.messagedoprepareCardPayment()— umcatchsilencioso esconde o
THREEDS_FAILEDe faz parecer que "voltoupayinCardnulo sem erro". - Não tokenize por fora quando o 3DS falha. Em erro, peça outro cartão.
Falhas mais comuns
err.code | O que aconteceu | O que fazer |
|---|---|---|
THREEDS_EXPIRED | A sessão do desafio venceu (vale ~15 min) antes de o comprador concluir. O cartão não foi recusado. | Ofereça nova tentativa com o mesmo cartão — refazendo prepareCardPayment + /payin. |
THREEDS_FAILED | O emissor não autenticou: código errado, cancelado, cartão sem suporte a 3DS. | Mensagem amigável e peça outro cartão. |
THREEDS_SDK_UNAVAILABLE | O SDK da adquirente não carregou (CSP, bloqueador, rede). | Verifique o CSP do sdkUrl de getConfig(). |
EXPIREDnão é recusa. Tratar os dois como "peça outro cartão" faz o comprador trocar decartão quando ele só precisava de mais tempo — e o cartão dele estava perfeito. Diferencie.
O erro do browser não é o desfecho da venda. O SDK da adquirente pode desistir de esperarantes de a venda resolver no servidor (a janela de polling costuma ser menor que a validade da
sessão). Um comprador lento pode ver erro na tela e receber a confirmação por e-mail minutos
depois. Nunca marque a venda como perdida com base no erro do SDK — o desfecho é o webhook.
Resumindo
- Não importe manualmente o SDK do provider — só carregue o
legacy-pay.js; ele injeta o SDK 3DS do provider sozinho. Garanta o CSP para osdkUrl. - Não monte payloads de 3DS — o SDK monta tudo internamente.
- Use
client.prepareCardPayment()para tokenizar o cartão e rodar o que for pré-ordem. Ele não encerra o fluxo em adquirente pós-ordem — veja o próximo item. - Não assuma
cavv/ecino browser — em fluxos pre-order vemthreeDSecure.referenceId(o backend resolvecavv/eci). Só repasse opayinCard. - Nunca tokenize sem 3DS como fallback quando
threeDS.enabled— a adquirente recusa (REJECTED_BY_RISK/INVALID_DATA). - SEMPRE cheque
threeDSecurePendingna resposta do/payine chamehandlePendingThreeDS(). Ignorar isso em adquirente pós-ordem é a causa nº 1 de venda que fica "em análise" e morre. - Não desenhe overlay/spinner por cima do desafio e não redirecione antes de a Promise resolver — os dois matam a autenticação em silêncio.
- Distinga
THREEDS_EXPIRED(nova tentativa) deTHREEDS_FAILED(outro cartão) e logueerr.code/err.message. - Use
client.processCardPayment()se você quer um único método que cuida do/payin+ desafio pós-order. - Confirme a venda via webhook ou
GET /payin/{id}— o sucesso do SDK indica só autenticação 3DS, nunca aprovação da venda.
Updated 8 days ago