Skip to main content
Use this page when you accept card payments with Payment Element and need a reliable model for three_ds_required, next_action, and final settlement.

What 3DS changes in your flow

3DS adds one asynchronous customer-authentication step between card submission and the final payment result. That means:
  • your frontend may need to pause for a bank challenge or issuer approval flow
  • your backend still creates the transaction normally
  • your order must stay pending until the final webhook or reconciliation confirms success or failure

State handling rules

StateMeaningWhat your system should do
pendingthe card flow started but is not finalkeep checkout open and wait
three_ds_requiredissuer authentication is still requiredlet Payment Element continue the challenge
authorizedissuer approved, but your internal fulfillment rule may still wait for a later statekeep pending unless your business explicitly ships on authorization
capturedfinal merchant-safe successfulfill the order
refusedthe issuer or acquirer rejected the chargeshow retry or another payment method
canceledthe attempt was canceled during the flowclose the attempt and let the customer retry
Do not treat three_ds_required as a successful payment.

Frontend pattern

When possible, let elements.submit() orchestrate 3DS automatically.
const result = await elements.submit({
	createTransaction: async (tokenData) => {
		const response = await fetch("/api/pay", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				token: tokenData.token,
				amount: 2490,
				installments: 1,
				orderId: "order_2001"
			})
		});

		return response.json();
	}
});

if (result.status === "error") {
	showCheckoutError(result.error?.message ?? "Payment failed");
	return;
}

showPendingState("We are confirming your payment");

What the browser should do

  • show a submitting or authenticating state while elements.submit() is running
  • prevent double-submits during the challenge flow
  • treat the immediate response as provisional unless the final state is already terminal
  • return the shopper to a pending screen if the challenge completes but settlement is still asynchronous

Backend pattern

Your backend should stay simple: create the transaction, return the Pagou response, and let webhook processing own final fulfillment.
app.post("/api/pay", async (req, res) => {
	const { token, amount, installments, orderId } = req.body;

	const response = await fetch("https://api-sandbox.pagou.ai/v2/transactions", {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
			Authorization: `Bearer ${process.env.PAGOU_TOKEN}`,
		},
		body: JSON.stringify({
			external_ref: orderId,
			amount,
			currency: "BRL",
			method: "credit_card",
			token,
			installments,
			buyer: {
				name: "Ada Lovelace",
				email: "ada@example.com",
				document: {
					type: "CPF",
					number: "12345678901",
				},
			},
			products: [{ name: "Plan upgrade", price: amount, quantity: 1 }],
		}),
	});

	const payload = await response.json();

	await savePaymentAttempt({
		orderId,
		transactionId: String(payload.data?.id ?? ""),
		status: payload.data?.status ?? "unknown",
	});

	res.json(payload);
});

Webhook ownership of final state

Your webhook consumer should be the source of truth for fulfillment.
export async function handlePaymentWebhook(event) {
	await markWebhookReceived(event.id);

	const transaction = await pagouClient.transactions.retrieve(String(event.data.id), {
		requestId: `webhook_${event.id}`,
	});

	switch (transaction.data.status) {
		case "captured":
		case "paid":
			await markOrderAsPaid(transaction.data.external_ref);
			break;
		case "refused":
		case "canceled":
		case "expired":
			await markOrderAsFailed(transaction.data.external_ref);
			break;
		default:
			await keepOrderPending(transaction.data.external_ref);
	}
}

Failure modes to plan for

  • The customer closes the challenge window: keep the order pending, then reconcile or wait for webhook outcome.
  • The browser times out after the challenge: query GET /v2/transactions/{id} before telling the customer to retry.
  • The issuer declines after 3DS: surface a recoverable card-failure message and keep inventory reserved only if your business requires it.
  • The webhook arrives before the browser flow fully finishes: trust the backend state machine, not the browser session.

Production checklist

  • Use one stable external_ref per order attempt.
  • Store the Pagou transaction ID returned by your backend.
  • Disable duplicate submit while the challenge is in progress.
  • Fulfill only on a final state such as captured or paid.
  • Reconcile uncertain outcomes through GET /v2/transactions/{id}.