Overview
You send the payment to Halba; Halba screens it with your fraud vendor, decides between 3D Secure, an SCA exemption or a direct authorization, sends it to the PSP chosen for you and, if that PSP fails for technical reasons, retries once on your backup PSP. You get one response with the outcome, the PSP that processed it and who bears the fraud liability.
Your PSPs are your own accounts: connect them in the dashboard (Routing & risk > Connections). The first PSP you connect is the primary, the second is the backup.
Environments and keys
| Environment | Dashboard | API | Keys |
|---|---|---|---|
| Sandbox | sandbox.halba.ai | https://api.sandbox.halba.ai | hb_test_… |
| Production | app.halba.ai (coming soon) | https://api.halba.ai | hb_live_… |
The sandbox accepts only test credentials for your PSPs and never moves real money. Create keys under Developers > API keys. The secret key is shown once: keep it on your server. Each key belongs to one workspace. To rotate, create a new key, deploy it, then revoke the old one: a revoked key stops working within a minute.
Authorization: Bearer hb_test_…halba.js in the browser
Fraud screening and 3D Secure need data that only the browser has: IP address, browser headers, screen and time zone, and a fraud session. halba.js collects them on your checkout page with the publishable key (hb_pk_test_…), which can only collect device data and complete 3DS for your payments.
<script src="https://api.sandbox.halba.ai/v1/halba.js"></script>
<script>
const halba = Halba("hb_pk_test_…"); // publishable key
const { clientSession } = await halba.collect(); // send it to your server with the order
// after your server created the payment:
if (payment.next_action) payment = await halba.handleNextAction(payment);
</script>collect() returns a clientSession id, valid for one hour. halba.js uses no cookies and does not read your page.
Your first payment
From your server, with the secret key. The card is a token from the vault: in the sandbox, your connected Stripe test account, so Stripe test tokens such as tok_visa work. With client_session you can leave out device.
curl https://api.sandbox.halba.ai/v1/payments \
-H "Authorization: Bearer hb_test_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1001" \
-d '{
"merchant_reference": "order_1001",
"amount": { "value": 12990, "currency": "EUR" },
"payment_method": { "type": "card", "card": { "token": { "vault": "stripe", "id": "tok_visa" } } },
"customer": { "email": "maria.bianchi@example.com" },
"client_session": "cs_…"
}'This is the minimum. Addresses, order items and account history are optional but improve fraud screening and exemptions; if your fraud vendor requires one of them, the response is 422 with the list of missing fields and nothing is charged.
{
"id": "att_5229a8c1-a2d7-4216-8781-bec068a9afd3",
"object": "payment",
"status": "authorized",
"psp": "stripe",
"psp_reference": "pi_3UYun1CxTwW6t0Np0xFVJQtn",
"fell_back": false,
"next_action": null,
"decline": null,
"authentication": { "outcome": "authenticated", "eci": "05", "challenged": false, "exemption": null },
"liability": { "holder": "issuer", "basis": "3ds_authenticated" },
"amounts": { "authorized": 12990, "captured": 0, "capturable": 12990, "refunded": 0, "refundable": 0, "voided": false }
}Payment outcomes
POST /v1/payments answers 201 whenever a payment was created, declines included: check status.
| status | Meaning |
|---|---|
authorized | Approved by the issuer or provider; capture it when you ship. |
captured | Approved and already collected (iDEAL, EPS, Trustly, TWINT). |
declined | Refused by the issuer or the PSP; decline.code says why. |
blocked | Stopped before the PSP, by the fraud screening or a failed 3D Secure. |
requires_fingerprint, requires_challenge | 3D Secure needs the customer's browser. |
requires_action | Send the customer to next_action.url to approve at PayPal, Klarna or their bank. |
processing, canceled, expired | Redirect methods: waiting for the provider, given up by the customer, not completed in time. |
fell_back: true means the backup PSP processed the payment. liability tells you who bears a fraud chargeback.
3D Secure
When the issuer asks for it, the payment pauses with requires_challenge (or requires_fingerprint). With halba.js, pass the payment to halba.handleNextAction(payment): it shows the bank's page above your checkout and resolves with the final payment. Without halba.js, open next_action.url and then call POST /v1/payments/{id}/resume from your server.
Response details
The base response is small: status, amounts, PSP, decline reason and your metadata. Ask for more with expand, in the query of a GET or in the body of a POST.
curl "https://api.sandbox.halba.ai/v1/payments/att_…?expand=fraud,liability" \
-H "Authorization: Bearer hb_test_…"
{
"id": "att_…", "status": "authorized", …,
"fraud": { "vendor": "riskified", "verdict": "approve", "guaranteed": true, … },
"liability": { "holder": "issuer", "basis": "3ds_authenticated" }
}| Block | What you get |
|---|---|
authentication | 3DS outcome, ECI, challenge, exemption requested. |
fraud | Vendor, verdict, SCA advice, guarantee, reason. |
liability | Who bears a fraud chargeback, and why. |
routing | Primary and final PSP, every attempt with its outcome. |
card | Brand, BIN, last 4, expiry, funding, issuer country and name, PAR. |
operations | Captures, voids and refunds with their status. |
timeline | Every step of the flow, for debugging. |
PayPal, Klarna, iDEAL and other redirect methods
Same call, different payment_method.type, plus a return_url. Halba answers requires_action with a link on pay.halba.ai: send the customer there. Halba forwards them to the provider, checks the result with the provider server-side and sends them back to your return_url.
curl https://api.sandbox.halba.ai/v1/payments \
-H "Authorization: Bearer hb_test_…" -H "Content-Type: application/json" \
-d '{ "merchant_reference": "order_1001", "amount": { "value": 1000, "currency": "EUR" },
"payment_method": { "type": "ideal" },
"return_url": "https://shop.example.com/checkout/return" }'
{
"id": "att_…", "status": "requires_action",
"next_action": { "type": "redirect", "url": "https://sandbox.pay.halba.ai/r/att_…?t=…" }
}
# back on your return_url:
# https://shop.example.com/checkout/return?halba_payment_id=att_…&halba_status=captured- Methods:
paypal,klarna,klarna_paynow,ideal,eps,trustly,twint, through your Adyen connection with the method active. - Outcome: PayPal and Klarna end
authorized(capture when you ship); iDEAL, EPS, Trustly and TWINT endcaptured. Alsoprocessing,declined,canceledorexpiredafter one hour. - Confirm on your server with
GET /v1/payments/{id}: the query parameters on your return page only tell you which payment it is. - With halba.js,
halba.handleNextAction(payment)does the redirect for you.
Capture, void and refund
Point to the payment with Halba's id or your merchant_reference.
# capture what is left (or pass "amount" for a partial capture)
curl -X POST https://api.sandbox.halba.ai/v1/payments/order_1001/capture \
-H "Authorization: Bearer hb_test_…" -H "Idempotency-Key: capture-order_1001"
# refund part of it
curl https://api.sandbox.halba.ai/v1/payments/order_1001/refunds \
-H "Authorization: Bearer hb_test_…" -H "Content-Type: application/json" \
-d '{ "amount": 2000, "reason": "Item returned" }'- Capture: without
amount, everything left. A partial capture releases the rest, unless multiple captures are enabled on the PSP connection (then passfinal: false). - Void: cancels the whole authorization, only before any capture.
- Refund: all or part of the captured amount, several times, up to the captured total. With Policy Protect, pass the customer's request as
claim.
Each call returns an operation. Some PSPs confirm later: the operation is pending, then succeeded or failed.
Login protection
Call Halba on every login attempt with the client_session from halba.js on your login page. With Riskified Account Secure the answer is allow, notify or challenge; without a vendor, always allow with screened: false.
curl https://api.sandbox.halba.ai/v1/events/login \
-H "Authorization: Bearer hb_test_…" -H "Content-Type: application/json" \
-d '{ "customer": { "id": "cus_42", "email": "maria@example.com" }, "status": "success", "client_session": "cs_…" }'
{ "id": "lg_…", "object": "login_event", "decision": "allow", "screened": true, "vendor": "riskified" }Idempotency
Send an Idempotency-Key with every payment and operation. Retrying with the same key and body within 24 hours returns the original response with Idempotent-Replayed: true. Same key with a different body: 422; still running: 409; after a 5xx you can retry with the same key.
Errors and limits
{
"error": {
"type": "invalid_request",
"message": "…",
"issues": [{ "path": "customer.email", "message": "…" }]
}
}| HTTP | type | When |
|---|---|---|
| 400 | invalid_request | Body is not JSON or the Idempotency-Key is malformed. |
| 401 | authentication_error | Missing, unknown or revoked key. |
| 404 | not_found | Payment not found in this workspace and environment. |
| 409 | conflict | Request in progress, nothing left to capture, or 3DS event not expected. |
| 422 | invalid_request | Invalid or missing data, or amount above what is available. |
| 422 | configuration_error | No PSP connected, or multiple captures not enabled. |
| 429 | rate_limited | Too many requests with invalid keys from your IP. |
| 502 | provider_error | A PSP or vendor did not respond: retry with the same Idempotency-Key. |
Sandbox test cards
| Token | Result |
|---|---|
tok_visa, tok_mastercard | Authorized |
tok_threeDSecure2Required | 3D Secure challenge, then authorized |
tok_chargeDeclinedInsufficientFunds | Declined: insufficient funds |
tok_chargeDeclinedProcessingError | Technical failure on the primary PSP, authorized on the backup |