Partners
Markdown

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_id is 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" → read incomplete_fields (field paths and messages)
  • blocked_by: "missing_documents" → upload what it names
  • submitted: 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.