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.

No LegacyPay, a autenticação 3DS roda automaticamente dentro do client.prepareCardPayment(). Você só carrega o legacy-pay.js e não precisa importar manualmente nenhum SDK externo: quando o seu perfil exige um SDK 3DS próprio, o próprio legacy-pay.js carrega esse SDK sozinho a partir do sdkUrl informado em getConfig(). Você não vê desafios no seu código, não monta payloads diferentes para bancos diferentes. Quando o prepareCardPayment() retorna com sucesso, o prepared.payinCard já contém os dados de autenticação prontos para o /payin.

⚙️

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 — tanto em script-src quanto em connect-src. Leia esse valor em runtime e libere o host correspondente. Sem isso, o script é bloqueado e o 3DS falha.

🚧

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.


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.legacyecom.com.br. 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.legacyecom.com.br/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.legacyecom.com.br" });

  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.legacyecom.com.br (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.legacyecom.com.br";
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 res = await fetch("/api/pagamento", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      }).then((r) => r.json());

      // Sucesso do SDK ≠ venda aprovada — confirme via webhook ou GET /payin/{id}.
      return res;
    } catch (err: any) {
      // 3DS falhou/expirou/cancelado, ou cartão sem suporte a 3DS.
      // NÃO faça fallback sem 3DS — peça outro cartão.
      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] prepareCardPayment", 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

CausaSolução
Comprador não consegue confirmar o desafio (timeout, código errado, app fechado).Mostre erro amigável e ofereça outro cartão.
Cartão não suportado pelo emissor para 3DS.Idem — peça outro cartão.
Navegador sem suporte (popups bloqueados, modo anônimo restritivo).Oriente o comprador a permitir popups ou trocar de navegador.

Todas essas situações chegam como LegacyPayError com err.code === "THREEDS_FAILED". Você não precisa diferenciar a sub-causa — a UX em todos os casos é a mesma: pedir outro cartão.


Quando o desafio acontece "depois"

Em alguns perfis de loja, o desafio 3DS é decidido após o /payin ser criado (a análise antifraude pode pedir o desafio só pra casos suspeitos). Nessa situação, a resposta de /payin virá com "threeDSecurePending": true, "status": "PENDING_3DS" e os campos planos threeDSecureSession, threeDSecureTransactionId e threeDSecureSdkUrl com os dados do desafio.

O SDK lida com isso automaticamente quando você usa client.processCardPayment() — ele detecta o threeDSecurePending, roda o desafio via handlePendingThreeDS() e retoma o fluxo. Se você está montando o /payin manualmente, use client.handlePendingThreeDS(payinResponse, { card, customer, amount, installments }) para completar o desafio.


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 executar o 3DS automaticamente.
  • 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).
  • Trate LegacyPayError.code === "THREEDS_FAILED" com uma mensagem amigável (inclui timeout do SDK do provider) 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?