3-D Secure
3-D Secure authentication, one call, the final charge.
A card charge can be born requires_action asking for 3-D Secure authentication. The hosted SDK runs the challenge and completes it at POST /charges/{id}/confirm-3ds, resolving the final canonical charge.
When 3DS happens
When a 3DS rule requires authentication AND the resolved acquirer supports 3DS, the charge is born requires_action and includes next_action instead of authorizing immediately. If there is no 3DS (no rule, or an acquirer without support), next_action is absent and the flow proceeds normally.
The charge's next_action
next_action only appears while the charge is requires_action. sdk_payload is opaque, acquirer-specific and browser-bound — hand it to the SDK without inspecting it.
{ "id": "ch_xxx", "object": "charge", "status": "requires_action", "amount_cents": 10990, "currency": "BRL", "payment_method": "credit_card", "capture_method": "automatic", "captured": false, "installments": 1, "next_action": { "type": "three_ds_challenge", "three_ds": { "authentication_id": "tds_xxx", "acquirer": "cielo", "sdk_payload": {} } }, "request_id": "req_xxx"}Include the hosted SDK
Load the hosted script and create a client with create(). It installs the global Bunne. Do not pass a key in the browser: today we only issue secret keys (sk_), which must never be exposed. The browser only runs the challenge; confirm-3ds is a backend call.
<script src="https://bunne.com.br/sdk/v1/bunne-3ds.js"></script><script> // Installs window.Bunne. Bind non-secret config once. // No API key in the browser: we only issue secret keys (sk_), which must // never be exposed. The browser only runs the challenge; confirm-3ds is a // backend call. Point baseUrl at your API (or your proxy). const client = Bunne.create({ baseUrl: "https://bunne.com.br" });</script>The one-call way
threeDSecure(charge) does everything: if the charge does not require 3DS it resolves the charge unchanged (frictionless, no network call); otherwise it runs the challenge and POSTs to confirm-3ds, resolving the final charge. Because confirm needs the sk_ key, use this shortcut only where the key is safe (backend or server-rendered). Today, with no publishable key, prefer the two-step flow below.
// charge is the JSON returned by POST /charges (created from your backend).// Its status may be "requires_action" with a next_action, or already final.const finalCharge = await client.threeDSecure(charge);// finalCharge.status is now final: "paid" / "authorized" / "refused".// No 3DS? threeDSecure resolves the same charge unchanged, with no network call.Recommended: challenge in the browser, confirm on your backend
In the browser, run initThreeDS(next_action): it runs the challenge with NO key and resolves { authentication_id, acquirer, result }. Send that object to YOUR backend, which POSTs to /charges/{id}/confirm-3ds with the sk_ key (see below). No key ever touches the browser.
// 1. Run the challenge only (acquirer-agnostic). No network call yet.// Resolves { authentication_id, acquirer, result }.const resolved = await client.initThreeDS(charge.next_action);// 2. Send `resolved` ({ authentication_id, result }) to YOUR backend, which// confirms with the sk_ key (see the backend example below). If you ever// issue a browser-safe key, client.confirm3DS(charge.id, resolved) does// this call for you.Pure backend (run the challenge yourself)
Teams running the 3DS challenge themselves confirm directly: POST /charges/{id}/confirm-3ds with { authentication_id, result }. It returns the canonical charge and is idempotent — a duplicate confirm is a replay and never authorizes twice.
curl -X POST https://bunne.com.br/api/v1/charges/ch_xxx/confirm-3ds \ -H "Authorization: Bearer sk_test_xxx" \ -H "Idempotency-Key: order_123_confirm_3ds" \ -H "Content-Type: application/json" \ -d '{ "authentication_id": "tds_xxx", "result": { "authenticated": true } }'Security: never expose sk_ in the browser
sk_... key in the browser (the SDK warns if it detects one). Today we only issue secret keys, so the browser only runs the challenge and /charges/{id}/confirm-3dsis called from your backend with sk_.Endpoint reference
/charges/{id}/confirm-3dsConfirm 3-D Secure
Completes a `requires_action` charge after the client runs the 3-D Secure challenge. Send the `authentication_id` from the charge's `next_action` and the opaque `result` produced by the challenge. Authorizes only the acquirer bound at prepare time (no cross-acquirer fallback) and returns the canonical charge. A duplicate confirm is an idempotent replay and never authorizes twice.
Headers
| Field | Type | Description |
|---|---|---|
Idempotency-Key | string | Recommended for confirm retries. |
Parameters and body
| Field | Type | Description |
|---|---|---|
idrequired | path string | Charge id with `ch_` prefix, currently `requires_action`. |
authentication_idrequired | string | The `tds_` id from the charge's `next_action.three_ds.authentication_id`. |
resultrequired | object | Opaque challenge result produced by the 3-D Secure SDK. Passed through verbatim; must not carry raw card data. |
Response fields
| Field | Type | Description |
|---|---|---|
id | string | `ch_` public id. |
status | string | `paid`/`authorized` on success, `refused` on a failed authentication. |
captured | boolean | True after payment/capture. |
paid_at | datetime | UTC timestamp when the charge was paid. |
request_id | string | Request trace id. |
curl -X POST https://bunne.com.br/api/v1/charges/ch_xxx/confirm-3ds \ -H "Authorization: Bearer sk_test_xxx" \ -H "Idempotency-Key: order_123_confirm_3ds" \ -H "Content-Type: application/json" \ -d '{ "authentication_id": "tds_xxx", "result": { "authenticated": true }}'{ "id": "ch_xxx", "object": "charge", "status": "paid", "amount_cents": 10990, "currency": "BRL", "payment_method": "credit_card", "capture_method": "automatic", "captured": true, "installments": 1, "authorization_code": "MOCK-AUTH", "acquirer_return_code": null, "acquirer_return_message": null, "refused_reason": null, "buyer": { "name": "Ada Lovelace", "email": "ada@example.com", "phone": "5511999990000", "document": "93541134780", "document_type": "cpf", "ip": "203.0.113.10", "address": { "line_1": "Av. Paulista, 1000", "line_2": null, "zip_code": "01310100", "city": "Sao Paulo", "state": "SP", "country": "BR" } }, "metadata": { "order_id": "order_123" }, "paid_at": "2026-06-25T12:10:05.000Z", "created_at": "2026-06-25T12:09:40.000Z", "updated_at": "2026-06-25T12:10:05.000Z", "request_id": "req_xxx"}