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.js e inicializou o client. 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é-ordemPós-ordem (flow: "post-order")
Quando o desafio ocorredentro do prepareCardPayment()depois que o /payin responde
O que você precisa fazernadachamar handlePendingThreeDS()
Se você não fizera 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 no prepareCardPayment() é o erro mais caro.

O /payin responde 201 com status: "PENDING" e threeDSecurePending: 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 o legacy-pay.js injeta o SDK do provider no browser, sua Content-Security-Policy precisa permitir o host do sdkUrl retornado em getConfig().capabilities.threeDS — em script-src, connect-src e frame-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 aprovada

Quando prepareCardPayment() resolve com sucesso (ou prepared.threeDS.status === "authenticated"), isso significa apenas que o portador foi autenticado pelo banconã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 de PENDING.

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 /payin sem o 3DS. Em lojas com threeDS.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_DATA indicando que o operation_session_id está faltando). O caminho certo no erro é
pedir outro cartão.


Fluxo pós-ordem: o desafio depois do /payin

Em 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 /payin até o handlePendingThreeDS.

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 recebe threeDSecurePending: 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.href destró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

O 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:

FluxopayinCard.threeDSecure trazQuem resolve cavv/eci
Pre-order por sessão{ referenceId } — o id da sessão 3DSO backend resolve cavv/eci a partir do referenceId. Não vêm no browser.
Autenticação no browserpode 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/eci podem não ser
produzidos no browser
— quem os obtém é o nosso backend.


Alternativa: 3DS na nossa origem mantendo o seu formulário (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 prepareCardPayment self-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 de frame-src pro 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.js injeta. Só ajuste o CSP.
  • Sempre logue err.code/err.message do prepareCardPayment() — um catch silencioso esconde o
    THREEDS_FAILED e faz parecer que "voltou payinCard nulo sem erro".
  • Não tokenize por fora quando o 3DS falha. Em erro, peça outro cartão.

Falhas mais comuns

err.codeO que aconteceuO que fazer
THREEDS_EXPIREDA 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_FAILEDO 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_UNAVAILABLEO SDK da adquirente não carregou (CSP, bloqueador, rede).Verifique o CSP do sdkUrl de getConfig().
⚠️

EXPIRED não é recusa. Tratar os dois como "peça outro cartão" faz o comprador trocar de

cartã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 esperar

antes 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 o sdkUrl.
  • 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/eci no browser — em fluxos pre-order vem threeDSecure.referenceId (o backend resolve cavv/eci). Só repasse o payinCard.
  • Nunca tokenize sem 3DS como fallback quando threeDS.enabled — a adquirente recusa (REJECTED_BY_RISK/INVALID_DATA).
  • SEMPRE cheque threeDSecurePending na resposta do /payin e chame handlePendingThreeDS(). 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) de THREEDS_FAILED (outro cartão) e logue err.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.

Did this page help you?