Skip to main content
Use this guide to run card payments that may require 3D Secure, using either the session-first or transaction-first architecture. Both flows are officially supported. Choose the one that matches how your backend orchestrates transaction state and authentication.

Choose a Flow

FlowSequenceWhen to use
Session-firstCreate 3DS session -> complete 3DS -> create transactionUse when your backend authenticates before transaction creation
Transaction-firstCreate transaction (AWAITING_3DS) -> complete 3DS -> authenticate transactionUse when your backend starts transaction orchestration first
Both flows are official in Rinne. There is no platform-level preferred flow.

Backend Endpoint Mapping

ContextCreate sessionCreate transactionAuthenticate transaction
Self (company)POST /v1/3ds-sessionsPOST /v1/transactionsPOST /v1/transactions/{transactionId}/authenticate
Merchant (organization on behalf)POST /v1/merchants/{merchantId}/3ds-sessionsPOST /v1/merchants/{merchantId}/transactionsPOST /v1/merchants/{merchantId}/transactions/{transactionId}/authenticate

Shared Frontend Setup

Before processing card transactions, your merchant or organization must have an active Rinne card affiliation.
Use encrypted values from mountedCard.values only. Do not collect raw PAN/CVC in custom inputs.
import { Rinne } from '@rinnebr/js'

const rinne = new Rinne({
  merchantId: 'your-merchant-id',
  environment: 'production'
})

const cardElement = await rinne.elements.card({
  theme: 'material',
  colorScheme: 'dark',
  locale: 'pt',
  fields: ['number', 'expiry', 'cvc'],
  onChange: (state) => {
    submitButton.disabled = !state.isComplete || !state.isValid
  }
})

const mountedCard = await cardElement.mount('#card-element')

Session-First Flow

1

Create 3DS session on backend

Send the encrypted card number and plain expiry (month/year) from mountedCard.values to your backend. The Card Element encrypts the card number client-side; expiry values are not encrypted.
2

Run the 3DS challenge

Create threeDSecure once and call mount(tdsSessionId).
3

Create transaction after authentication

On onSuccess, call your backend and include the authenticated session ID.

Backend session request example

curl -X POST 'https://your-api.example.com/api/3ds-sessions' \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 1090,
    "currency": "BRL",
    "card": {
      "number": "ev:encrypted:...",
      "expiry": { "month": "04", "year": "2028" }
    }
  }'
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "tds_session_id": "tds_session_123",
  "auth_status": "ACTION_REQUIRED"
}
async function runThreeDSChallenge(sessionId: string): Promise<void> {
  let resolveAuth: () => void
  let rejectAuth: (error: Error) => void
  const completed = new Promise<void>((resolve, reject) => {
    resolveAuth = resolve
    rejectAuth = reject
  })

  const threeDS = await rinne.elements.threeDSecure({
    target: '#three-ds-container',
    colorScheme: 'dark',
    onReady: () => console.log('Challenge is ready'),
    onSuccess: () => resolveAuth(),
    onFailure: (error) => rejectAuth(new Error(error?.message ?? '3DS authentication failed')),
    onError: (error) => rejectAuth(new Error(error.message))
  })

  await threeDS.mount(sessionId)
  return completed
}

async function post(url: string, body: object) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  })
  if (!res.ok) {
    const error = await res.json().catch(() => ({}))
    throw new Error(error.message ?? `Request failed with status ${res.status}`)
  }
  return res.json()
}

async function runSessionFirst(amountInCents: number) {
  if (!mountedCard.isComplete || !mountedCard.isValid) {
    throw new Error('Card form is not ready')
  }

  const cardValues = mountedCard.values

  const session = await post('/api/3ds-sessions', {
    amount: amountInCents,
    currency: 'BRL',
    card: {
      number: cardValues.card.number,
      expiry: { month: cardValues.card.expiry_month, year: cardValues.card.expiry_year }
    }
  })

  if (session.auth_status === 'FAILED') {
    throw new Error(session.failure_reason ?? '3DS authentication failed')
  }

  if (session.auth_status === 'ACTION_REQUIRED') {
    await runThreeDSChallenge(session.tds_session_id)
  }

  const payment = await post('/api/transactions', {
    amount: amountInCents,
    cardData: {
      number: cardValues.card.number,
      cvv: cardValues.card.cvv,
      expiry_month: cardValues.card.expiry_month,
      expiry_year: cardValues.card.expiry_year
    },
    three_d_secure_session_id: session.id
  })

  return payment
}

Transaction-First Flow

In this flow, your backend transaction status determines whether frontend should run 3DS.
1

Create transaction and branch by status

Backend creates the transaction and returns the initial status.Use require_3ds: true when you want deterministic AWAITING_3DS entry in transaction-first.
2

Start 3DS only for AWAITING_3DS

If transaction is AWAITING_3DS, create a 3DS session with the same amount/currency and encrypted card number/expiry.
3

Mount challenge when required and authenticate

Branch on auth_status: mount only for ACTION_REQUIRED, skip for AUTHENTICATED, abort for FAILED. After challenge success (or if already AUTHENTICATED), backend authenticates the pending transaction with session id using:
  • Self: POST /v1/transactions/{transactionId}/authenticate
  • Merchant: POST /v1/merchants/{merchantId}/transactions/{transactionId}/authenticate
4

Handle transaction result

Card transactions typically resolve synchronously — the /authenticate response already contains the final status (APPROVED, AUTHORIZED, or REFUSED). Use this status to drive your UI immediately.

Challenge strategy flags (backend)

  • require_3ds: true: create directly in AWAITING_3DS, so you can enforce 3DS while staying on one transaction-first backend flow.
  • refuse_on_challenge: true: fail fast with REFUSED + status_reason=CHALLENGE_NOT_ALLOWED when a challenge-triggered path would occur.
  • Use refuse_on_challenge when you intentionally do not support challenge UX for a merchant, channel, or transaction segment.
  • The two flags cannot both be true.

Transaction-first status branching

Transaction statusFrontend/backend action
AWAITING_3DSRun 3DS session/challenge and call authenticate
PROCESSINGWait for webhook or status refresh; do not call authenticate yet
AUTHORIZED / APPROVEDPayment already completed; continue normal success flow
REFUSED + CHALLENGE_NOT_ALLOWEDFail fast path triggered; no 3DS handling required
REFUSED (other)Regular decline flow
If a transaction is initially PROCESSING and later becomes AWAITING_3DS (for example, soft-decline recovery), run the same transaction-first 3DS steps at that point.

Transaction-first orchestration example

type TransactionResult = {
  id: string
  status: 'PROCESSING' | 'AWAITING_3DS' | 'AUTHORIZED' | 'APPROVED' | 'REFUSED'
  status_reason?: string
}

async function runTransactionFirst(amountInCents: number): Promise<TransactionResult> {
  if (!mountedCard.isComplete || !mountedCard.isValid) {
    throw new Error('Card form is not ready')
  }

  const cardValues = mountedCard.values

  const created: TransactionResult = await post('/api/transactions', {
    amount: amountInCents,
    currency: 'BRL',
    cardData: {
      number: cardValues.card.number,
      cvv: cardValues.card.cvv,
      expiry_month: cardValues.card.expiry_month,
      expiry_year: cardValues.card.expiry_year
    },
    require_3ds: true
  })

  if (created.status === 'REFUSED' && created.status_reason === 'CHALLENGE_NOT_ALLOWED') {
    throw new Error('3DS challenge is disabled by transaction policy')
  }

  if (created.status !== 'AWAITING_3DS') {
    return created
  }

  const session = await post('/api/3ds-sessions', {
    amount: amountInCents,
    currency: 'BRL',
    card: {
      number: cardValues.card.number,
      expiry: { month: cardValues.card.expiry_month, year: cardValues.card.expiry_year }
    }
  })

  if (session.auth_status === 'FAILED') {
    throw new Error(session.failure_reason ?? '3DS authentication failed')
  }

  if (session.auth_status === 'ACTION_REQUIRED') {
    await runThreeDSChallenge(session.tds_session_id)
  }

  const authenticated: TransactionResult = await post(
    `/api/transactions/${created.id}/authenticate`,
    { three_d_secure_session_id: session.id }
  )

  return authenticated
}
Authenticate requests must use session id as three_d_secure_session_id. Do not send tds_session_id to your backend authenticate route.

Session ID Mapping

  • tds_session_id: use in threeDSecure.mount(tds_session_id) on frontend.
  • id: use as three_d_secure_session_id in backend transaction create/authenticate calls.

Handle 3DS Statuses

In practice, sessions come back as ACTION_REQUIRED or FAILED at creation — even frictionless authentication completes through the SDK element. The API contract allows AUTHENTICATED as a creation-time response for forward compatibility, so always branch on all three statuses.
auth_status at creationMeaningFrontend action
ACTION_REQUIREDAuthentication required — mount the 3DS elementMount 3DS element; the SDK handles frictionless and challenge internally
FAILEDCard was rejected before the flow could startSurface the error; let the user retry with a different card or payment method
AUTHENTICATEDFrictionless authentication completed at creation time (rare)Skip mounting; proceed directly to transaction creation or /authenticate

Frictionless vs challenge

When the element is mounted, the issuer decides whether the transaction can be authenticated silently (frictionless) or needs cardholder interaction (challenge):
  • Frictionless: authentication completes in the background with no visible UI. onSuccess fires automatically, often within seconds.
  • Challenge: an issuer-hosted verification step appears inside the element (OTP, app notification, biometric). onSuccess fires after the user completes it.
Your frontend code handles both identically — onSuccess is the signal to proceed in either case. You can inspect authentication_flow on the backend after the session is authenticated to see which path was taken.

Retry Strategy

When a session expires or fails, request a new session and mount again.
await threeDSecure.mount('tds_session_new')
The SDK automatically unmounts previous 3DS instances when you remount with a new session ID.