Skip to main content
Use this page as the canonical Payment Element integration guide.

Payment flow at a glance

  1. Load the Payment Element script in the browser.
  2. Create an Elements instance with your public key.
  3. Mount the hosted card field.
  4. Submit through elements.submit(...).
  5. Your backend creates POST /v2/transactions.
  6. The SDK handles 3DS automatically or returns requires_action.
  7. Your backend finalizes the order through webhooks and reconciliation.

Frontend example

<form id="payment-form">
	<div id="card-element"></div>
	<button id="submit-button" type="submit" disabled>Pay now</button>
	<p id="payment-message" role="status"></p>
</form>

<script src="https://js.pagou.ai/payments/v3.js"></script>
<script>
	Pagou.setEnvironment("sandbox");

	const form = document.getElementById("payment-form");
	const submitButton = document.getElementById("submit-button");
	const messageEl = document.getElementById("payment-message");
	let isSubmitting = false;

	const elements = Pagou.elements({
		publicKey: "pk_sandbox_your_public_key",
		locale: "en",
		origin: window.location.origin,
	});

	const card = elements.create("card", {
		theme: "default",
	});

	card.mount("#card-element");

	card.on("ready", () => {
		submitButton.disabled = false;
	});

	card.on("change", (event) => {
		if (event.valid) {
			messageEl.textContent = "";
			return;
		}

		messageEl.textContent = "Complete the card details.";
	});

	card.on("error", (event) => {
		messageEl.textContent = event.message ?? "Unable to load secure card fields.";
	});

	form.addEventListener("submit", async (event) => {
		event.preventDefault();
		if (isSubmitting) return;

		isSubmitting = true;
		submitButton.disabled = true;
		messageEl.textContent = "Processing payment...";

		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",
					}),
				});

				const payload = await response.json();

				if (!response.ok) {
					throw new Error(payload.detail ?? "Payment request failed.");
				}

				return payload.data ?? payload;
			},
		});

		if (result.status === "error") {
			messageEl.textContent = result.error ?? "Payment failed.";
			isSubmitting = false;
			submitButton.disabled = false;
			return;
		}

		if (result.status === "requires_action" && result.transaction?.next_action) {
			messageEl.textContent = "Additional authentication required...";

			const challengeResult = await Pagou.handleNextAction(result.transaction.next_action);

			if (
				challengeResult.status === "failed" ||
				challengeResult.status === "canceled" ||
				challengeResult.status === "timed_out"
			) {
				messageEl.textContent = "Authentication was not completed.";
				isSubmitting = false;
				submitButton.disabled = false;
				return;
			}
		}

		messageEl.textContent = "Payment submitted. Waiting for confirmation...";
	});
</script>

What elements.submit() does

When a card element is mounted, elements.submit():
  1. creates an Elements session if one does not already exist
  2. associates the session with the card field
  3. tokenizes the card
  4. calls your createTransaction callback with the tokenized data
  5. inspects the returned transaction payload
  6. continues 3DS automatically if next_action is present and auto modal is enabled
  7. otherwise returns requires_action so your code can call Pagou.handleNextAction(...)
Pagou’s Transactions API is envelope-wrapped, but elements.submit() expects your createTransaction callback to return the transaction object itself, with top-level status and next_action.

Backend example

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.id ?? payload.data?.id ?? ""),
		status: payload.status ?? payload.data?.status ?? "unknown",
	});

	res.json(payload);
});

Backend contract rules

Your backend should:
  • create the transaction with your secret credentials
  • return a shape the frontend can use directly
  • preserve id, status, and next_action
  • store the transaction ID for reconciliation
Do not strip the next_action object before the browser receives it.

Result handling

Treat the immediate browser result as an integration result, not a fulfillment decision.
Browser outcomeMeaningWhat to do
errorsubmit or tokenization failedshow retry UI
requires_actionmanual continuation is requiredcall Pagou.handleNextAction(...)
transaction status such as pending or authorizedpayment flow advanced but may not be finalshow pending UI
succeeded from challenge handlingchallenge completedstill wait for backend-confirmed final state
failed, canceled, timed_out from challenge handlingchallenge did not complete successfullyallow retry and reconcile if needed

Fulfillment model

Your backend should own the final payment decision. Recommended rule:
  • browser starts the payment
  • webhook confirms the final business-safe state
  • reconciliation handles missing or uncertain webhook timing

Webhook pattern

export async function handlePaymentWebhook(event) {
	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);
	}
}

Production checklist

  • disable duplicate submit while the flow is in progress
  • use a stable external_ref per order attempt
  • store the Pagou transaction ID
  • preserve next_action for the browser
  • fulfill only from final backend-confirmed states
  • reconcile uncertain outcomes with GET /v2/transactions/{id}