Card payments, end to end
Every call from “we have a new merchant” to “the buyer paid”, in order, with what to expect back and what can go wrong at each step.
Read Crypto payments, end to end for the other rail. The first four steps are shared; only the last two differ.
The shape of it
1 create the customer ← once per merchant
2 accept the agreements ← once per merchant
3 run the KYB loop until approved ← once per merchant
4 create a fiat integration ← once per storefront
5 create an order ← per purchase
6 follow it to paid ← per purchase
7 refund it, fully or in part ← when the buyer sends it back
Steps 1–4 you can do in one sitting. Step 3 is the one with a human in it.
You can do step 4 before step 3 finishes. Configuring a checkout is not charging through one, so integrations are not gated on KYB — which means the integration is ready the moment approval lands.
0. Get a token
curl -X POST https://www.niftipay.com/api/oauth/token \
-H "Content-Type: application/json" \
-d '{ "grant_type": "client_credentials",
"client_id": "<partner_client_id>",
"client_secret": "<partner_client_secret>",
"scope": "customers:write kyb:write kyb:read documents:write agreements:accept payments:create payments:read" }'
The token is at data.access_token, not the top level, and lives about five
minutes. Scope requests are all-or-nothing: ask for one scope your client
does not hold and the whole request is refused. See
Authentication.
1. Create the customer
curl -X POST $BASE/customers \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "external_customer_id": "your-id-42", "email": "merchant@example.com", "name": "Example SL" }'
{
"data": {
"partner_customer_id": "pc_…",
"customer_id": "usr_…",
"relationship_status": "active"
}
}Keep partner_customer_id. Every later call is scoped by it, and it is not
the same as customer_id.
This provisions a real merchant behind the scenes — wallets, keys, a default integration. Nothing is charged and nothing is live yet.
external_customer_idis yours and must be unique within your account. Reusing one returns the existing relationship rather than creating a second.
2. Accept the agreements
curl $BASE/customers/pc_…/agreements -H "Authorization: Bearer $TOKEN"
Two come back — terms and privacy — each with a content_hash and
status: "required". For each:
curl -X POST $BASE/customers/pc_…/agreements/niftipay-tos/acceptances \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "agreement_version": "1.0", "acceptance_channel": "partner_api",
"accepted_at": "2026-08-27T09:12:50.000Z",
"evidence": { "external_session_id": "sess_88213" } }'
accepted_at must not be in the future. Acceptance is the mandatory consent
gate — marketing opt-in is not, and never gates onboarding.
3. The KYB loop
This is a loop, not a call. Send what you have; read what is missing; send more.
curl -X POST $BASE/customers/pc_…/verifications \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "business": { "legal_name": "Example SL", "registration_number": "B123",
"industry": "ecommerce_retail", "incorporation_country": "ES",
"incorporation_date": "2019-04-01",
"business_registration_country": "ES",
"registered_address": { "line1": "1 Calle", "city": "Madrid",
"postal_code": "28001", "country": "ES" } } }'
Every response tells you exactly what is left:
{
"data": {
"submitted": false,
"blocked_by": "missing_documents",
"missing_documents": [
"director_id",
"bank_statement"
],
"incomplete_fields": []
}
}blocked_by: "validation_failed"→ readincomplete_fields(field paths and messages)blocked_by: "missing_documents"→ upload what it namessubmitted: true→ the case is in review
Field set and every enum: KYB field reference,
or GET /onboarding/schema to build your form
from data rather than from a page.
Upload the five documents
multipart/form-data, not base64:
curl -X POST $BASE/customers/pc_…/documents \
-H "Authorization: Bearer $TOKEN" \
-F "document_type=certificate_of_incorporation" \
-F "file=@incorporation.pdf;type=application/pdf"
All five are required. Uploads append — a second upload of the same type is how you correct one; there is no delete.
Then re-POST to submit
An empty POST /verifications is a valid way to re-attempt submission once
the documents exist.
Wait for approval
Listen for verification.approved, or poll GET /verifications.
If it comes back rejected, rejection_reason says why —
industry_not_accepted and sanctions_or_pep are not fixable by
resubmitting, so do not offer a retry on those.
If the underwriter needs one more thing, you get a requirement rather than a rejection: the case reopens, the merchant supplies it, and you carry on.
What approval turns on
{
"capability": "payments.fiat_card",
"enabled": true,
"available": true
}payments.fiat_card and withdrawals are KYB-gated; the other four are granted
at creation. Check with
capabilities if you want to render a state.
4. Create a fiat integration
One per storefront. This works before KYB completes — the resource is not capability-gated, so the integration is ready the moment approval lands.
curl -X POST $BASE/customers/pc_…/fiat-integrations \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "name": "storefront-eu",
"return_url": "https://shop.example.com/thanks",
"failure_url": "https://shop.example.com/oops" }'
{
"data": {
"integration": {
"integration_id": "95d92bb0-…",
"psp": "niftipayp1"
}
}
}Keep integration_id — step 5 needs it.
Every URL must be https. Only name, return_url, failure_url and
contact_url are settable; sending psp or merchant_webhook_url is a 400
naming the field, not a silent drop.
Each customer already has one seeded integration called
trapyfy. You can use it, but a storefront with its own return URL wants its own.
5. Create the order
curl -X POST $BASE/customers/pc_…/fiat-orders \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "integration_id": "95d92bb0-…", "amount_cents": 1250, "currency": "EUR",
"reference": "INV-1042",
"return_url": "https://shop.example.com/thanks",
"failure_url": "https://shop.example.com/oops",
"email": "buyer@example.com" }'
{
"data": {
"fiat_order": {
"order_key": "36824",
"status": "new",
"amount_cents": 1250,
"service_fee_cents": 56,
"total_cents": 1306
},
"pay_url": "https://www.niftipay.com/paylink/e9cc4022-…"
}
}Send the buyer to pay_url. Keep order_key — it is how you read the order
back, and it is a short integer, not a uuid.
amount_cents is what you charge; total_cents is what the buyer pays once the
service fee is applied. Do not compute the fee yourself.
Idempotency-Key is required. On a retry, reuse the same key — this call
mints a real payment link, and a fresh key would mint a second one.
6. Follow it
Listen for the payment.* events (payment.confirmed, payment.expired, payment.refunded, … — see Webhooks), or poll:
curl $BASE/fiat-orders/36824 -H "Authorization: Bearer $TOKEN"
Statuses you will see: new → processing → completed, or expired,
cancelled, error, refunded.
7. Refund it
Needs the refunds:write scope and the refunds capability. The money goes
back to the original payer, so you never name a destination and no signed
second factor is involved.
curl -X POST $BASE/fiat-orders/36824/refunds \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "amount_cents": 400, "description": "Item returned" }'
{
"data": {
"order_key": "36824",
"currency": "EUR",
"refund": {
"amount_cents": 400,
"status": "partially_refunded"
},
"service_fee_payer": "customer",
"max_refundable_cents": 1306,
"refunded_cents": 400,
"remaining_refundable_cents": 906
}
}Call it again for each further refund until remaining_refundable_cents is 0,
at which point the order’s status becomes refunded. The ceiling is the buyer’s
total_cents when the buyer paid the service fee, and the merchant’s
amount_cents when the merchant did — never compute it yourself, read
remaining_refundable_cents.
Reuse the idempotency key on a retry here too. A fresh key after a timeout
is a second refund of real money. See
Refund a fiat order for the full error table,
including the 409 refund_full_value_only one processor forces.
What goes wrong, and what it means
| Response | Cause | Do |
|---|---|---|
403 capability_not_enabled |
KYB not approved yet | Wait for verification.approved. Not retryable. |
409 capability_unavailable |
Granted, but the merchant is not currently eligible | Read details[0].reason. |
409 integration_missing |
integration_id is not this customer’s |
Re-read the list. |
409 reference_conflict |
That reference already exists for this merchant |
Pick another, or read the order you already have. |
409 below_minimum |
Under the merchant’s EUR minimum | details[0].minimum_eur has the threshold. |
429 limit_reached |
Daily cap | details[0].reset_at when known. |
404 resource_not_found (on a refund) |
Unknown key, another partner’s order, or refunds not granted |
Read capabilities; do not infer the grant from this. |
409 not_refundable / 409 order_not_paid |
Payment link, or not paid yet | Nothing to retry. |
409 amount_exceeds_remaining / 409 refund_full_value_only |
Over the balance, or a full-value-only processor | Retry with details[0].remaining_refundable_cents. |
502 provider_error |
The processor refused or failed | Retry once, then quote the request_id. |
503 temporarily_unavailable |
Transient | The only code that means retry. |
A plain 500 deliberately does not invite a retry — retrying a
deterministic failure buys a machine caller an endless loop.
Two things worth getting right early
Reuse the idempotency key on retries. The check runs before anything else, so
a replayed key returns the recorded response — same order, same pay_url, same
request_id. A fresh key on a timeout creates a second payment link.
Dedupe webhooks on event_id. It identifies the event, not the delivery
attempt, so every retry carries the same value. Register two destinations and
both receive the same id.