Charge
Charge with payment intents. Your server creates the intent, your page confirms it with n1co.js and the webhook tells you the result.
- Your server creates a payment intent with
POST /v1/payment_intentsand hands itsclient_secretto the page. - Your page confirms the payment with
confirmPayment()from n1co.js, which shows 3D Secure if the bank asks for it. - Your server fulfills the order when the
payment_intent.succeededwebhook confirms it.
First, your server creates the intent:
curl -s -X POST https://api-epay.dev.n1co.dev/v1/payment_intents \
-H "Authorization: Bearer $N1CO_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{
"amount": 1250,
"currency": "USD",
"customer": {
"id": "cust-1001",
"name": "Ana López",
"email": "ana.lopez@example.com",
"phone": "+503 7000-1234"
},
"description": "Order 8812",
"reference": "order-8812",
"metadata": { "cart": "c-1029" },
"page_origins": ["https://shop.example.com"]
}'{
"id": "pi_UGF5bWVudEludGVudEV4YW1w",
"object": "payment_intent",
"client_secret": "pi_UGF5bWVudEludGVudEV4YW1w_secret_U2VjcmV0RXhhbXBsZTAwMDAwMDAwMDAw",
"status": "requires_payment_method",
"amount": 1250,
"currency": "USD",
"description": "Order 8812",
"reference": "order-8812",
"metadata": { "cart": "c-1029" },
"billing": null,
"page_origins": ["https://shop.example.com"],
"card": null,
"payment_method": null,
"cvc_required": false,
"next_action": null,
"last_error": null,
"attempts_remaining": 3,
"authorization_code": null,
"order_id": null,
"cancellation_reason": null,
"refund": null,
"created_at": "2026-10-17T15:04:05Z",
"updated_at": "2026-10-17T15:04:05Z",
"succeeded_at": null,
"canceled_at": null,
"expires_at": "2026-10-17T16:04:05Z"
}Then, the page. It asks for the client_secret once and uses it on every attempt:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pay</title>
</head>
<body>
<form id="payment-form">
<div id="card"></div>
<p id="payment-message" role="alert"></p>
<button id="pay" type="submit" disabled>Pay $12.50</button>
</form>
<script src="https://elements.n1co.dev/v1/n1co.js"></script>
<script>
const n1 = n1co('pk_3x4mpl3pUbl1shabl3k3yAAAAAAAAAAA');
const card = n1.elements().create('card', { locale: 'en' });
const pay = document.querySelector('#pay');
const message = document.querySelector('#payment-message');
// Your endpoint creates the intent with your secret key and returns only its client_secret.
const clientSecret = fetch('/checkout/payment-intent', { method: 'POST' })
.then((r) => r.json())
.then((body) => body.client_secret);
card
.on('change', ({ complete, error }) => {
pay.disabled = !complete;
message.textContent = error ? error.message : '';
})
.mount('#card');
document.querySelector('#payment-form').addEventListener('submit', async (event) => {
event.preventDefault();
pay.disabled = true;
message.textContent = '';
try {
const intent = await n1.confirmPayment({ client_secret: await clientSecret, card });
if (intent.status === 'succeeded') {
// Your server confirms with the webhook before fulfilling the order.
window.location.assign('/checkout/gracias');
} else if (intent.status === 'processing') {
message.textContent = "We're confirming your payment. Don't pay again; we'll let you know.";
} else if (intent.status === 'canceled') {
message.textContent = 'This payment can no longer be completed. Please start your purchase again.';
} else {
message.textContent = intent.last_error?.message ?? 'The payment could not be completed.';
pay.disabled = false;
}
} catch (error) {
message.textContent = error.message;
pay.disabled = false;
}
});
</script>
</body>
</html>Create the payment intent#
amountis in minor units:1250is 12.50.currencyis an ISO 4217 code with two decimal places. Your store charges in a single currency, the one of its first successful payment.customercarries youridfor the buyer, their name, email and phone, as in Save a card.billingis the billing address. n1co asks for it for cards from the United States and Canada.page_originslimits the intent to your pages, as with card sessions.
The client_secret is shown only here. An intent expires in one hour and has 3 attempts. The rules of each field are in the API reference.
Charge a saved card#
Your server attaches the card with its id in payment_method. The card must belong to the customer with that customer.id. The browser never picks or sees saved cards.
curl -s -X POST https://api-epay.dev.n1co.dev/v1/payment_intents \
-H "Authorization: Bearer $N1CO_SECRET_KEY" \
-H 'Content-Type: application/json' \
-d '{
"amount": 1250,
"currency": "USD",
"customer": { "id": "cust-1001", "name": "Ana López", "email": "ana.lopez@example.com", "phone": "+503 7000-1234" },
"payment_method": "card_Q2FyZEV4YW1wbGUwMDAwMDAx"
}'The page confirms with the client_secret alone, without a card element:
const intent = await n1co('pk_3x4mpl3pUbl1shabl3k3yAAAAAAAAAAA').confirmPayment({ client_secret });| Error | What happened | What to do |
|---|---|---|
404 card_not_found |
The card does not belong to that customer. | Check both id values. |
409 card_unavailable |
The card can no longer be used. | Charge with the card element. |
A new card is never saved when charging. To save it, use a card session.
Ask for the security code of a saved card#
Your server creates the intent with payment_method and "cvc_required": true. Your page mounts the cvc element and confirms with confirmPayment({ client_secret, cvc }).
The buyer types the security code only inside n1co's frame: it never passes through your page or your server. n1co registers it for the saved card and then makes the charge.
<form id="payment-form">
<div id="cvc"></div>
<p id="payment-message" role="alert"></p>
<button id="pay" type="submit" disabled>Pay $12.50</button>
</form>
<script src="https://elements.n1co.dev/v1/n1co.js"></script>
<script>
const n1 = n1co('pk_3x4mpl3pUbl1shabl3k3yAAAAAAAAAAA');
// brand and last4 of the attached card: from your server or from intent.payment_method.
const cvc = n1.elements().create('cvc', { brand: 'visa', last4: '4242', locale: 'en' });
const pay = document.querySelector('#pay');
const message = document.querySelector('#payment-message');
const clientSecret = fetch('/checkout/payment-intent', { method: 'POST' })
.then((r) => r.json())
.then((body) => body.client_secret);
cvc
.on('change', ({ complete, error }) => {
pay.disabled = !complete;
message.textContent = error ? error.message : '';
})
.mount('#cvc');
document.querySelector('#payment-form').addEventListener('submit', async (event) => {
event.preventDefault();
pay.disabled = true;
try {
const intent = await n1.confirmPayment({ client_secret: await clientSecret, cvc });
// Handle intent.status as in the example above.
if (intent.status === 'succeeded') window.location.assign('/checkout/thanks');
else if (intent.status === 'requires_payment_method') {
message.textContent = intent.last_error.message;
pay.disabled = false;
}
} catch (error) {
message.textContent = error.message;
pay.disabled = false;
}
});
</script>| Case | What happened | What to do |
|---|---|---|
cvc_required rejection |
Confirmed without the cvc element. No attempt was used and nothing was charged. | Confirm with cvc. |
last_error.code card_update_failed |
n1co did not accept the code. Nothing was charged. | Let the buyer retry. |
last_error.code card_unavailable |
n1co no longer knows the card; it is now unavailable. |
Charge with the card element. |
What confirmPayment() returns#
n1co.js handles 3D Secure in its own window and returns the intent. Check intent.status:
status |
What to do on your page |
|---|---|
succeeded |
Show the confirmation. |
processing |
Show that it is being confirmed. Do not create another intent. |
requires_payment_method |
Show last_error.message and let the buyer retry. |
canceled |
Create a new intent if the buyer wants to pay. |
These cases need attention:
billing_required: ask for country, state and postal code, and confirm again withbilling.card_unavailable: the saved card stopped working; the buyer pays with the card element.outcome_unknown: the intent staysprocessingand n1co never charges twice. Wait for the webhook.
The last_error codes are in Errors and the promise rejections in the n1co.js reference.
Confirm on your server#
Fulfill the order only when your server receives payment_intent.succeeded with a valid signature, or reads the intent with GET /v1/payment_intents/{id} and it is succeeded.
curl -s https://api-epay.dev.n1co.dev/v1/payment_intents/pi_UGF5bWVudEludGVudEV4YW1w \
-H "Authorization: Bearer $N1CO_SECRET_KEY"| Event | When |
|---|---|
payment_intent.succeeded |
Once, when the charge is accepted. |
payment_intent.payment_failed |
For every failed attempt. |
payment_intent.canceled |
When it is canceled or expires. |
States, cancellation and refunds#
succeeded and canceled are final. With no attempts left, the intent also ends canceled.Cancel an intent you no longer need with POST /v1/payment_intents/{id}/cancel. It can be canceled only in requires_payment_method or requires_action.
To return a succeeded payment, create a refund.