CaktoDevelopers
SDKs & Exemplos

React / Next.js

Hook React pronto para uso com suporte a cartão simples e 3DS Cielo.

React / Next.js

Hook useCaktoCheckout

Hook completo que encapsula todo o fluxo de pagamento — detecção de fraude, 3DS Cielo e submissão do checkout.

// hooks/useCaktoCheckout.ts
import { useState, useRef, useEffect, useCallback } from 'react'

const BASE_URL    = process.env.NEXT_PUBLIC_CAKTO_BASE_URL!
const MERCHANT_ID = Number(process.env.NEXT_PUBLIC_CAKTO_MERCHANT_ID)
const IS_PROD     = process.env.NEXT_PUBLIC_ENV === 'production'
const NETHONE_URL = process.env.NEXT_PUBLIC_NETHONE_SCRIPT_URL!
// ⚠️ Em produção, use o BFF — nunca exponha o token no bundle do cliente
const API_TOKEN   = process.env.NEXT_PUBLIC_CAKTO_API_TOKEN!

const BPMPI_URL = IS_PROD
  ? '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'

// Helpers

async function loadScript(src: string): Promise<void> {
  if (document.querySelector(`script[src="${src}"]`)) return
  return new Promise((res, rej) => {
    const s = document.createElement('script')
    s.src = src; s.async = true
    s.onload = () => res()
    s.onerror = () => rej(new Error(`Falha ao carregar ${src}`))
    document.head.appendChild(s)
  })
}

async function getUserIp(): Promise<string> {
  try {
    const r = await fetch('https://api.ipify.org?format=json', {
      signal: AbortSignal.timeout(3_000),
    })
    return (await r.json()).ip
  } catch { return '' }
}

async function getThreeDsToken(): Promise<string> {
  const r = await fetch(`${BASE_URL}/api/3ds/token/?provider=cielo`, {
    signal: AbortSignal.timeout(10_000),
  })
  if (!r.ok) throw new Error(`Falha ao obter token 3DS: ${r.status}`)
  return (await r.json()).access_token
}

type ThreeDSData = {
  Cavv: string; Xid: string; Eci: string
  Version: string; ReferenceId: string; DataOnly: boolean
}
type ThreeDSResult = { ok: true; data: ThreeDSData } | { ok: false; error: string }

function runThreeDsAuth(params: {
  accessToken: string; amount: number; installments: number
  cardNumber: string; expMonth: string; expYear: string; deviceIp: string
}): Promise<ThreeDSResult> {
  return new Promise((resolve) => {
    const timeout = setTimeout(() => resolve({ ok: false, error: '3DS timeout' }), 90_000)
    let activated = false
    const activate = () => { if (!activated) { activated = true; window.bpmpi_authenticate?.() } }
    const watchdog = setTimeout(activate, 5_000)

    const observer = new MutationObserver(() => {
      const f = document.querySelector<HTMLIFrameElement>('iframe[name="bpmpi_frame"]')
      if (!f || f.dataset.styled) return
      f.dataset.styled = 'true'
      Object.assign(f.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 = (r: ThreeDSResult) => {
      clearTimeout(timeout); clearTimeout(watchdog); observer.disconnect(); resolve(r)
    }

    window.bpmpi_config = () => ({
      Environment: IS_PROD ? 'PRD' : 'SDB', Debug: false,
      onReady:            () => { clearTimeout(watchdog); activate() },
      onSuccess:          (e: any) => done({ ok: true, data: {
        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' }),
      onUnsupportedBrand: () => done({ ok: false, error: 'Bandeira não suportada pelo 3DS' }),
      onError:   (e: any) => done({ ok: false, error: e?.ReturnMessage ?? 'Erro SDK 3DS' }),
    })

    document.getElementById('bpmpi-form')?.remove()
    const form = document.createElement('form')
    form.id = 'bpmpi-form'; form.style.display = 'none'
    const yr = String(params.expYear)
    ;[
      ['bpmpi_auth',                'true'],
      ['bpmpi_accesstoken',         params.accessToken],
      ['bpmpi_ordernumber',         `ORDER-${Date.now()}`],
      ['bpmpi_currency',            '986'],
      ['bpmpi_totalamount',         String(Math.round(params.amount * 100))],
      ['bpmpi_installments',        String(params.installments)],
      ['bpmpi_paymentmethod',       'credit'],
      ['bpmpi_cardnumber',          params.cardNumber.replace(/\D/g, '')],
      ['bpmpi_cardexpirationmonth', String(params.expMonth).padStart(2, '0')],
      ['bpmpi_cardexpirationyear',  yr.length === 2 ? `20${yr}` : yr],
      ['bpmpi_device_ipaddress',    params.deviceIp || '127.0.0.1'],
      ['bpmpi_device_1_channel',    'Browser'],
    ].forEach(([cls, val]) => {
      const i = document.createElement('input'); i.className = cls; i.value = val; form.appendChild(i)
    })
    document.body.appendChild(form)
  })
}

// Tipos

type CheckoutStatus = 'idle' | 'loading' | 'success' | 'error'

interface CheckoutInput {
  use3DS: boolean
  card: {
    number: string; holderName: string
    expMonth: string; expYear: string; cvv: string
  }
  customer: {
    name: string; email: string; phone: string
    docType: 'cpf' | 'cnpj'; docNumber: string; birthDate?: string
  }
  address: {
    street: string; number: string; complement?: string
    neighborhood: string; city: string; state: string; zipcode: string
  }
  order: {
    amount: number; installments: number
    items: Array<{
      title: string; unitPrice: number
      externalRef?: string; quantity?: number; tangible?: boolean
    }>
    webhookUrl: string
  }
}

// Hook

export function useCaktoCheckout() {
  const [status, setStatus] = useState<CheckoutStatus>('idle')
  const [error, setError]   = useState<string | null>(null)
  const fraudSessionId      = useRef(crypto.randomUUID())

  useEffect(() => {
    loadScript(NETHONE_URL).then(() => {
      window.dftp?.init({ attemptReference: fraudSessionId.current })
    })
  }, [])

  const preload3DS = useCallback(() => {
    loadScript(CARDINAL_URL)
    loadScript(BPMPI_URL)
  }, [])

  const submit = useCallback(async (input: CheckoutInput) => {
    setStatus('loading')
    setError(null)

    try {
      await window.dftp?.profileCompleted()
      const ip = await getUserIp()

      let threeDSecure: ThreeDSData | undefined

      if (input.use3DS) {
        const [accessToken] = await Promise.all([
          getThreeDsToken(),
          loadScript(CARDINAL_URL),
          loadScript(BPMPI_URL),
        ])

        const authResult = await runThreeDsAuth({
          accessToken, amount: input.order.amount,
          installments: input.order.installments,
          cardNumber: input.card.number,
          expMonth: input.card.expMonth, expYear: input.card.expYear,
          deviceIp: ip,
        })

        if (!authResult.ok) throw new Error(authResult.error)
        threeDSecure = authResult.data
      }

      const payload: Record<string, unknown> = {
        user:          MERCHANT_ID,
        paymentMethod: input.use3DS ? 'threeDs' : 'credit_card',
        amount:        input.order.amount.toFixed(2),
        installments:  input.order.installments,
        antifraud_profiling_attempt_reference: fraudSessionId.current,
        card: {
          number:     input.card.number.replace(/\D/g, ''),
          holderName: input.card.holderName.toUpperCase(),
          expMonth:   input.card.expMonth,
          expYear:    input.card.expYear,
          cvv:        input.card.cvv,
        },
        customer: {
          name:      input.customer.name,
          email:     input.customer.email,
          phone:     input.customer.phone.replace(/\D/g, ''),
          docType:   input.customer.docType,
          docNumber: input.customer.docNumber.replace(/\D/g, ''),
          birthDate: input.customer.birthDate ?? null,
          ip:        ip || null,
        },
        address: { ...input.address, country: 'BR' },
        items: input.order.items.map(i => ({
          title:       i.title,
          unitPrice:   i.unitPrice.toFixed(2),
          quantity:    i.quantity ?? 1,
          tangible:    i.tangible ?? false,
          externalRef: i.externalRef ?? null,
        })),
        postbackUrl: input.order.webhookUrl,
        checkoutUrl: typeof window !== 'undefined' ? window.location.href : '',
      }

      if (threeDSecure) payload.threeDSecure = threeDSecure

      const res = await fetch(`${BASE_URL}/api/checkout/`, {
        method:  'POST',
        headers: {
          'Content-Type':  'application/json',
          // ⚠️ Use BFF em produção — nunca exponha o token no bundle do cliente
          'Authorization': `Token ${API_TOKEN}`,
        },
        body:   JSON.stringify(payload),
        signal: AbortSignal.timeout(30_000),
      })

      const data = await res.json()
      if (!res.ok) throw new Error(data.error ?? JSON.stringify(data.errors ?? data))

      setStatus('success')
      return data

    } catch (e: any) {
      setStatus('error')
      setError(e.message)
      throw e
    }
  }, [])

  return { submit, preload3DS, status, error, fraudSessionId: fraudSessionId.current }
}

Uso no componente

export function CheckoutPage() {
  const { submit, preload3DS, status, error } = useCaktoCheckout()
  const USE_3DS = true

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    try {
      const payment = await submit({
        use3DS: USE_3DS,
        card: {
          number:     formData.get('cardNumber') as string,
          holderName: formData.get('holderName') as string,
          expMonth:   formData.get('expMonth') as string,
          expYear:    formData.get('expYear') as string,
          cvv:        formData.get('cvv') as string,
        },
        customer: {
          name:      formData.get('name') as string,
          email:     formData.get('email') as string,
          phone:     formData.get('phone') as string,
          docType:   'cpf',
          docNumber: formData.get('cpf') as string,
        },
        address: {
          street:       formData.get('street') as string,
          number:       formData.get('addrNumber') as string,
          complement:   formData.get('complement') as string || undefined,
          neighborhood: formData.get('neighborhood') as string,
          city:         formData.get('city') as string,
          state:        formData.get('state') as string,
          zipcode:      (formData.get('zipcode') as string).replace(/\D/g, ''),
        },
        order: {
          amount:       149.90,
          installments: 1,
          items: [{
            title:       'Curso de Marketing Digital',
            externalRef: 'PROD-001',
            unitPrice:   149.90,
          }],
          webhookUrl: 'https://sua-loja.com/webhooks/cakto',
        },
      })

      if (payment.status === 'paid') {
        window.location.href = '/obrigado'
      } else {
        alert('Pagamento não aprovado. Verifique os dados do cartão.')
      }
    } catch {
      alert('Não foi possível processar o pagamento. Tente novamente.')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* Seus campos de formulário */}
      <button
        type="submit"
        disabled={status === 'loading'}
        onMouseEnter={USE_3DS ? preload3DS : undefined}
      >
        {status === 'loading' ? 'Processando...' : 'Pagar'}
      </button>
      {error && <p>Pagamento não aprovado. Tente novamente.</p>}
    </form>
  )
}

Next.js BFF

Para não expor credenciais no bundle do cliente, crie rotas de API no Next.js:

// app/api/cakto/3ds-token/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const base = process.env.CAKTO_BASE_URL!
  const r = await fetch(`${base}/api/3ds/token/?provider=cielo`)
  return NextResponse.json(await r.json(), { status: r.status })
}
// app/api/cakto/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'

const base  = process.env.CAKTO_BASE_URL!
const token = process.env.CAKTO_API_TOKEN!

export async function POST(req: NextRequest) {
  const r = await fetch(`${base}/api/checkout/`, {
    method:  'POST',
    headers: {
      'Authorization': `Token ${token}`,
      'Content-Type':  'application/json',
    },
    body: await req.text(),
  })
  return NextResponse.json(await r.json(), { status: r.status })
}

No hook, substitua as URLs:

  • ${BASE_URL}/api/3ds/token/.../api/cakto/3ds-token
  • ${BASE_URL}/api/checkout//api/cakto/checkout
  • Remova o header Authorization das chamadas client-side

Declaração de tipos globais

// types/global.d.ts
declare global {
  interface Window {
    dftp?: {
      init: (options: { attemptReference: string }) => void
      profileCompleted: () => Promise<void>
    }
    bpmpi_authenticate?: () => void
    bpmpi_config?: () => {
      Environment: string
      Debug: boolean
      onReady?: () => void
      onSuccess?: (e: any) => void
      onFailure?: () => void
      onUnenrolled?: () => void
      onDisabled?: () => void
      onUnsupportedBrand?: () => void
      onError?: (e: any) => void
    }
  }
}

export {}