Handle 3D Secure / SCA

Handle 3D Secure / SCA — a runnable, end-to-end guide verified against the VINR sandbox.

View as MarkdownInstall skills

3D Secure (3DS) is the bank-led authentication step behind the EU's Strong Customer Authentication (SCA) rules. This guide shows when VINR triggers it, how to drive the challenge to completion, and how to claim exemptions so you only prompt customers when you have to — all runnable against the sandbox.

When SCA appliesAsk

SCA generally applies to customer-initiated card payments in the European Economic Area and the UK. When a payment needs authentication, the cardholder's bank asks them to prove identity — usually a one-time code or a banking-app approval. VINR decides per payment based on the card's issuing country, the amount, and whether you've requested an exemption.

You don't compute this yourself. Create the payment as normal and inspect the response: if status comes back as requires_action, authentication is needed before the charge can settle.

ScenarioTypically needs SCA?
Customer checking out interactively (EEA/UK card)Yes, unless an exemption applies
Merchant-initiated charge on a saved cardNo — covered by the original mandate
Recurring subscription renewalNo — off-session, mandate-backed
Low-value payment under EUR 30Often exempt (see Exemptions)

If you use VINR-hosted Checkout, 3DS is handled for you — the hosted page renders the challenge and returns the customer to your returnUrl. The flow below is for when you build your own card-collection UI with VINR Elements.

Triggering authenticationAsk

Create the payment with a returnUrl. VINR evaluates SCA requirements and, when needed, returns status: 'requires_action' plus a nextAction object describing the challenge.

import { Vinr } from '@vinr/sdk';

const vinr = new Vinr({ secretKey: process.env.VINR_SECRET_KEY });

export async function POST(req: Request) {
  const { orderId, paymentMethodId } = await req.json();

  const payment = await vinr.payments.create(
    {
      amount: 4999,            // €49.99
      currency: 'EUR',
      paymentMethod: paymentMethodId,
      confirm: true,           // attempt the charge immediately
      returnUrl: `https://yoursite.com/orders/${orderId}/complete`,
      metadata: { orderId },
    },
    { idempotencyKey: `order-${orderId}` },
  );

  return Response.json({
    paymentId: payment.id,
    status: payment.status,                       // 'completed' or 'requires_action'
    redirectUrl: payment.nextAction?.redirectUrl, // present only for 'requires_action'
  });
}

Handling the challenge flowAsk

When status is requires_action, redirect the browser to nextAction.redirectUrl. The bank renders its challenge there; afterwards the customer lands back on your returnUrl.

const res = await fetch('/api/pay', {
  method: 'POST',
  body: JSON.stringify({ orderId: '1234', paymentMethodId: 'pm_card' }),
}).then((r) => r.json());

if (res.status === 'requires_action') {
  window.location.href = res.redirectUrl;   // hand off to the issuer's challenge
} else if (res.status === 'completed') {
  showSuccess();
}

On return, re-check the status server-side — never trust the redirect alone. Authentication can still fail or be abandoned.

const payment = await vinr.payments.retrieve(paymentId);

switch (payment.status) {
  case 'completed':
    // authenticated and charged — but fulfil on the webhook
    break;
  case 'failed':
    // issuer rejected authentication; ask for another method
    break;
  case 'requires_action':
    // customer abandoned the challenge; let them retry
    break;
}

As always, treat the payment.completed webhook as the source of truth for fulfilment so it happens exactly once. See the full payment lifecycle.

export async function POST(req: Request) {
  const event = vinr.webhooks.verify(
    await req.text(),
    req.headers.get('x-vinr-signature'),
  );

  if (event.type === 'payment.completed') {
    await fulfillOrder(event.data.metadata.orderId);   // idempotent
  }
  return new Response('OK', { status: 200 });
}

ExemptionsAsk

Some payments qualify to skip the challenge while staying compliant. Request one with requestExemption — the issuer makes the final call and may still demand authentication.

Prop

Type

const payment = await vinr.payments.create({
  amount: 1500,              // €15.00 — under the low-value cap
  currency: 'EUR',
  paymentMethod: paymentMethodId,
  confirm: true,
  requestExemption: 'low_value',
  returnUrl: 'https://yoursite.com/return',
});

An exemption is a request, not a guarantee. If the issuer overrides it you'll still receive requires_action — always keep the challenge-handling path above wired up. With an exemption, liability for fraud chargebacks generally shifts back to you, so weigh friction against risk.

Testing 3DSAsk

Use these sandbox cards with your own card UI to exercise each branch:

CardBehaviour
4242 4242 4242 4242Succeeds with no challenge
4000 0000 0000 3220Forces a 3DS challenge you must complete
4000 0000 0000 0002Declined after (or instead of) authentication

On the sandbox challenge page, click Complete to authenticate or Fail to simulate abandonment, then confirm your returnUrl handler reacts to each resulting status.

Next stepsAsk

Was this page helpful?
Edit on GitHub

Last updated on

On this page