CaktoDevelopers
Pagamentos

Cartão + 3DS Cielo

Pagamento com cartão de crédito e autenticação 3D Secure via Cielo/Braspag BP.MPI.

Cartão + 3DS Cielo

Fluxo com autenticação 3D Secure (3DS 2.x) via BP.MPI da Braspag. Oferece liability shift: em caso de chargeback por não-reconhecimento, a responsabilidade é do emissor do cartão, não da sua loja.

Resumo

1. [mount]  Carregar scripts 3DS + iniciar detecção de fraude
2. [submit] Fechar detecção de fraude
3. [submit] GET /api/3ds/token/    ← token Cielo (público, sem auth)
4. [submit] bpmpi_authenticate()   ← SDK 3DS no browser do usuário
5. [submit] POST /api/checkout/    ← com threeDSecure preenchido

Diagrama de fluxo

Passo 1 — Scripts a carregar no mount

const IS_PRODUCTION = process.env.NEXT_PUBLIC_ENV === 'production'

const BPMPI_URL = IS_PRODUCTION
  ? 'https://mpi.braspag.com.br/Scripts/BP.Mpi.3ds20.min.js'
  : 'https://mpisandbox.braspag.com.br/Scripts/BP.Mpi.3ds20.min.js'

const CARDINAL_URL = 'https://songbird.cardinalcommerce.com/edge/v1/songbird.js'

Carregue no mount, não no submit — o SDK precisa de tempo para inicializar.

Passo 2 — Obter token 3DS

GET {BASE_URL}/api/3ds/token/?provider=cielo

Endpoint público — sem header Authorization.

async function getThreeDsToken() {
  const r = await fetch(`${BASE_URL}/api/3ds/token/?provider=cielo`)
  if (!r.ok) throw new Error(`Erro ao obter token 3DS: ${r.status}`)
  const { access_token } = await r.json()
  return access_token
}

Resposta:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600
}

Gere o token imediatamente antes de chamar bpmpi_authenticate(). TTL é ~1h — não armazene em estado persistente.

Passo 3 — Executar autenticação 3DS (BP.MPI)

O SDK roda no browser do usuário. Ele lê dados do cartão via inputs HTML com classes específicas e retorna o criptograma via callback.

function runThreeDsAuth({ accessToken, amount, installments, card, deviceIp }) {
  return new Promise((resolve) => {
    const timeout = setTimeout(
      () => resolve({ ok: false, error: '3DS timeout — tente novamente' }),
      90_000
    )

    let activated = false
    const activate = () => {
      if (!activated) { activated = true; window.bpmpi_authenticate?.() }
    }
    // Watchdog: algumas versões do SDK não disparam onReady
    const watchdog = setTimeout(activate, 5_000)

    // Tornar o iframe do challenge visível (nasce invisível sem isso)
    const observer = new MutationObserver(() => {
      const iframe = document.querySelector('iframe[name="bpmpi_frame"]')
      if (!iframe || iframe.dataset.styled) return
      iframe.dataset.styled = 'true'
      Object.assign(iframe.style, {
        position: 'fixed', top: '0', left: '0',
        width: '100vw', height: '100vh',
        zIndex: '99999', border: 'none',
      })
    })
    observer.observe(document.body, { childList: true, subtree: true })

    const done = (result) => {
      clearTimeout(timeout)
      clearTimeout(watchdog)
      observer.disconnect()
      resolve(result)
    }

    // Registrar callbacks ANTES de criar o form
    window.bpmpi_config = () => ({
      Environment: IS_PRODUCTION ? 'PRD' : 'SDB',
      Debug: false,

      onReady: () => { clearTimeout(watchdog); activate() },

      onSuccess: (e) => done({
        ok: true,
        threeDSecure: {
          Cavv:        e.Cavv,
          Xid:         e.Xid,
          Eci:         e.Eci,
          Version:     e.Version,
          ReferenceId: e.ReferenceId,
          DataOnly:    false,
        },
      }),

      onFailure:          () => done({ ok: false, error: 'Autenticação 3DS falhou' }),
      onUnenrolled:       () => done({ ok: false, error: 'Cartão não cadastrado no 3DS' }),
      onDisabled:         () => done({ ok: false, error: '3DS desabilitado para este cartão' }),
      onUnsupportedBrand: () => done({ ok: false, error: 'Bandeira não suportada pelo 3DS' }),
      onError: (e) => done({ ok: false, error: e?.ReturnMessage ?? 'Erro no SDK 3DS' }),
    })

    // Form oculto — o SDK BP.MPI lê os campos por classe CSS
    document.getElementById('bpmpi-form')?.remove()
    const form = document.createElement('form')
    form.id = 'bpmpi-form'
    form.style.display = 'none'

    const amountCents = Math.round(amount * 100)
    const expYear = String(card.expYear)

    const fields = [
      ['bpmpi_auth',                 'true'],
      ['bpmpi_accesstoken',          accessToken],
      ['bpmpi_ordernumber',          `ORDER-${Date.now()}`],
      ['bpmpi_currency',             '986'],           // BRL = 986
      ['bpmpi_totalamount',          String(amountCents)],
      ['bpmpi_installments',         String(installments)],
      ['bpmpi_paymentmethod',        'credit'],
      ['bpmpi_cardnumber',           card.number.replace(/\D/g, '')],
      ['bpmpi_cardexpirationmonth',  String(card.expMonth).padStart(2, '0')],
      ['bpmpi_cardexpirationyear',   expYear.length === 2 ? `20${expYear}` : expYear],
      ['bpmpi_device_ipaddress',     deviceIp || '127.0.0.1'],
      ['bpmpi_device_1_channel',     'Browser'],
    ]

    fields.forEach(([cls, value]) => {
      const input = document.createElement('input')
      input.className = cls
      input.value = value
      form.appendChild(input)
    })

    document.body.appendChild(form)
  })
}

Passo 4 — Request do checkout com 3DS

O campo threeDSecure recebe exatamente o que o onSuccess retornou — chaves PascalCase, sem transformação.

{
  "user": 42,
  "paymentMethod": "threeDs",
  "amount": "149.90",
  "installments": 1,
  "antifraud_profiling_attempt_reference": "550e8400-e29b-41d4-a716-446655440000",

  "threeDSecure": {
    "Cavv":        "AAIBBYNoEwAAACcKhAJkdQAAAAA=",
    "Xid":         "5f3be6de-71cd-4b17-8e3f-000000000001",
    "Eci":         "05",
    "Version":     "2.2.0",
    "ReferenceId": "a24a5d87-b1a1-4aef-a31b-52d1e47b6eb1",
    "DataOnly":    false
  },

  "card": { ... },
  "customer": { ... },
  "address": { ... },
  "items": [ ... ],
  "postbackUrl": "https://sua-loja.com/webhooks/cakto"
}

Hard-fail: Se Cavv, Eci ou Version estiverem vazios, a API retorna HTTP 400. Nunca chame /api/checkout/ se o callback onSuccess não foi disparado.

Resposta — 3DS aprovado com liability shift

{
  "id": "9a1b2c3d-ef45-6789-abcd-000000",
  "status": "paid",
  "amount": 149.9,
  "paidAmount": 149.9,
  "liquidAmount": 143.52,
  "fee": 6.38,
  "installments": 1,
  "tid": "0505096727000195830101",
  "return_code": "4",
  "acquirerType": "cielo",
  "currency": "BRL",
  "threeDs": {
    "eci": "05",
    "cavv": "AAIBBYNoEwAAACcKhAJkdQAAAAA=",
    "version": "2.2.0",
    "liability_shift": true
  },
  "card": {
    "lastDigits": "1111",
    "holderName": "JOAO DA SILVA",
    "brand": "visa",
    "token": null
  }
}

ECI e liability shift

ECIBandeiraLiability shift
05Visa✅ Sim (emissor responsável)
06Visa✅ Sim
07Visa❌ Não
02Mastercard✅ Sim
01Mastercard✅ Sim
00Mastercard❌ Não

liability_shift: true na resposta confirma que o chargeback por não-reconhecimento é responsabilidade do emissor.

Checklist de implementação

  • Scripts BPMPI e Cardinal carregados no mount (não no submit)
  • Token gerado imediatamente antes de bpmpi_authenticate()
  • bpmpi_config registrado antes de criar o form
  • bpmpi_form removido e recriado a cada tentativa
  • threeDSecure com chaves PascalCase exatamente como retornado pelo onSuccess
  • paymentMethod: "threeDs" (com capital D e S)
  • Não chamar /api/checkout/ se onSuccess não disparou
  • Tratamento de todos os callbacks: onFailure, onUnenrolled, onDisabled, onError