# Niftipay Partner API — complete documentation Docs version 2026-10-17. Generated from https://partners.niftipay.com --- # Introduction Source: https://partners.niftipay.com/getting-started/index.html # Introduction The Niftipay Partner API lets a partner accept card and crypto payments on behalf of its own customers, through a merchant's payment rails. This site documents every partner endpoint. ## Base URL ``` https://www.niftipay.com/api/v1/partner ``` All paths below are relative to that base. The current API version is `2026-08-01` and is returned on every response as `api_version`. ## Before you start **Start here if you are integrating:** the two end-to-end walkthroughs cover every call in order — [Card payments](/guides/fiat-lifecycle.html) and [Crypto payments](/guides/crypto-lifecycle.html). The reference pages below document each endpoint on its own; the guides put them in sequence. 1. Obtain a partner API key (see [Authentication](/getting-started/authentication.html)). 2. Understand [idempotency](/getting-started/idempotency.html) — every write requires an `Idempotency-Key`. 3. Read the [error model](/getting-started/errors.html). --- # Authentication Source: https://partners.niftipay.com/getting-started/authentication.html # Authentication Calls are authenticated with a Bearer token — a short-lived **JWT** — not a static key. ``` Authorization: Bearer ``` The JWT is scoped to your partner (`clientId`) and a set of capabilities (e.g. `payments:create`, `payments.fiat_card`). Requests without a valid token are refused with `401`. ## Available scopes Each endpoint's reference page states the scope(s) it requires. The full set: | Scope | Grants | | --- | --- | | `customers:read` | Read partner customers and their capabilities. | | `customers:write` | Create partner customers. | | `payments:read` | Read fiat integrations and fiat orders. | | `payments:create` | Create/update fiat integrations; create fiat orders. | | `refunds:write` | Refund a fiat order (also requires the `refunds` capability). | | `orders:read` | Read crypto orders. | | `orders:create` | Create crypto orders. | | `wallets:read` | Read deposit wallets. | | `wallets:write` | Create deposit wallets. | | `withdrawals:read` | Read withdrawals and withdrawal wallets. | | `withdrawals:write` | Create withdrawals and withdrawal wallets (also requires the `withdrawals` capability and a signed second factor). | | `kyb:read` | Read verification records and their field requirements. | | `kyb:write` | Submit verification records. | | `documents:read` | List and download KYB documents. | | `documents:write` | Upload KYB documents. | | `agreements:read` | Read agreements and this customer's acceptance status. | | `agreements:accept` | Accept an agreement on a customer's behalf. | | `partner:read` | Read your own client's configuration — the onboarding schema, webhook subscriptions. | | `payouts:read` | Read-only access to a customer's payout forecast and history. | | `payout_wallets:read` | Read a customer's fiat payout wallet book (also requires the `payout_wallets` capability). | | `payout_wallets:write` | Add, rename, archive, replace and set the payout wallet (also requires the `payout_wallets` capability and a signed second factor). | ## Partner client credentials When your partner integration is provisioned you receive two values — an OAuth **client id** and **client secret**. These are what you use to obtain (or re-obtain) the JWT: | Value | Description | | --- | --- | | `partner_client_id` | Your OAuth client identifier. It becomes the `sub` claim of the JWT (`partner:`). | | `partner_client_secret` | The client secret. It is exchanged for the JWT and is **never** sent on API calls — only to the token endpoint. | > A partner customer (`partnerCustomerId`) is the link between a partner's > external customer and the merchant's ledger. Every order, wallet and > withdrawal is attributed to one. ## Obtaining the JWT (client_credentials) POST `/api/oauth/token` > No scope, and no `Authorization` header — this is the endpoint that mints one. Exchange your client credentials for a JWT using the OAuth `client_credentials` grant: ```bash curl -X POST https://www.niftipay.com/api/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "client_credentials", "client_id": "", "client_secret": "", "scope": "customers:read customers:write payments:create ..." }' ``` The token response uses the same envelope as every other endpoint — the token is at `data.access_token`, **not** at the top level: ### Response `200` ```json { "data": { "access_token": "", "expires_in": 300, "token_type": "bearer" }, "request_id": "req-1", "api_version": "2026-08-01" } ``` Read it defensively. A client that reads `access_token` off the top level gets `undefined`, sends `Authorization: Bearer undefined`, and the failure surfaces one call later as `jwt malformed` — a long way from its cause. Send the returned `access_token` as the `Authorization: Bearer ` header. The token is short-lived (see `expires_in`); mint a fresh one before it expires. If you were instead issued a ready-made API key (a JWT) directly, you can use it as the Bearer token without this exchange. ## Handling your credentials - **Keep the secret server-side.** `partner_client_secret` must live only in your backend (or your platform's secret store) and the environment of this console. Never ship it in client-side code, commit it, or expose it in a browser. - **The JWT is not the secret.** The secret mints tokens; only the JWT is sent on each API request. Don't paste the secret where a token is expected, and don't paste a token into the secret field. - **Rotate the secret, not the token flow.** If you rotate `partner_client_secret`, update it wherever it is configured; tokens already minted stay valid until they expire, then new ones use the rotated secret. - **Mind scopes.** The JWT is minted with the scopes you request, limited to what your client is allowed. Requesting a scope your client lacks returns `invalid_scope`; calling an endpoint whose scope you didn't request returns `403 insufficient_scope`. - **Scope requests are all-or-nothing.** If any one scope you ask for falls outside your client's grant, the token endpoint refuses the **whole** request rather than issuing a token with the subset it could honour. There is no introspection endpoint, so a client cannot discover its own scopes — ask for what you need, and if you get `invalid_scope`, retry with less. - **Treat JWTs as secrets too.** Although short-lived, log them sparingly and don't embed them in URLs. --- # Idempotency Source: https://partners.niftipay.com/getting-started/idempotency.html # Idempotency Every state-changing request must carry a unique `Idempotency-Key` header (a UUID). Replaying the same key returns the original result instead of creating a duplicate. - The key is required; omitting it is a `400 invalid_request`. - A key is **released back to you** when the request is refused *before* the service runs (for example a wrong-typed field or a missing required URL), so you can fix the payload and retry with the same key. - A key is **consumed** once the request reaches the service; a later replay with the same key returns the first outcome. --- # Signing withdrawals Source: https://partners.niftipay.com/getting-started/withdrawal-signing.html # Signing withdrawals The money-out endpoints — [Register a withdrawal wallet](/api/withdrawal-wallets/create.html), [Create a withdrawal](/api/withdrawals/create.html) and every write to a customer's [payout wallets](/api/payout-wallets/rules.html) — need a second credential beyond your bearer token: an Ed25519 assertion in `X-Withdrawal-Signature`, signed with the same key. Everything else in this API needs only the token. ## Why A bearer token crosses TLS terminators, proxies, log aggregators and your own HTTP client's memory. It is the credential that realistically leaks. This one does not travel. You hold the private key; Niftipay holds only the public half and can never produce a signature with it. Someone who steals your token still cannot move money — they would also need code execution wherever your private key lives. ## Setup, once 1. Generate an **Ed25519** keypair. ```bash openssl genpkey -algorithm ed25519 -out withdrawal-key.pem openssl pkey -in withdrawal-key.pem -pubout -out withdrawal-key.pub ``` 2. Send us `withdrawal-key.pub` — the public half only. Never the private one; we have no use for it and no way to ask for it. 3. We register it and reply with a **fingerprint**. Compare it against what you deployed. That fingerprint is the only representation of the key that appears in our responses, logs or screens. Until that registration happens **every** money-out request is refused with `401 signature_required`, and it looks exactly like a broken signature — the reason (`no_key`) is logged on our side, not returned. If your very first signed request fails, confirm the key is registered before debugging your signing code. Keep the private key wherever your other server-side secrets live. It signs money movement, so it warrants the same handling as your database credentials — and it should never reach a browser. ## Building the assertion, per request ``` target = sha256hex(rawBody) canonical = JSON.stringify(["partner.withdrawal", target, clientId, nonce, iat, exp]) signature = ed25519_sign(privateKey, utf8Bytes(canonical)) header = base64url(canonical) + "." + base64url(signature) ``` | Field | Value | |-------------|-----------------------------------------------------------------------| | action | `"partner.withdrawal"` for withdrawals and withdrawal wallets; `"partner.payout_wallet"` for payout-wallet writes. An assertion for one is refused by the other. | | target | Lowercase hex SHA-256 of the request body, as bytes sent. | | clientId | Your OAuth client id. Must match the token's caller. | | nonce | Unique per assertion. Single-use. | | iat / exp | **Seconds** since epoch. `exp - iat` ≤ 120. `iat` ≤ now + 30s. | Ed25519 as specified — no prehash, no context string. Sign the canonical string's UTF-8 bytes directly. ### The canonical form is a positional array Not an object. You will reimplement this in another language, and two implementations that agree on every field can still disagree on key ordering, whitespace, or how a number serialises. A positional array leaves nothing to disagree about. We verify against our **own** recomputation of that array, never against the bytes you sent. A payload reshaped in transit cannot ride its original signature. ## Example ```js import { createHash, sign, randomUUID } from "node:crypto"; function withdrawalSignature(privateKey, clientId, rawBody) { const iat = Math.floor(Date.now() / 1000); const canonical = JSON.stringify([ "partner.withdrawal", createHash("sha256").update(rawBody, "utf8").digest("hex"), clientId, randomUUID(), iat, iat + 60, ]); const sig = sign(null, Buffer.from(canonical, "utf8"), privateKey); return `${Buffer.from(canonical).toString("base64url")}.${sig.toString("base64url")}`; } // Serialise ONCE. Sign that string, and send that same string. const rawBody = JSON.stringify({ chain: "BTC", asset: "BTC", amount: "0.5", to: "bc1q…" }); await fetch(`${BASE}/customers/${customerId}/withdrawals`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "Idempotency-Key": randomUUID(), "X-Withdrawal-Signature": withdrawalSignature(privateKey, clientId, rawBody), }, body: rawBody, }); ``` ## Three things that will catch you out **Sign the bytes you send.** `target` hashes the raw body. If anything re-serialises it after you sign — a pretty-printer, a middleware that reorders keys, a client that re-encodes JSON — you get `target_mismatch` on a payload that looks correct. Serialise once, hash that string, send that string. **One assertion per request.** The nonce is single-use, and `exp - iat` cannot exceed 120 seconds. Do not cache an assertion across calls. **On a retry, reuse the same `Idempotency-Key`.** The idempotency check runs *before* signature verification, so a retried key replays the recorded response without needing a fresh signature. If a first attempt burned its nonce but its response was lost to you, mint a new assertion with a new nonce and keep the same `Idempotency-Key`. ## Rotation Send us a new public key. The outgoing one keeps verifying for a grace window — 24 hours by default — so a partner mid-redeploy is never locked out of its own money. Both keys are accepted during the window; sign with whichever you have. If a private key is compromised, tell us and ask for a **zero grace** rotation. The old key stops verifying on the very next request. ## Failure reasons `401 signature_required` covers all of these. The specific reason is logged on our side and quoted against your `request_id`, rather than returned — telling a caller *which* check failed is a hint we would rather not hand to someone probing: | Reason | What it means | |--------|----------------| | `missing` | No header. | | `no_key` | We hold no usable key for you. Registration has not happened. | | `bad_signature` | Malformed token, or no registered key verifies it. | | `expired` / `issued_in_future` / `ttl_too_long` | Clock or TTL bounds. Check `exp - iat ≤ 120` and your clock. | | `target_mismatch` | The body hash does not match the body received. | | `client_mismatch` | `clientId` is not the authenticated caller. | | `action_mismatch` | `action` is not the one the endpoint expects (`partner.withdrawal` or `partner.payout_wallet`). | | `nonce_replayed` | That nonce was already used. | --- # Errors Source: https://partners.niftipay.com/getting-started/errors.html # Errors Every error uses the same envelope: ```json { "error": { "code": "invalid_request", "message": "…", "request_id": "req-1", "details": [ { "field": "return_url", "reason": "invalid" } ] } } ``` `details` is optional and machine-readable. When an endpoint can say *which* field it refused and *why*, it puts that here rather than expecting you to parse `message` — `message` is prose and may be reworded. Quote `request_id` to support. It is also returned as the `x-request-id` response header, so you can log it without parsing a body — which matters most on the responses you failed to parse. ## Codes | Status | code | Meaning | |--------|---------------------------|-------------------------------------------------------------| | 400 | invalid_request | Unknown field, non-https URL, wrong-typed field, bad body | | 400 | idempotency_key_required | A write that requires `Idempotency-Key` did not get one | | 400 | idempotency_key_invalid | The key is longer than the cap | | 401 | unauthorized | Missing, malformed or expired token | | 403 | insufficient_scope | Valid token, but minted without the scope this endpoint needs | | 403 | capability_not_enabled | The capability is not granted on this customer | | 404 | resource_not_found | Resource does not exist **or is not yours** — the two are deliberately indistinguishable | | 409 | verification_in_progress | The case is under review. [Raise a requirement](/api/requirements/list.html) to reopen it for what you hold | | 409 | capability_unavailable | Granted, but the merchant cannot currently use it (see `details[].reason`) | | 409 | reference_conflict | The `reference` is already used (generic, ownerless) | | 409 | not_refundable | The order cannot be refunded at all — payment link, or no processor-side payment | | 409 | order_not_paid | Only a paid or completed order can be refunded | | 409 | nothing_to_refund | The refundable balance is already zero | | 409 | amount_exceeds_remaining | Over the remaining refundable balance; `details[].remaining_refundable_cents` | | 409 | refund_full_value_only | This processor refunds the full remaining balance only; `details[].remaining_refundable_cents` | | 409 | name_conflict | A named resource with that name already exists on the customer | | 409 | limit_reached | A per-customer ceiling was hit | | 409 | idempotency_key_reuse | Same key, different body or different path | | 409 | idempotency_key_in_progress | The first attempt with this key is still running | | 422 | validation_error | Service-level validation failed | | 429 | limit_reached | A rate or daily cap was hit; `details[].reset_at` when known | | 500 | internal_error | Unexpected server error | | 502 | provider_error | The payment provider refused or failed | | 503 | temporarily_unavailable | Transient failure. **Retry** — this is the only code that means that | ## Two rules worth building against **`404` never confirms existence.** A resource belonging to another partner or another merchant answers exactly like one that was never created. A `403` there would confirm the id is real and turn a retrieve endpoint into a discovery endpoint. So a `404` means *"not yours, or not there"* — check both. **Only `503 temporarily_unavailable` means retry.** Errors are classified before they are returned: a dropped connection earns that code, a deterministic failure does not. A client that retries on every `5xx` will loop forever against a bug, which is why a plain `500` deliberately does not invite one. > `reference_conflict` is deliberately generic — it does **not** reveal whose > order holds the conflicting reference. > Unmatched paths under `/api/v1/partner/*` return this JSON envelope too, not > an HTML error page, so a client can parse a typo'd URL the same way it parses > everything else. --- # Machine-readable docs Source: https://partners.niftipay.com/getting-started/machine-readable.html # Machine-readable docs Four artefacts, rebuilt on every deploy from the same pages you are reading. Use them instead of scraping the HTML — scraped pages arrive with nav chrome, and code fences do not survive the trip. | What | Where | For | |---|---|---| | One page as Markdown | any page + `.md` | Pasting one endpoint into a model | | Index of every page | [`/llms.txt`](/llms.txt) | Letting an agent fetch only what it needs | | The whole corpus | [`/llms-full.txt`](/llms-full.txt) | One paste, whole API in context | | OpenAPI 3.1 | [`/openapi.json`](/openapi.json) | Client generators, Postman, Insomnia | ## Any page, as Markdown Swap `.html` for `.md`: ``` https://partners.niftipay.com/api/withdrawals/create.html ← what you read https://partners.niftipay.com/api/withdrawals/create.md ← what you paste ``` Every page also carries **Markdown** and **Copy** buttons in the top right. Copy puts the raw Markdown on your clipboard. ## `/llms.txt` and `/llms-full.txt` [`/llms.txt`](/llms.txt) follows the [llmstxt.org](https://llmstxt.org) convention: a short summary and every page as a link, grouped. Hand it to an agent that can fetch, and it pulls the one page it needs rather than the whole site. [`/llms-full.txt`](/llms-full.txt) is everything concatenated, ordered the way the sidebar is — so a model reading top to bottom meets authentication before it meets withdrawals. Around 125 KB, which fits comfortably in a modern context window. ```bash curl -s https://partners.niftipay.com/llms-full.txt > niftipay-partner-api.md ``` ## `/openapi.json` OpenAPI 3.1, generated from these pages. Carries paths, methods, summaries, path and query parameters, bearer security, the request and response examples printed on each page, and `x-required-scopes` per operation. ```bash npx @openapitools/openapi-generator-cli generate \ -i https://partners.niftipay.com/openapi.json -g typescript-fetch -o ./niftipay ``` ### What it deliberately does not contain **Field-level JSON Schemas for request bodies.** The reference describes fields in prose tables, and machine-reading those would produce a schema that looks authoritative and is not — which is worse than none, because a client generator would emit types from it without hesitating. For the one place a field-level contract genuinely exists, use the API itself: ``` GET /api/v1/partner/onboarding/schema ``` That returns the KYB field set as data — every enum inline, `required_at_submit` per field, repeating groups with their shapes. See [Onboarding schema](/api/onboarding-schema.html). So: **`openapi.json` is the surface map, `/onboarding/schema` is the field contract.** Neither is a substitute for the other. ## Versioning `openapi.json` carries `info.version`, and `llms.txt` states a docs version. Both are dates. They move when the docs are rebuilt, which is not the same as the API changing — the API's own contract version is `api_version`, returned on every response, and that is the one to pin behaviour to. --- # Create a customer Source: https://partners.niftipay.com/api/customers/create.html # Create a customer POST `/api/v1/partner/customers` > Required scope: `customers:write`. Links a partner's external customer to the merchant ledger. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "external_customer_id": "cust_42", "email": "buyer@example.com" }' ``` | Field | Type | Required | Notes | |-----------------------|--------|----------|------------------------------------| | `external_customer_id`| string | yes | Your id for this customer (≤200). | | `email` | string | yes | Used to link repeat customers. | | `name` | string | no | Optional display name. | | `phone` | string | no | Optional (≤32). | | `telegram_handle` | string | no | Optional (≤64), leading `@` stripped. | Unrecognised fields are refused, never ignored. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "partner_customer_id": "pc-1", "customer_id": "user_abc", "external_customer_id": "cust_42", "relationship_status": "active", "created_at": "2026-08-01T00:00:00.000Z", "customer": { "email": "buyer@example.com", "name": null, "country": null, "created_at": null } } } ``` ## Errors `400 invalid_request` for unrecognised fields or a missing/wrong-typed `external_customer_id` or `email`. `409 customer_exists` if the customer already belongs to another identity. --- # List customers Source: https://partners.niftipay.com/api/customers/list.html # List customers GET `/api/v1/partner/customers` > Required scope: `customers:read`. Returns the partner's customers (cursor-paginated). ```bash curl https://www.niftipay.com/api/v1/partner/customers \ -H "Authorization: Bearer " ``` | Query | Type | Notes | |--------------|--------|---------------------------| | `limit` | int | Page size (default 50). | | `cursor` | string | Opaque cursor from prior response. | ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "customers": [ { "partner_customer_id": "pc-1", "customer_id": "user_abc", "external_customer_id": "cust_42", "relationship_status": "active", "created_at": "2026-08-01T00:00:00.000Z", "customer": { "email": "buyer@example.com", "name": null, "country": null, "created_at": null } } ], "next_cursor": null } } ``` --- # Retrieve a customer Source: https://partners.niftipay.com/api/customers/retrieve.html # Retrieve a customer GET `/api/v1/partner/customers/{customerId}` > Required scope: `customers:read`. ```bash curl https://www.niftipay.com/api/v1/partner/customers/pc-1 \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "partner_customer_id": "pc-1", "customer_id": "user_abc", "external_customer_id": "cust_42", "relationship_status": "active", "created_at": "2026-08-01T00:00:00.000Z", "customer": { "email": "buyer@example.com", "name": null, "country": null, "created_at": null } } } ``` `404 not_found` if the customer is not yours. --- # List fiat integrations Source: https://partners.niftipay.com/api/fiat-integrations/list.html # List fiat integrations GET `/api/v1/partner/customers/{customerId}/fiat-integrations` > Required scope: `payments:read` (global tier). An **integration** is one checkout configuration belonging to your customer: a name, and the URLs a buyer is sent to after paying or failing. Its `integration_id` is the value [Create a fiat card order](/api/fiat-orders/create.html) requires. Every customer starts with one integration seeded at signup, so this list is never empty for a live customer. ## Not gated on `payments.fiat_card` All five integration endpoints need the scope and the relationship — and deliberately **not** the `payments.fiat_card` capability that gates the order rail. That capability only turns on once KYB completes, and gating configuration on it would lock you out of preparing an integration for exactly as long as you are waiting for KYB. So you can create and configure integrations while verification is still pending. Charging through one is a separate question, and `POST /fiat-orders` answers it on its own. ## Request ```bash curl https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-integrations \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "integrations": [ { "integration_id": "03c9bad0-3174-4def-8d88-651693617c39", "name": "trapyfy", "psp": "niftipayp1", "return_url": "https://shop.test/done", "failure_url": "https://shop.test/oops", "contact_url": null, "created_at": "2026-08-26T15:10:19.465Z", "updated_at": null } ] } } ``` Ordered oldest first, so the seeded integration is the first entry. | Field | Notes | |------------------|--------------------------------------------------------------------| | `integration_id` | Pass this to `POST /fiat-orders` as `integration_id`. | | `name` | Your label. Unique per customer. | | `psp` | The processor, always reported as `niftipayp1`. Read-only. | | `return_url` | Where the buyer goes after a successful payment. | | `failure_url` | Where the buyer goes after a decline. May be `null`. | | `contact_url` | Support page shown to the buyer. May be `null`. | | `updated_at` | `null` until the integration is changed. | ## What is never returned An integration row also carries the **merchant's** own webhook URL and its PSP project credentials. Neither is readable through this API, and neither is settable — see [Create](/api/fiat-integrations/create.html). ## Errors | Status | code | Meaning | |--------|-------------------------|-----------------------------------------------| | 403 | insufficient_scope | Token was not minted with `payments:read`. | | 404 | resource_not_found | Customer is not yours / relationship missing. | | 503 | temporarily_unavailable | Transient database failure. Retry. | --- # Create a fiat integration Source: https://partners.niftipay.com/api/fiat-integrations/create.html # Create a fiat integration POST `/api/v1/partner/customers/{customerId}/fiat-integrations` > Required scope: `payments:create`. `Idempotency-Key` is **required**. Creates a new checkout configuration for your customer. Use one per storefront that needs its own return destination. Like the rest of this resource, it is **not** gated on `payments.fiat_card`, so you can set an integration up while the customer's KYB is still pending. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-integrations \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "name": "storefront-eu", "return_url": "https://shop.test/done", "failure_url": "https://shop.test/oops", "contact_url": "https://shop.test/support" }' ``` | Field | Type | Required | Notes | |---------------|--------|----------|-----------------------------------------------------------| | `name` | string | yes | Your label. Max 100 chars. Unique per customer. | | `return_url` | string | yes | **https only.** Where the buyer goes after paying. | | `failure_url` | string | no | **https only.** Where the buyer goes after a decline. | | `contact_url` | string | no | **https only.** Support page shown to the buyer. | ## Those four fields are the whole surface `psp`, `psp_project_id` and `merchant_webhook_url` are **not settable**, and sending one is a `400` naming the field — not a silent drop: ```json { "error": { "code": "invalid_request", "message": "`psp` is not a settable field on a fiat integration. Settable fields are: name, return_url, failure_url, contact_url.", "request_id": "req-1", "details": [ { "field": "psp", "reason": "unknown_field" } ] } } ``` Two reasons the refusal is loud rather than tolerant: - **Every settable field is a URL a buyer is sent to.** A misspelled `returnURL` that was quietly ignored would leave the integration pointing wherever it pointed before, and you would find out from a buyer landing on the wrong page. - `merchant_webhook_url` is where the **merchant's** payment notifications go. A partner able to write a field it cannot read could silently redirect them. Your own events arrive on the [partner webhook](/api/webhooks.html) channel instead. `name` over 100 characters is refused, never truncated — a shortened name is a row you cannot find again by the name you think you wrote. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "integration": { "integration_id": "95d92bb0-65a4-462f-ae14-90be85bea223", "name": "storefront-eu", "psp": "niftipayp1", "return_url": "https://shop.test/done", "failure_url": "https://shop.test/oops", "contact_url": "https://shop.test/support", "created_at": "2026-08-27T08:53:29.538Z", "updated_at": null } } } ``` ## Idempotency Required here, and only here on this resource. A retry after a socket timeout cannot know whether the first attempt landed, and without the key that retry silently doubles the customer's integration list — the list a later `POST /fiat-orders` then has to pick from by id. Replaying the same key with the same body replays the original response, including its `request_id`. The same key with a **different** body is `409 idempotency_key_reuse`. See [Idempotency](/getting-started/idempotency.html). A `400` decided from the request alone (bad JSON, or a field that failed validation) **releases** the key, so you can fix the payload and retry with the same one. ## Limits A customer may hold at most **25** integrations. The 26th is `409 limit_reached`. This is a ceiling against a runaway retry loop, not a product limit — a customer needs one integration per storefront. ## Errors | Status | code | Meaning | |--------|--------------------------|-----------------------------------------------------------| | 400 | invalid_request | Unknown field, non-https URL, missing `name`/`return_url`, name over 100 chars. | | 400 | idempotency_key_required | No `Idempotency-Key` header. | | 403 | insufficient_scope | Token was not minted with `payments:create`. | | 404 | resource_not_found | Customer is not yours / relationship missing. | | 409 | name_conflict | The customer already has an integration with that name. | | 409 | limit_reached | The customer already holds 25 integrations. | | 409 | idempotency_key_reuse | Same key, different body. | | 503 | temporarily_unavailable | Transient database failure. Retry. | --- # Retrieve a fiat integration Source: https://partners.niftipay.com/api/fiat-integrations/retrieve.html # Retrieve a fiat integration GET `/api/v1/partner/customers/{customerId}/fiat-integrations/{integrationId}` > Required scope: `payments:read` (global tier). Returns one integration. Same object as a [list](/api/fiat-integrations/list.html) entry. ## Request ```bash curl https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-integrations/95d92bb0-65a4-462f-ae14-90be85bea223 \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "integration": { "integration_id": "95d92bb0-65a4-462f-ae14-90be85bea223", "name": "storefront-eu", "psp": "niftipayp1", "return_url": "https://shop.test/done", "failure_url": "https://shop.test/oops", "contact_url": null, "created_at": "2026-08-27T08:53:29.538Z", "updated_at": "2026-08-27T08:53:40.151Z" } } } ``` ## An id that is not yours is `404`, never `403` An `integrationId` belonging to a different merchant answers exactly the same as one that does not exist. A `403` would confirm the id is real, which is the customer-discovery the relationship guard exists to prevent — the same rule [Retrieve a customer](/api/customers/retrieve.html) follows. Practically: if you get a `404` you cannot tell whether you mistyped the id or aimed a valid id at the wrong customer. Check both. ## Errors | Status | code | Meaning | |--------|-------------------------|----------------------------------------------------| | 403 | insufficient_scope | Token was not minted with `payments:read`. | | 404 | resource_not_found | No such integration **on this customer**. | | 503 | temporarily_unavailable | Transient database failure. Retry. | --- # Update a fiat integration Source: https://partners.niftipay.com/api/fiat-integrations/update.html # Update a fiat integration PATCH `/api/v1/partner/customers/{customerId}/fiat-integrations/{integrationId}` > Required scope: `payments:create`. **No** `Idempotency-Key`. Changes named fields on an existing integration. Fields you omit are left alone. ## Request ```bash curl -X PATCH https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-integrations/95d92bb0-65a4-462f-ae14-90be85bea223 \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "storefront-eu-v2", "contact_url": "https://shop.test/support" }' ``` | Field | Type | Notes | |---------------|---------------|---------------------------------------------------------| | `name` | string | Max 100 chars. Must stay unique on this customer. | | `return_url` | string | https only. **Cannot** be set to `null`. | | `failure_url` | string \| null | https only. `null` clears it. | | `contact_url` | string \| null | https only. `null` clears it. | Same closed field set as [Create](/api/fiat-integrations/create.html): anything else is `400 invalid_request` with `reason: "unknown_field"`. An **empty body** is `400` with `{ "field": "body", "reason": "empty" }` rather than a no-op success — a `200` for a request that changed nothing would be indistinguishable from one that worked. `null` clears the two optional URLs; that is the only way to unset one. It is refused on `return_url`, which every order needs. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "integration": { "integration_id": "95d92bb0-65a4-462f-ae14-90be85bea223", "name": "storefront-eu-v2", "psp": "niftipayp1", "return_url": "https://shop.test/done", "failure_url": "https://shop.test/oops", "contact_url": "https://shop.test/support", "created_at": "2026-08-27T08:53:29.538Z", "updated_at": "2026-08-27T08:53:40.151Z" } } } ``` `updated_at` is stamped on every successful `PATCH`. ## Why no idempotency key A `PATCH` here sets named columns to supplied values, so replaying it lands on the same row in the same state. Requiring a key on a naturally idempotent verb would only hand you a `409` to work around. The creating `POST` is the one that produces something new per attempt, and that is the one that requires a key. ## Errors | Status | code | Meaning | |--------|-------------------------|---------------------------------------------------------------| | 400 | invalid_request | Unknown field, non-https URL, empty body, `return_url: null`, name over 100 chars. | | 403 | insufficient_scope | Token was not minted with `payments:create`. | | 404 | resource_not_found | No such integration **on this customer**. | | 409 | name_conflict | Another integration on this customer already uses that name. | | 503 | temporarily_unavailable | Transient database failure. Retry. | > Renaming an integration to the name it already has is fine — the conflict > check ignores the row being changed. --- # Delete a fiat integration Source: https://partners.niftipay.com/api/fiat-integrations/delete.html # Delete a fiat integration DELETE `/api/v1/partner/customers/{customerId}/fiat-integrations/{integrationId}` > Required scope: `payments:create`. **No** `Idempotency-Key`. Removes an integration from your customer. ## Read this before you call it Deleting has two side effects that are **not** limited to the integration row: | What | What happens | |-----------------------------|---------------------------------------------------------------------| | Existing fiat orders | Survive, with their `integration_id` cleared. Payment history is not erased, and deleting is not a way to erase it. | | The merchant's webhooks | Any webhook the **merchant** registered against this integration is deleted with it. | The second is the one worth pausing on: tidying up an integration you created can take a merchant's notification wiring with it. There is no undo. ## Request ```bash curl -X DELETE https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-integrations/95d92bb0-65a4-462f-ae14-90be85bea223 \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "deleted": true, "integration_id": "95d92bb0-65a4-462f-ae14-90be85bea223" } } ``` ## Deleting something that is not there is `404` Not a silent `200`. A delete that "succeeded" against a mistyped id would leave you believing something was removed that is still live and still able to take payments. So a second `DELETE` on the same id answers `404`, and that is the correct answer rather than a quirk: the integration is gone either way, and your next read agrees. ## Errors | Status | code | Meaning | |--------|-------------------------|-----------------------------------------------| | 403 | insufficient_scope | Token was not minted with `payments:create`. | | 404 | resource_not_found | No such integration **on this customer**, or already deleted. | | 503 | temporarily_unavailable | Transient database failure. Retry. | --- # Create a fiat card order Source: https://partners.niftipay.com/api/fiat-orders/create.html # Create a fiat card order POST `/api/v1/partner/customers/{customerId}/fiat-orders` > Required scope: `payments:create` (granted only after the partner migration is applied). Creates a card payment on behalf of the customer. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-orders \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "integration_id": "int-1", "amount_cents": 1000, "currency": "EUR", "reference": "INV-1", "return_url": "https://shop.test/done", "failure_url": "https://shop.test/oops", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace", "phone": "+34600111222", "country": "ES" }' ``` | Field | Type | Required | Notes | |------------------|---------|----------|------------------------------------------------------| | `integration_id` | string | yes | Which integration processes the payment. Get one from [List fiat integrations](/api/fiat-integrations/list.html). | | `amount_cents` | integer | yes | Major units are **not** accepted; send cents. | | `currency` | string | yes | ISO 4217, e.g. `EUR`. | | `reference` | string | yes | Your id for this order (unique per merchant). | | `return_url` | string | **yes** | https only. Where the buyer goes after success. | | `failure_url` | string | **yes** | https only. Where the buyer goes after a decline. | | `description` | string | no | Optional memo. | | `email` | string | no | Buyer email. | | `first_name` | string | no | Buyer first name. See **Buyer details** below. | | `last_name` | string | no | Buyer last name. | | `phone` | string | no | Buyer phone. E.164 preferred, e.g. `+34600111222`. | | `country` | string | no | Buyer ISO-2 billing country. Normalized to upper case. | > Both `return_url` and `failure_url` are **required and https-only**. Omitting > either sends the buyer to the merchant's own page on that screen — so the API > refuses it with `400 invalid_request`. ## Buyer details The five buyer fields are all optional, and all are forwarded to the card processor rather than stored on the order. Only `email` is persisted on the order row; the other four are also written to the merchant's customer profile for that email, where the first value seen wins and a later order without them does not erase it. Send them when your checkout already has them. Processors differ in what they require, and the one handling the payment may need a name, a phone and a billing country — when a field is missing, a placeholder is generated for it, which is accepted but makes a card issuer more likely to decline. They are the one place this rail takes **either** naming convention. `first_name` / `last_name` / `phone` / `country` and `firstName` / `lastName` / `customer_phone` / `customer_country` are both read; the snake_case spelling is preferred, to match the rest of this body. A blank or wrong-typed buyer field is **dropped, not refused**. Unlike `reference` or `email`, these never produce a `400` — a bad phone is not a reason to fail a payment the buyer is waiting on. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "fiat_order": { "order_key": "1001", "status": "new", "currency": "EUR", "amount_cents": 1000, "service_fee_cents": 25, "total_cents": 1025, "merchant_reference": "INV-1", "created_at": "2026-08-24T10:00:00.000Z", "updated_at": null }, "pay_url": "https://psp.test/pay/1001" } } ``` ## Errors | Status | code | Meaning | |--------|--------------------|----------------------------------------------------| | 400 | invalid_request | Missing/!https `return_url`/`failure_url`, wrong-typed field | | 409 | reference_conflict | `reference` already used (generic, ownerless) | | 409 | integration_missing | `integration_id` does not belong to this customer | | 409 | capability_unavailable | Card payments granted but not currently usable | > A repeated `reference` returns a generic `reference_conflict` — it never > tells you whose order holds it. --- # List a customer's fiat orders Source: https://partners.niftipay.com/api/fiat-orders/list.html # List a customer's fiat orders GET `/api/v1/partner/customers/{customerId}/fiat-orders` > Required scope: `payments:read`. Returns only orders minted by **this** partner relationship. A partner can never see another partner's orders (they share the merchant's `userId`). ```bash curl https://www.niftipay.com/api/v1/partner/customers/pc-1/fiat-orders \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "fiat_orders": [ { "order_key": "1001", "status": "new", "amount_cents": 1000, "refunded_cents": 0, "chargeback_cents": 0 } ] } } ``` Every order carries `refunded_cents` and `chargeback_cents`, filled from our own records rather than from the processor, so reconciling a page of orders costs no provider calls. A chargeback reverses the whole order, so a disputed order shows its amount in `chargeback_cents` and `0` in `refunded_cents` — see [Retrieve a fiat order](/api/fiat-orders/retrieve.html). --- # Retrieve a fiat order Source: https://partners.niftipay.com/api/fiat-orders/retrieve.html # Retrieve a fiat order GET `/api/v1/partner/fiat-orders/{orderKey}` > Required scope: `payments:read` (global tier). The identifier is `order_key` — the short integer returned on the order — **not** the order's uuid. There is no customer id in the path, so an order can be read back by a partner that stored nothing but the key. ```bash curl https://www.niftipay.com/api/v1/partner/fiat-orders/1001 \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "fiat_order": { "order_key": "1001", "status": "new", "currency": "EUR", "amount_cents": 1000, "service_fee_cents": 25, "total_cents": 1025, "merchant_reference": "INV-1", "refunded_cents": 0, "chargeback_cents": 0, "created_at": "2026-08-24T10:00:00.000Z", "updated_at": null }, "partner_customer_id": "pc-1" } } ``` `partner_customer_id` tells you which of your relationships the order belongs to — the path did not name one. ## What came back: `refunded_cents` and `chargeback_cents` Both are integers in the order's own currency, never `null`, and both are `0` on an order nothing has reversed. | Field | Meaning | |--------------------|---------------------------------------------------------------| | `refunded_cents` | Cents refunded to the buyer on this order so far. | | `chargeback_cents` | Cents taken back by a dispute. | They are read from our own records, so this call stays a plain read: no processor round trip, which is also why the same two fields appear on the [list](/api/fiat-orders/list.html) and cost nothing there. **A chargeback reverses the whole order.** So a disputed order reports its full amount in `chargeback_cents` and `0` in `refunded_cents` — even when a partial refund happened first. The same cents are never reported as both a refund and a dispute, which means `refunded_cents` dropping to `0` is itself a signal: look at `chargeback_cents`. To reconcile, read the pair together; `refunded_cents + chargeback_cents` never exceeds `total_cents`. For the per-refund breakdown, with the processor's own refund ids and the exact remaining balance, use [List an order's refunds](/api/fiat-orders/refunds-list.html). For the dispute records, use [Chargebacks](/api/chargebacks.html). ## Every miss is the same `404` You may only read orders your own relationships minted. An order that does not exist, one belonging to another partner on the same merchant, and one on a merchant you have never onboarded all answer identically: ```json { "error": { "code": "resource_not_found", "message": "No partner-visible fiat order exists for this order key.", "request_id": "req-1" } } ``` That matters more here than elsewhere. `order_key` is a **small consecutive integer from a shared sequence**, so anything that distinguished the cases would not require guessing ids — you could count. A `403` on someone else's order would confirm a live order on a merchant you have no relationship with. --- # Refund a fiat order Source: https://partners.niftipay.com/api/fiat-orders/refund.html # Refund a fiat order POST `/api/v1/partner/fiat-orders/{orderKey}/refunds` > Required scope: `refunds:write`. Also requires the `refunds` capability on the > relationship that owns the order. Sends money back to the buyer who paid the order — fully, or partially, as many times as the remaining balance allows. The identifier is `order_key`, the short integer returned on the order, and there is no customer id in the path: a partner that stored nothing but the key can refund. Refunds are issued against the processor that took the payment, on the merchant account that holds the order. You never name a destination — it is the order's own original payer, which is why this call needs no signed second factor, unlike a withdrawal or a payout-wallet change. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/fiat-orders/1001/refunds \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "amount_cents": 400, "description": "Item returned" }' ``` | Field | Type | Required | Notes | |----------------|---------|----------|----------------------------------------------------------------| | `amount_cents` | integer | yes | Positive integer, in cents. Must not exceed `remaining_refundable_cents`. | | `description` | string | no | Memo forwarded to the processor. `null` is accepted. | | `order_lines` | array | no | Line-level refund, for processors that support it. `null` is accepted. | | `order_lines[].merchant_order_line_id` | string | yes (in a line) | Non-empty. | | `order_lines[].quantity` | integer | yes (in a line) | Positive integer. | An `order_lines` array may not be empty — send `null` or omit the field when you have no lines to name. `Idempotency-Key` is **required**: money moves on this call, and a caller that retried after a socket timeout cannot know whether the first attempt landed. On a retry, **reuse the same key**; a fresh key is a second refund. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "order_key": "1001", "currency": "EUR", "refund": { "amount_cents": 400, "status": "partially_refunded" }, "service_fee_payer": "customer", "max_refundable_cents": 1025, "refunded_cents": 400, "remaining_refundable_cents": 625 } } ``` | Field | Meaning | |------------------------------|-------------------------------------------------------------------------| | `refund.amount_cents` | Exactly the amount you asked for. This rail never substitutes a different figure. | | `refund.status` | `refunded` when nothing is left to refund, otherwise `partially_refunded`. | | `service_fee_payer` | `customer` or `merchant` — it decides the ceiling, see below. | | `max_refundable_cents` | The ceiling for the whole order. | | `refunded_cents` | Total refunded on this order so far, this refund included. | | `remaining_refundable_cents` | What a further refund may still ask for. | The order's own `status` becomes `refunded` once `remaining_refundable_cents` reaches `0`; a partial refund leaves it unchanged. ## How much can be refunded The ceiling depends on who paid the service fee, which is on the order itself: | `service_fee_payer` | `max_refundable_cents` is | Because | |---------------------|------------------------------------|-----------------------------------------------| | `customer` | the order's `total_cents` | the buyer paid the fee on top, so the buyer gets it back | | `merchant` | the order's `amount_cents` (subtotal) | the buyer never paid the fee, so there is nothing of it to return | `refunded_cents` is read from the processor on every call, not from our own records, so a refund issued outside this API still counts against the ceiling. ## Preconditions - The order must be a real order — a **payment link** cannot be refunded (`409 not_refundable`). - Its status must be `paid` or `completed` (`409 order_not_paid`). - It must have reached the processor at all; an order with no processor-side payment answers `409 not_refundable`. ## Some processors refund full value only One processor exposes no partial-refund API. On such an order, an `amount_cents` that is not exactly `remaining_refundable_cents` is **refused** with `409 refund_full_value_only`, and `details[0].remaining_refundable_cents` carries the one amount that would be accepted: ```json { "error": { "code": "refund_full_value_only", "message": "This provider only supports a full refund of the remaining balance. See details for the amount that would be accepted.", "request_id": "req-1", "details": [ { "remaining_refundable_cents": 1025 } ] } } ``` Retry with that figure. Nothing is substituted silently — a machine caller sees only the number it sent, so refunding a different amount without saying so would be worse than a refusal. ## Every miss is the same `404` Four different situations answer identically: the order key does not exist, it belongs to another partner on the same merchant, it belongs to a merchant you never onboarded, or **the `refunds` capability is not granted** on the relationship that owns it. ```json { "error": { "code": "resource_not_found", "message": "No partner-visible fiat order exists for this order key.", "request_id": "req-1" } } ``` Note the last case: unlike every other rail, a missing grant here is a `404`, not `403 capability_not_enabled`. `order_key` is a small consecutive integer from a shared sequence, so anything that told the cases apart would not require guessing ids — you could count. Read [capabilities](/api/capabilities.html) to find out whether `refunds` is granted; do not infer it from this endpoint. A capability that is granted but not currently usable is a separate `409 capability_unavailable` — reaching that check already proves the order is yours, so the distinction reveals nothing. ## Errors | Status | code | Meaning | Key released | |--------|--------------------------|-------------------------------------------------------------------------|--------------| | 400 | invalid_request | Body is not a JSON object, `amount_cents` is not a positive integer, `description` is not a string, `order_lines` is malformed or empty | yes | | 400 | idempotency_key_required | No `Idempotency-Key` header | n/a | | 403 | insufficient_scope | Token minted without `refunds:write` | n/a | | 404 | resource_not_found | Not there, not yours, or `refunds` not granted — see above | n/a | | 409 | not_refundable | `payment_link`, or no processor-side payment to refund | yes | | 409 | order_not_paid | Status is not `paid` or `completed` | yes | | 409 | nothing_to_refund | `remaining_refundable_cents` is already `0` | no | | 409 | amount_exceeds_remaining | Over the remaining balance; `details[0].remaining_refundable_cents` | no | | 409 | refund_full_value_only | Full-value-only processor; `details[0].remaining_refundable_cents` | no | | 409 | capability_unavailable | `refunds` granted, merchant not currently eligible; `details[0].reason` | n/a | | 500 | internal_error | Unexpected failure — the outcome is **unknown**, do not blind-retry | no | | 502 | provider_error | The processor refused or failed | no | | 503 | temporarily_unavailable | Transient failure. **Retry** — the only code that means that | n/a | "Key released" means the `Idempotency-Key` can be reused after fixing the request: it is released only for refusals decided **before** anything was sent to the processor. Once the processor has been contacted the key is consumed, because the outcome of the first attempt is no longer certain — replay the same key to read what happened rather than sending a second refund. Refusals marked `n/a` are decided before the key is recorded at all. ## The event A successful refund emits two webhook events: `refund.completed`, keyed on the refund, and `payment.refunded`, the order's status twin. Both carry the order's identifiers and status, not the refunded amount — use this call's own response, re-read the order for `refunded_cents`, or call [List an order's refunds](/api/fiat-orders/refunds-list.html) for the per-refund breakdown. See [Webhooks](/api/webhooks.html). --- # List an order's refunds Source: https://partners.niftipay.com/api/fiat-orders/refunds-list.html # List an order's refunds GET `/api/v1/partner/fiat-orders/{orderKey}/refunds` > Required scope: `payments:read` (global tier). Requires the > `payments.fiat_card` capability on the relationship that owns the order — > **not** `refunds`: reading what you refunded does not grant refunding. Every refund issued on one order, with what is still refundable. The figures come from the processor on each call, so a refund issued outside this API — from the merchant's own dashboard, or by our support — is included. ```bash curl https://www.niftipay.com/api/v1/partner/fiat-orders/1001/refunds \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "order_key": "1001", "refunds": [ { "id": "rf-1", "amount_cents": 400, "status": "refunded", "created_at": "2026-10-02T09:15:00.000Z" } ], "service_fee_payer": "customer", "max_refundable_cents": 1025, "refunded_cents": 400, "remaining_refundable_cents": 625 } } ``` | Field | Meaning | |------------------------------|------------------------------------------------------------------------------| | `refunds[].id` | The processor's own refund id. | | `refunds[].amount_cents` | That refund's amount, or `null` when the processor did not state one. | | `refunds[].status` | The processor's word for that refund, passed through. `null` when absent. | | `refunds[].created_at` | When the processor created it, or `null` when it does not say. | | `service_fee_payer` | `customer` or `merchant` — it decides the ceiling. | | `max_refundable_cents` | The ceiling for the whole order. | | `refunded_cents` | Total refunded so far, summed from the list above. | | `remaining_refundable_cents` | What a further refund may still ask for. | `refunds` is an empty array on an order that has never been refunded. The ceiling fields are still filled, so this is also the call to make **before** refunding when you want to know what will be accepted. ## This call or the order? Both answer "how much came back", and they are not the same answer: - **[Retrieve a fiat order](/api/fiat-orders/retrieve.html)** carries `refunded_cents` from our own records. No processor round trip, so it is fast and it works on the [list](/api/fiat-orders/list.html) too. Use it to reconcile in bulk. - **This call** asks the processor, and is the authoritative per-refund breakdown. Use it when you need refund ids, or the exact remaining balance before issuing one. ## Errors | Status | code | Meaning | |--------|-------------------------|------------------------------------------------------------------------------| | 400 | invalid_request | The order has no processor-side payment, so it has no refunds to list | | 403 | insufficient_scope | Token minted without `payments:read` | | 404 | resource_not_found | Not there, not yours, or `payments.fiat_card` not granted — one answer for all | | 500 | internal_error | Unexpected failure | | 502 | provider_error | The processor failed to list refunds. The order is fine; the upstream call is not | | 503 | temporarily_unavailable | Transient failure. **Retry** — the only code that means that | The `404` is deliberately indistinguishable, exactly as on [Retrieve a fiat order](/api/fiat-orders/retrieve.html): `order_key` is a small consecutive integer from a shared sequence, so a status that told the cases apart would let you count your way to other partners' live orders. --- # List a customer's payouts Source: https://partners.niftipay.com/api/payouts/list.html # List a customer's payouts GET `/api/v1/partner/customers/{customerId}/payouts` > Required scope: `payouts:read`. Capability: `payouts`. Newest first. Merges two legs onto one timeline: **forecast** rows (`status: "upcoming"`, not yet run) and **history** rows (every payout that has already been executed). A read of this endpoint is exempt from the merchant-eligibility gate — a lapsed KYB grace does not take a merchant's own settlement history offline for the partner that most needs it to reconcile. ```bash curl "https://www.niftipay.com/api/v1/partner/customers/pc-1/payouts?limit=25" \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "payouts": [ { "id": "upcoming_usr_a1b2c3d4_2026-10-15_EUR", "status": "upcoming", "currency": "EUR", "net_cents": 128900, "net_eur_cents": 128900, "payout_due_at": "2026-10-15T00:00:00.000Z", "paid_at": null, "created_at": "2026-10-01T00:00:03.000Z", "orders_count": 14 }, { "id": "7d1dcdd4-9f3a-4c21-8f0e-2b1c9a5d4e10", "status": "paid", "currency": "EUR", "net_cents": 483210, "net_eur_cents": 483210, "payout_due_at": "2026-09-30T00:00:00.000Z", "paid_at": "2026-09-30T08:12:44.000Z", "created_at": "2026-09-23T00:00:05.000Z", "orders_count": 0 } ], "next_cursor": "MjAyNi0xMC0xNVQwMDowMDowMC4wMDBafHVwY29taW5nX3Vzcl9hMWIyYzNkNF8yMDI2LTEwLTE1X0VVUg" } } ``` An empty list is `200` with `[]`, never `404`. The customer exists — "no payouts yet" is a fact about the collection, not a missing resource. ## Query parameters | Parameter | Notes | |---|---| | `?limit=` | Defaults to **25**, caps at **100**. Out-of-range is **clamped, not rejected** — an oversized page size has one obvious correct reading, so it should not fail a partner merely tuning it. | | `?before=` | The `next_cursor` from a previous response. Opaque — treat it as a token, not a value to construct. | | `?status=` | One of `upcoming`, `pending`, `paid`, `failed`, `cancelled`, `processing`. Unlike `limit`, an out-of-range value is **rejected with `400 invalid_request`**, not clamped or ignored: a typo'd status has no obvious correct reading, and silently answering with the unfiltered list would let a partner reconcile against a set it believes is narrower than it actually is. | ## Paging **`next_cursor` is issued only on a full page.** A short page is the end of the list, so it comes back `null`. A full page that happens to be the last one gets a cursor whose next fetch is an empty page — that is the loop's ordinary termination. The cursor is a keyset over `(payout_due_at, id)`, base64url-encoded and opaque. Do not construct one yourself — a hand-built cursor that isn't exactly what this API issued comes back `400 invalid_cursor`. ``` while cursor is not null: page = GET /customers/{id}/payouts?limit=100&before= cursor = page.data.next_cursor ``` **`orders_count` is `0` on every settled (non-`upcoming`) row.** The underlying payout record has no such column — the real count lives inside the payout's `calculation` JSON, and parsing that JSON per row on a list is exactly the cost this endpoint exists to avoid. Fetch [the payout by id](/api/payouts/retrieve) to get the real `orders_count` and the per-order array. Forecast (`upcoming`) rows are unaffected and always carry their real count. ## What a payout covers **A payout is the merchant's entire settlement for the period — not just the business this partner originated.** A payout is computed against the merchant's whole account: their direct sales, orders placed through this partner, and orders placed through any other partner the merchant also works with, all summed against one retention hold and one fee total for that period. So the amounts on this list (and the full breakdown on the [detail read](/api/payouts/retrieve)) can be, and often will be, larger than the sum of orders this partner itself created for that merchant. There is no way to filter a payout down to "this partner's share" — the money was never split that way in the first place. If a specific merchant should not be readable this way, the partner's `payouts` capability on that relationship can be revoked; the payout itself cannot be narrowed. ## Errors | Status | code | Meaning | |--------|-------------------------|-------------------------------------------------------------| | 400 | invalid_cursor | `before` is not a cursor this API issued. | | 400 | invalid_request | `status` is not one of the recognised values. | | 403 | insufficient_scope | Token was not minted with `payouts:read`. | | 403 | capability_not_enabled | `payouts` is off for this customer. | | 404 | resource_not_found | Not your customer, or the relationship was revoked. | | 503 | temporarily_unavailable | Read failed transiently; retry shortly. | | 500 | internal_error | Unknown failure. **Not** an invitation to retry. | --- # Retrieve a payout Source: https://partners.niftipay.com/api/payouts/retrieve.html # Retrieve a payout GET `/api/v1/partner/payouts/{payoutId}` > Required scope: `payouts:read`. Capability: `payouts`. Top-level, not nested under the customer — matching `/api/v1/partner/orders/{orderId}`: a payout id is globally unique, so requiring the caller to also know which customer it belongs to would add a lookup the caller often cannot do. `{payoutId}` accepts **either** id form the list endpoint hands out: - a real payout id, opaque — treat it as a token, not a value to construct or parse, or - a forecast id, `upcoming___` — a projection that has no row of its own yet. Its owner and date are parsed out of the id itself and carried through the identical relationship and capability checks as a real payout. ```bash curl https://www.niftipay.com/api/v1/partner/payouts/7d1dcdd4-9f3a-4c21-8f0e-2b1c9a5d4e10 \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "payout": { "id": "7d1dcdd4-9f3a-4c21-8f0e-2b1c9a5d4e10", "status": "paid", "currency": "EUR", "net_cents": 483210, "net_eur_cents": 483210, "payout_due_at": "2026-09-30T00:00:00.000Z", "paid_at": "2026-09-30T08:12:44.000Z", "created_at": "2026-09-23T00:00:05.000Z", "updated_at": "2026-09-30T08:12:44.000Z", "orders_count": 3, "payout_asset": "USDT", "payout_asset_amount": "5232.45", "payout_asset_rate_eur": 0.9238, "payout_asset_decimals": 6, "orders": [ { "id": "9c1f2b3a-4d5e-6f70-8192-a3b4c5d6e7f8", "order_key": "CRY-1042", "merchant_reference": "INV-2026-0091", "currency": "EUR", "amount_cents": 210000, "subtotal_cents": 205000, "payable_now_cents": 210000, "created_at": "2026-09-24T11:02:00.000Z", "completed_at": "2026-09-24T11:08:41.000Z" } ], "calculation": { "orders_count": 3, "gross_cents": 512000, "fee_cents": 15360, "retention_hold_cents": 10000, "matured_retention_cents": null, "deductions_cents": 3430, "deduction_items": [ { "reason": "chargeback", "cents": 3430, "currency": "EUR", "source_id": "cb_2f1a9c", "forfeited_at": "2026-09-27T09:15:00.000Z" } ], "period_start": "2026-09-16T00:00:00.000Z", "period_end": "2026-09-23T00:00:00.000Z", "settlement_days": 7, "settlement_cutoff": "2026-09-23T23:59:59.999Z" } } } } ``` ## What a payout covers **This is the merchant's entire settlement for the period, not just this partner's business.** `orders[]` and every total in `calculation` are computed across the merchant's whole account — their direct sales, this partner's orders, and any other partner's orders — summed against one retention hold and one fee total. A partner granted `payouts` on a shared merchant will see orders, and `merchant_reference` values, it did not itself create. There is no way to narrow this response to "this partner's share"; see [the list endpoint](/api/payouts/list) for the full reasoning. The instrument for a merchant who must not be exposed this way is revoking `payouts` on that relationship. ## One answer for every miss Every failure to read a payout — the id does not exist, it belongs to a merchant this partner has no relationship with, the relationship was revoked, or the `payouts` capability is not granted on it — comes back as the same `404 resource_not_found`, deliberately. A distinguishable response for any one of those cases would confirm to the caller which payout ids exist for a merchant it cannot otherwise see. ## Errors | Status | code | Meaning | |--------|--------------------------|------------------------------------------------------------| | 403 | insufficient_scope | Token was not minted with `payouts:read`. | | 404 | resource_not_found | No payout with that id is available to this partner (see above — covers a missing id, no relationship, a revoked relationship, and a capability not granted). | | 503 | temporarily_unavailable | Read failed transiently; retry shortly. | | 500 | internal_error | Unknown failure. **Not** an invitation to retry. | --- # Create a crypto order Source: https://partners.niftipay.com/api/crypto-orders/create.html # Create a crypto order POST `/api/v1/partner/customers/{customerId}/orders` > Required scope: `orders:create` + capability `payments.crypto`. Creates a crypto payment. Unlike the fiat rail, a duplicate `reference` is **replayed** rather than refused: the same `reference` for this customer returns the original order. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/orders \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "reference": "CRY-1", "network": "ETH", "asset": "USDT", "amount": 100, "currency": "EUR", "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com", "phone": "+34600111222", "country": "ES" }' ``` | Field | Type | Required | Notes | |--------------|---------|----------|-----------------------------------------------------------| | `reference` | string | yes | Replaying it returns the original order. | | `network` | string | yes | Blockchain, e.g. `ETH`. | | `asset` | string | yes | Token, e.g. `USDT`. | | `amount` | number | yes | Fiat amount in major units (e.g. `100` for €100). | | `currency` | string | yes | e.g. `EUR`. | | `firstName` | string | yes | Buyer first name. | | `lastName` | string | yes | Buyer last name. | | `email` | string | no | Buyer email. Without it no customer profile is created. | | `phone` | string | no | Buyer phone. E.164 preferred. See **Buyer details** below. | | `country` | string | no | Buyer ISO-2 country. Normalized to upper case. | | `merchantId` | string | no | Override the merchant account if the relationship allows. | > `Idempotency-Key` is **required**. A retry after a socket timeout has no way > to know whether the first attempt landed, and this POST mints a deposit > address — so the key is mandatory, not optional. ## Buyer details `phone` and `country` are optional and are **not** stored on the order. A crypto payment settles on-chain, so there is no card processor to present a buyer to. They are written to the merchant's customer profile for this `email`, alongside `firstName` and `lastName`, where the first value seen wins and a later order without them does not erase it. No `email` means no profile, so sending `phone` or `country` without one has no effect. Either naming convention is read: `phone` / `country` and `customer_phone` / `customer_country`. A blank or wrong-typed value is dropped, not refused. ## Response `201` The status is always `201`, on both a fresh create and a replay. A replay is flagged by `"replayed": true` in the body and returns the **original** order. ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "order": { "id": "ord_1", "reference": "CRY-1", "network": "ETH", "asset": "USDT", "amount": "100.00000000", "currency": "EUR", "status": "pending", "address": "0xdeadbeef", "paymentUri": "ethereum:0xdeadbeef?amount=100", "qrUrl": "https://qr.example.com/1", "createdAt": "2026-08-21T10:00:00.000Z", "expiresAt": "2026-08-21T11:00:00.000Z" }, "replayed": false } } ``` ## Errors | Status | code | Meaning | |--------|--------------------------|------------------------------------------------------------------| | 400 | idempotency_key_required | Missing `Idempotency-Key` header. | | 400 | invalid_request | Missing/blank `reference`, or a body that is not JSON. | | 400 | invalid_request | Bad `network`/`asset`/`amount`/`currency` (`invalid_payload`). | | 409 | capability_unavailable | Crypto payments not enabled for this customer. | | 409 | reference_conflict | `reference` already used by a *different* order for this customer. | | 403 | forbidden | This order is not permitted for this customer. | | 503 | temporarily_unavailable | Address pool exhausted or rate unavailable; retry shortly. | | 500 | internal_error | Unknown failure. | > A repeated `reference` for **the same customer** is replayed, not conflicted: > you get the original order back with `"replayed": true`. A `reference_conflict` > (409) only happens when the reference collides with an order under a > *different* relationship — it never tells you whose order holds it. --- # List a customer's crypto orders Source: https://partners.niftipay.com/api/crypto-orders/list.html # List a customer's crypto orders GET `/api/v1/partner/customers/{customerId}/orders` > Required scope: `orders:read`. Capability: `payments.crypto`. Newest first. Returns only orders minted by **this** partner relationship — a partner never sees another partner's orders, even though both share the merchant's underlying account. ```bash curl "https://www.niftipay.com/api/v1/partner/customers/pc-1/orders?limit=25" \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "orders": [ { "id": "7d1dcdd4-9f3a-4c21-8f0e-2b1c9a5d4e10", "reference": "CRY-1042", "network": "ETH", "asset": "USDT", "amount": "116.751879", "totalToSend": "116.751879", "networkFees": null, "currency": "EUR", "fiatAmount": "100", "address": "0x49d83b0d1f2c4a6e8b7d5c3a9f1e0d2b4c6a8e70", "paymentUri": "ethereum:0x49d83b0d?contract=0xdAC17F&amount=116.751879", "qrUrl": "https://www.niftipay.com/api/qr?data=ethereum%3A0x49d83b0d", "status": "pending", "createdAt": "2026-08-27T09:12:02.923Z", "expiresAt": "2026-08-27T21:12:02.923Z" } ], "next_cursor": "b3JkOjE3NjQyMzc1MjI5MjM6N2QxZGNkZDQ" } } ``` An empty list is `200` with `[]`, never `404`. The customer exists — "no orders yet" is a fact about the collection, not a missing resource. ## Paging `?limit=` defaults to **25** and caps at **100**. An out-of-range value is **clamped, not rejected** — a partner tuning a page size should not get a `400`. `?before=` takes the `next_cursor` from a previous response. Treat it as opaque: it is a keyset cursor over `(createdAt, id)`, and constructing one yourself will silently skip rows that share a timestamp at a page boundary. **`next_cursor` is issued only on a full page.** A short page is the end of the list, so it comes back `null`. A full page that happens to be the last one gets a cursor whose next fetch is an empty page — that is the loop's ordinary termination. ``` while cursor is not null: page = GET /customers/{id}/orders?limit=100&before= cursor = page.data.next_cursor ``` A `before` we did not issue is a `400 invalid_cursor` rather than a silent page one — ignoring it would turn "fetch until empty" into an infinite loop. ## Two shape notes **`next_cursor` is snake_case; the order objects are camelCase.** The order view is inherited verbatim from the merchant-internal one rather than re-shaped, so it keeps its own casing. Everything partner-native inside `data` is snake_case. **List rows carry `qrUrl` but not `qrImage`.** The inline PNG is rendered only for single-order reads — a page of 100 would be 100 renders. Fetch the order by id, or render `qrUrl` yourself. ## Errors | Status | code | Meaning | |--------|-------------------------|-------------------------------------------------------------| | 400 | invalid_cursor | `before` is not a cursor this API issued. | | 403 | insufficient_scope | Token was not minted with `orders:read`. | | 403 | capability_not_enabled | `payments.crypto` is off for this customer. | | 404 | resource_not_found | Not your customer, or the relationship was revoked. | | 503 | temporarily_unavailable | Read failed transiently; retry shortly. | | 500 | internal_error | Unknown failure. **Not** an invitation to retry. | --- # Retrieve a crypto order Source: https://partners.niftipay.com/api/crypto-orders/retrieve.html # Retrieve a crypto order GET `/api/v1/partner/orders/{orderId}` > Required scope: `orders:read`. ```bash curl https://www.niftipay.com/api/v1/partner/orders/ord_1 \ -H "Authorization: Bearer " ``` Partners may only fetch orders they can see; a hit and a miss are indistinguishable (`404 resource_not_found`) to avoid leaking existence. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "order": { "id": "ord_1", "reference": "CRY-1", "network": "ETH", "asset": "USDT", "amount": "100.00000000", "currency": "EUR", "status": "pending", "address": "0xdeadbeef", "paymentUri": "ethereum:0xdeadbeef?amount=100", "qrUrl": "https://qr.example.com/1", "createdAt": "2026-08-21T10:00:00.000Z", "expiresAt": "2026-08-21T11:00:00.000Z" }, "partner_customer_id": "pc-1" } } ``` ## Errors | Status | code | Meaning | |--------|--------------------------|---------------------------------------------------| | 404 | resource_not_found | No partner-visible order exists for this id. | | 503 | temporarily_unavailable | Read failed transiently; retry shortly. | | 500 | internal_error | Unknown failure. | --- # Create customer wallets Source: https://partners.niftipay.com/api/wallets/create.html # Create customer wallets POST `/api/v1/partner/customers/{customerId}/wallets` > Required scope: `wallets:write`. Provisions the customer's persistent master wallet addresses, one per chain (BTC, ETH, LTC, SOL, XRP, TRON) on mainnet, and returns them. Call this once per customer; it is idempotent per chain. ## Request No request body. The merchant is taken from the customer relationship, never from the request. This endpoint carries **no** `Idempotency-Key` requirement. ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/{customerId}/wallets \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" ``` ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "wallets": [ { "chain": "BTC", "address": "bc1qbtc", "balance": "0.5", "network": "mainnet" }, { "chain": "ETH", "address": "0xeth", "balance": "0", "balanceUnavailable": true, "tokens": { "USDT": "0", "USDC": "0" }, "tokensUnavailable": ["USDT"], "network": "mainnet" } ] } } ``` `wallets` is an array of objects. Fields: | Field | Type | Required | Notes | |---------------------|-----------------|----------|-------| | `chain` | string | always | e.g. `BTC`, `ETH`. | | `address` | string | always | The master address for the chain. | | `balance` | string | always | Decimal string of the chain-native balance. | | `network` | string | always | Always `mainnet`. | | `balanceUnavailable`| boolean | no | Present only when the balance could not be read. | | `tokens` | object | no | Map of token symbol to decimal-string balance. | | `tokensUnavailable` | array of string | no | Tokens whose balance could not be read. | If the post-provision balance read fails, `wallets` is `[]` and `walletsUnavailable: true` is added (so an empty list is not mistaken for "nothing provisioned"). That marker is omitted on a healthy read and on a genuinely empty provision. ## Errors `503 temporarily_unavailable` if wallet generation (Tatum) fails transiently — retry shortly. `500 internal_error` for permanent provisioning failures — do not retry automatically. --- # List customer wallets Source: https://partners.niftipay.com/api/wallets/list.html # List customer wallets GET `/api/v1/partner/customers/{customerId}/wallets` > Required scope: `wallets:read`. Returns the customer's master wallet addresses and balances across all chains. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/{customerId}/wallets \ -H "Authorization: Bearer " ``` No query parameters. The merchant is taken from the customer relationship. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "wallets": [ { "chain": "BTC", "address": "bc1qbtc", "balance": "0.5", "network": "mainnet" } ] } } ``` `wallets` is an array of objects. See [Create customer wallets](./create.md) for the per-wallet field table (`chain`, `address`, `balance`, `network`, `balanceUnavailable`, `tokens`, `tokensUnavailable`). An empty list (`[]`) is a valid answer for a customer with no wallets — it is not a 404. ## Errors `503 temporarily_unavailable` if balances could not be read transiently — retry shortly. `500 internal_error` for a permanent read failure — do not retry automatically. --- # Create a withdrawal Source: https://partners.niftipay.com/api/withdrawals/create.html # Create a withdrawal POST `/api/v1/partner/customers/{customerId}/withdrawals` > Required scope: `withdrawals:write`, plus the `withdrawals` capability — which > is KYB-gated, so it turns on when the customer's verification is approved. Sends crypto from the customer's wallet to a registered destination. Needs **both** an `Idempotency-Key` and an `X-Withdrawal-Signature`. See [Signing withdrawals](/getting-started/withdrawal-signing.html) for how to build the assertion — it is the same construction for both money-out endpoints. Register the destination with [Register a withdrawal wallet](/api/withdrawal-wallets/create.html) first; `to` must match one that already exists for this customer. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/{customerId}/withdrawals \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "X-Withdrawal-Signature: " \ -H "Content-Type: application/json" \ -d '{ "chain": "BTC", "asset": "BTC", "amount": "0.5", "to": "bc1qdest" }' ``` | Field | Type | Required | Notes | |----------|--------|----------|-------| | `chain` | string | yes | Uppercased. A registered destination must exist for this chain+address. | | `asset` | string | yes | Uppercased. | | `amount` | string | yes | Decimal string. | | `to` | string | yes | Destination address. Must match a registered withdrawal wallet for this customer. | | `network`| string | no | If present, must be `mainnet` (anything else is refused). | Only `chain`, `asset`, `amount`, and `to` are forwarded to the platform service; everything else in the body is ignored. `userId` is set server-side from the relationship and cannot be overridden. `destinationTag`, if any, is taken from the **registered** destination row, never from the request body. ## The merchant may have a minimum Each merchant carries a minimum withdrawal value in EUR. A request whose gross converts to less than that is refused, whatever the asset. The default is no minimum, so most merchants have none — but the ones that do can set it high (€300 is a real value in production). There is no endpoint that reports it, so the refusal carries it instead: ```json { "error": { "code": "below_minimum", "message": "The amount is below the minimum withdrawal this merchant is configured for. See `details[0].minimum_eur`.", "request_id": "req-1", "details": [ { "minimum_eur": 300 } ] } } ``` `minimum_eur` is the threshold in euros. Compare your gross against it and retry with a larger amount — this is a refusal you can act on without contacting anyone. It is absent, rather than null, on the rare occasion we cannot read a number, so you can tell "we did not say" from a value you can use. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "withdrawal": { "tx_hash": "0xabc", "chain": "BTC", "asset": "BTC", "gross": "0.5", "fee": "0.001", "net": "0.499", "gas": "0.0001", "to_address": "bc1qdest" } } } ``` ## Errors `400 idempotency_key_required` when no `Idempotency-Key` header is provided. `401 signature_required` when `X-Withdrawal-Signature` is missing or invalid. `400 invalid_request` for a non-JSON/unreadable body or missing `chain`, `asset`, `amount`, or `to`. `400 unsupported_network` when `network` is present but not `mainnet`. `403 destination_not_registered` when `to` is not a registered destination for this customer (identical response for a destination registered to another merchant). `409 insufficient_balance` when the wallet or token balance is too low. `400 amount_too_small` when the amount is consumed by gas and fees. `400 unsupported_asset` when the asset is unsupported on the chain. `400 invalid_amount` when `amount` is not a valid positive number. `409 below_minimum` when the gross is under the merchant's configured EUR minimum; `details[0].minimum_eur` carries the threshold. `400 withdrawal_refused` for any other service refusal not in the mapped set. `503 temporarily_unavailable` on a 5xx from the service or a transport failure (reservation is kept so a retry is safe). `500 internal_error` for permanent unclassified failures. `409 idempotency_key_reuse` when the same key is reused with a different body. `409 idempotency_key_in_progress` when retried inside the window after a transport failure. --- # List customer withdrawals Source: https://partners.niftipay.com/api/withdrawals/list.html # List customer withdrawals GET `/api/v1/partner/customers/{customerId}/withdrawals` > Required scope: `withdrawals:read`. Returns the customer's recent withdrawals, newest first. Reading history does not require the merchant to be currently eligible to transact. ## Request ```bash curl -X GET "https://www.niftipay.com/api/v1/partner/customers/{customerId}/withdrawals?limit=25" \ -H "Authorization: Bearer " ``` | Param | Type | Required | Notes | |--------|--------|----------|-------| | `limit`| integer| no | Default `25`, maximum `100`. Values above the maximum are clamped to `100`; non-positive or non-numeric values fall back to the default. | ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "withdrawals": [ { "id": "wd-1", "created_at": "2026-08-24T10:00:00.000Z", "chain": "BTC", "asset": "BTC", "gross": "0.5", "fee": "0.001", "to_address": "bc1qdest", "tx_hash": "0xabc", "status": "sent" } ] } } ``` Each withdrawal object: | Field | Type | Notes | |--------------|--------|-------| | `id` | string | Withdrawal id. | | `created_at` | string | ISO-8601 timestamp. | | `chain` | string | Chain. | | `asset` | string | Asset. | | `gross` | string | Decimal string gross amount. | | `fee` | string | Decimal string fee. | | `to_address` | string | Destination address. | | `tx_hash` | string or null | On-chain tx hash, or `null`. | | `status` | string | e.g. `sent`. | An empty list (`[]`) is a valid answer. ## Errors `503 temporarily_unavailable` if the list read fails transiently — retry shortly. `500 internal_error` for a permanent read failure — do not retry automatically. --- # Register a withdrawal wallet Source: https://partners.niftipay.com/api/withdrawal-wallets/create.html # Register a withdrawal wallet POST `/api/v1/partner/customers/{customerId}/withdrawal-wallets` > Required scope: `withdrawals:write`, plus the `withdrawals` capability — which > is KYB-gated, so it turns on when the customer's verification is approved. Adds a destination to the customer's withdrawal allowlist. This is the endpoint the signature really protects. A withdrawal can only send to an address already on the allowlist, so an attacker holding just your bearer token cannot move funds anywhere new — adding a destination needs the signature too. Needs both an `Idempotency-Key` and an `X-Withdrawal-Signature`; see [Signing withdrawals](/getting-started/withdrawal-signing.html). ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/{customerId}/withdrawal-wallets \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "X-Withdrawal-Signature: " \ -H "Content-Type: application/json" \ -d '{ "chain": "BTC", "asset": "BTC", "label": "Treasury", "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "destination_tag": "12345" }' ``` | Field | Type | Required | Notes | |------------------|--------|----------|-------| | `chain` | string | yes | One of `BTC`, `ETH`, `LTC`, `SOL`, `XRP`, `TRON` (uppercased). | | `asset` | string | yes | Uppercased. | | `label` | string | yes | Non-blank display label. | | `address` | string | yes | Must be valid for `chain` (typo guard). | | `destination_tag`| string | no | Optional; if present must be a non-blank string. | `userId` is set server-side from the relationship and ignored if supplied. At most 20 destinations may be registered per chain per customer. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "wallet": { "id": "w-1", "chain": "BTC", "asset": "BTC", "label": "Treasury", "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "destination_tag": "12345", "created_at": "2026-08-24T10:00:00.000Z", "last_used_at": null } } } ``` ## Errors `400 idempotency_key_required` when no `Idempotency-Key` header is provided. `401 signature_required` when `X-Withdrawal-Signature` is missing or invalid. `400 invalid_request` for a non-JSON body or missing `chain`, `asset`, `label`, or `address`. `400 unsupported_chain` when `chain` is not one of the six supported chains. `400 invalid_address` when `address` is not valid for its chain. `409 wallet_limit_reached` when 20 destinations already exist for this chain. `503 temporarily_unavailable` for a transient database failure (reservation kept). `500 internal_error` for a permanent write failure. `409 idempotency_key_reuse` when the same key is reused with a different body. `409 idempotency_key_in_progress` when retried inside the window after a transport failure. --- # List withdrawal wallets Source: https://partners.niftipay.com/api/withdrawal-wallets/list.html # List withdrawal wallets GET `/api/v1/partner/customers/{customerId}/withdrawal-wallets` > Required scope: `withdrawals:read`. Returns the customer's registered withdrawal destinations (the allowlist). Reading the allowlist does not require the merchant to be currently eligible to transact. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/{customerId}/withdrawal-wallets \ -H "Authorization: Bearer " ``` No query parameters. The merchant is taken from the customer relationship. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "wallets": [ { "id": "w-1", "chain": "BTC", "asset": "BTC", "label": "Treasury", "address": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "destination_tag": null, "created_at": "2026-08-24T10:00:00.000Z", "last_used_at": null } ] } } ``` Each wallet object: | Field | Type | Notes | |------------------|----------------|-------| | `id` | string | Destination id. | | `chain` | string | Chain. | | `asset` | string | Asset. | | `label` | string | Display label. | | `address` | string | Destination address. | | `destination_tag`| string or null | Optional destination tag. | | `created_at` | string | ISO-8601 timestamp. | | `last_used_at` | string or null | ISO-8601 timestamp of last use, or `null`. | An empty list (`[]`) is a valid answer — it is not a 404. ## Errors `503 temporarily_unavailable` if the list read fails transiently — retry shortly. `500 internal_error` for a permanent read failure — do not retry automatically. --- # Payout wallet rules Source: https://partners.niftipay.com/api/payout-wallets/rules.html # Payout wallet rules Every write to a customer's payout wallet book goes through the same book the merchant's own dashboard uses, with the same rules. The only difference is the second factor: where the merchant enters a 2FA code, you send an [`X-Withdrawal-Signature`](/getting-started/withdrawal-signing.html) assertion signed with **action `partner.payout_wallet`**, plus an `Idempotency-Key`. | Rule | Behaviour | |------|-----------| | Access | Scope `payout_wallets:write` **and** the `payout_wallets` capability. The capability is never granted by default — Niftipay staff turn it on per customer. | | Assets / chains | `asset`: `USDT` or `USDC`. `chain`: `ERC20` or `TRC20`. | | Cap | At most 3 active wallets. | | Duplicates | An address already in the book (active or archived) is refused with `409 conflict`. Archived holder → [`restore`](/api/payout-wallets/actions.html) it, then `set_payout`. Active holder → `set_payout` on that wallet. Never a second row for the same address. | | Rate limit | At most 3 changes per customer per 24 hours, merchant and partner changes combined → `429 rate_limited`. Every write counts, including an archive or a restore, so a wrong turn spends the budget. | | Cooling-off | A wallet added in the last 24 hours cannot become the payout wallet → `400 cooling_off`, with `details: [{ "cooling_off_until": "" }]`. | | Risk acknowledgement | Adding, replacing and setting the payout wallet need `"acknowledged": true`, confirming the merchant accepted the risk statement. Archiving and restoring do not — neither moves payouts. | | Payout wallet | The current payout wallet cannot be archived; set another wallet as payout first. | | Notification | Every change emails the merchant a security notice naming your integration, and is recorded in the merchant's wallet history. | The merchant is always the customer in the path — never anything in the body. A wallet id that belongs to another customer answers `404`. Common errors for all writes: `400 idempotency_key_required`, `401 signature_required`, `403 capability_not_enabled`, `409 idempotency_key_reuse`, `409 idempotency_key_in_progress`, `503 temporarily_unavailable`, `500 internal_error`. --- # List payout wallets Source: https://partners.niftipay.com/api/payout-wallets/list.html # List payout wallets GET `/api/v1/partner/customers/{customerId}/payout-wallets` > Required scope: `payout_wallets:read`, plus the `payout_wallets` capability. Returns the customer's fiat payout wallet book: the stablecoin wallets its fiat settlements are paid to. Exactly one active wallet has `is_payout_wallet: true` once the book is set up. Archived wallets are included so you can reconcile history. Reading the book does not require the merchant to be currently eligible to transact. ## Request ```bash curl https://www.niftipay.com/api/v1/partner/customers/{customerId}/payout-wallets \ -H "Authorization: Bearer " ``` ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "wallets": [ { "id": "7c1e…", "label": "Main USDT", "chain": "TRC20", "asset": "USDT", "address": "TXYZ…", "destination_tag": null, "status": "active", "is_payout_wallet": true, "created_at": "2026-09-01T10:00:00.000Z", "archived_at": null } ] } } ``` ## Errors `403 capability_not_enabled` when the `payout_wallets` capability is not granted. `503 temporarily_unavailable` for a transient failure. `500 internal_error` otherwise. --- # Add a payout wallet Source: https://partners.niftipay.com/api/payout-wallets/create.html # Add a payout wallet POST `/api/v1/partner/customers/{customerId}/payout-wallets` > Required scope: `payout_wallets:write`, plus the `payout_wallets` capability, > an `Idempotency-Key` and an `X-Withdrawal-Signature` with action > `partner.payout_wallet`. See [Payout wallet rules](/api/payout-wallets/rules.html). Adds a wallet to the customer's book. The first wallet in an empty book becomes the payout wallet; otherwise the payout wallet is unchanged — use [set as payout wallet](/api/payout-wallets/actions.html) after the 24-hour cooling-off. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/{customerId}/payout-wallets \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "X-Withdrawal-Signature: " \ -H "Content-Type: application/json" \ -d '{ "chain": "TRC20", "asset": "USDT", "address": "TXYZ…", "label": "Main USDT", "acknowledged": true }' ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `chain` | string | yes | `ERC20` or `TRC20`. | | `asset` | string | yes | `USDT` or `USDC`. | | `address` | string | yes | Validated for `chain`. | | `label` | string | no | Defaults to `" "`. | | `destination_tag` | string | no | | | `acknowledged` | boolean | yes | Must be `true`. | ## Response `201` `data.wallet`, in the shape shown in [List payout wallets](/api/payout-wallets/list.html). ## Errors `400 invalid_request` (unsupported asset/chain, invalid address, missing acknowledgement, 3 wallets already active), `409 conflict` (address already in the book), `429 rate_limited`, plus the [common errors](/api/payout-wallets/rules.html). --- # Rename a payout wallet Source: https://partners.niftipay.com/api/payout-wallets/update.html # Rename a payout wallet PATCH `/api/v1/partner/customers/{customerId}/payout-wallets/{walletId}` > Required scope: `payout_wallets:write`, plus the `payout_wallets` capability, an > `Idempotency-Key` and a `partner.payout_wallet` signature. Changes the label only. Address, chain and history are untouched. Counts toward the 24-hour change limit. ```bash curl -X PATCH https://www.niftipay.com/api/v1/partner/customers/{customerId}/payout-wallets/{walletId} \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "X-Withdrawal-Signature: " \ -H "Content-Type: application/json" \ -d '{ "label": "Treasury USDT" }' ``` ## Response `200` `data.wallet`. Errors: `404 not_found`, `400 invalid_request`, `429 rate_limited`, plus the [common errors](/api/payout-wallets/rules.html). --- # Archive a payout wallet Source: https://partners.niftipay.com/api/payout-wallets/delete.html # Archive a payout wallet DELETE `/api/v1/partner/customers/{customerId}/payout-wallets/{walletId}` > Required scope: `payout_wallets:write`, plus the `payout_wallets` capability, an > `Idempotency-Key` and a `partner.payout_wallet` signature (over an empty body). Archives the wallet. It stays in the book as `status: "archived"` for history, and keeps its address reserved: a later `create` or `replace` with that same address answers `409 conflict`. To use it again, bring the wallet itself back with [`restore`](/api/payout-wallets/actions.html) rather than adding a second row. The current payout wallet cannot be archived — set another wallet as payout first, or use [replace](/api/payout-wallets/actions.html). ## Response `200` `data.wallet` (now archived). Errors: `404 not_found`, `400 invalid_request` (payout wallet), `429 rate_limited`, plus the [common errors](/api/payout-wallets/rules.html). --- # Set payout wallet / replace / restore Source: https://partners.niftipay.com/api/payout-wallets/actions.html # Set payout wallet, replace or restore a wallet POST `/api/v1/partner/customers/{customerId}/payout-wallets/{walletId}` > Required scope: `payout_wallets:write`, plus the `payout_wallets` capability, an > `Idempotency-Key` and a `partner.payout_wallet` signature. ## `set_payout` — make this the payout wallet ```json { "action": "set_payout", "acknowledged": true } ``` Moves `is_payout_wallet` to this wallet. Refused with `400 cooling_off` while the wallet is inside its 24-hour cooling-off (see `details[0].cooling_off_until`). If it already is the payout wallet the response carries `"unchanged": true`. ## `replace` — swap a wallet for a new address ```json { "action": "replace", "chain": "TRC20", "asset": "USDT", "address": "TNEW…", "label": "New USDT", "acknowledged": true } ``` Archives `{walletId}` and adds the new wallet in one change. If the replaced wallet was the payout wallet, the new one takes over. The response carries the new `wallet` and `archived_wallet_id`. An address the customer has held before is still in the book, so `replace` onto it answers `409 conflict` rather than adding a second row. If the wallet holding it is archived, `restore` it and then `set_payout`; if it is active, `set_payout` on that wallet directly. ## `restore` — bring an archived wallet back ```json { "action": "restore" } ``` Returns an archived wallet to `status: "active"` with its original id, label and address. No acknowledgement, because nothing moves: `is_payout_wallet` is left alone, so a restored wallet becomes the payout destination only through a following `set_payout`. Refused with `400 invalid_request` when the wallet is not archived, or when the customer already holds the maximum number of active wallets. ## Errors `400 invalid_request` (unknown `action`, validation, missing acknowledgement, wallet not archived, active-wallet cap), `400 cooling_off`, `404 not_found`, `409 conflict` (the address is already in the book), `429 rate_limited`, plus the [common errors](/api/payout-wallets/rules.html). --- # Create a verification Source: https://partners.niftipay.com/api/verifications/create.html # Create a verification > After submission, anything still outstanding arrives as a > [requirement](/api/requirements/list.html) rather than a rejection of the whole > case. POST `/api/v1/partner/customers/{customerId}/verifications` > Required scope: `kyb:write`. Writes structured KYB data for the customer, then attempts to submit the case every time. Once the five required documents exist and the form is complete, the next POST carries the case into review — so "submit" is never a separate verb to forget. A submission blocked by missing evidence or an incomplete form is **not** an error: the response reports `submitted: false` and what is outstanding, and you re-POST once ready. Only `kyb` verifications exist. The server injects `type: "kyb"`; do **not** send a `type` field (sending `type: "kyc"` is refused). The payload is strict — unknown fields are rejected, not ignored. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/verifications \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "business": { "legal_name": "Example SL", "registration_number": "B123" } }' ``` | Field | Type | Required | Notes | |------------------------------------------|-----------------|----------|--------------------------------------------------------------| | `business` | object | no | Company details. | | `business.legal_name` | string | no | Mapped to `companyName`. | | `business.trading_name` | string | no | | | `business.website` | string | no | | | `business.industry` | string | no | Enum — see [KYB field reference](/api/verifications/fields.html). | | `business.registration_number` | string | no | | | `business.registered_address` | object | no | `{ line1, line2, city, postal_code, country }`. | | `business.incorporation_country` | string | no | | | `business.incorporation_date` | string | no | | | `business.business_registration_country` | string | no | | | `contact` | object | no | `{ first_name, last_name, email, phone }`. | | `commercial` | object | no | `{ expected_monthly_processing_volume, card_type, settlement_option, fee_handling, requested_payment_methods[] }`. | | `directors` | array (≤20) | no | Each: `{ full_name, date_of_birth, nationality, country?, email?, phone?, address_line1?, city?, postal_code? }`. | | `ubos` | array (≤20) | no | Person fields + `ownership_percent` (number 25–100). | | `ubo_declaration` | string | no | `has_ubos` (default) or `no_qualifying_ubo` — see [KYB field reference](/api/verifications/fields.html). | | `consent` | object | no | `{ version?, marketing_opt_in? }`. | Omitting a field leaves its stored value untouched; an explicit `null` clears it. ## Response `200` The status is always `200`, whether or not submission succeeded. ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "verification": { "verification_id": "app-1", "partner_customer_id": "pc-1", "type": "kyb", "status": "unverified", "submitted_at": null, "verified_at": null, "valid_until": null, "source": "niftipay", "reusable": false, "reuse_policy": "none", "document_access": "restricted", "created_at": "2026-08-01T00:00:00.000Z", "updated_at": null, "attributes": { "legal_name": "Example SL", "registration_number": "B123", "trading_name": null, "website": null, "industry": null, "registered_address_line1": null, "registered_address_line2": null, "registered_city": null, "registered_postal_code": null, "registered_country": null, "incorporation_country": null, "incorporation_date": null, "business_registration_country": null }, "directors": [], "ubos": [] }, "submitted": false, "blocked_by": "missing_documents", "missing_documents": ["proof_of_address", "director_id"], "incomplete_fields": [] } } ``` `status` is one of `unverified` | `pending` | `approved` | `rejected`. `blocked_by` is `null` when submitted, otherwise the submit code (`missing_documents` → see `missing_documents`; `validation_failed` → see `incomplete_fields`). ## Errors | Status | code | Meaning | |--------|------------------------------|----------------------------------------------------------------------| | 400 | invalid_request | Body missing or not a JSON object. | | 422 | invalid_request | Payload invalid or carries an unknown field (`details` lists issues).| | 422 | unsupported_verification_type| `type` was `kyc` (only `kyb` is supported). | | 409 | verification_in_progress | Case already under review. Raise a requirement to reopen it. | | 500 | internal_error | The verification could not be read back. | --- # KYB field reference Source: https://partners.niftipay.com/api/verifications/fields.html # KYB field reference > Building a form from this? [`GET /onboarding/schema`](/api/onboarding-schema.html) > returns the same contract as JSON, with the enums inline — render from that > rather than transcribing this page. Every enum, every required field, and the rules that are only enforced at submission. [Create a verification](/api/verifications/create.html) accepts partial data — this page is what "complete" means. ## Enums Send the exact string. Anything else is `422`. ### `business.industry` The selection **is** the risk classification, so there is no free-text option beyond `other`. `ecommerce_retail` · `cannabis` · `cbd` · `hemp` · `seeds` · `growshops` · `cannabis_clubs` · `standard_wellness_products` · `peptides` · `kratom` · `higher_risk_supplements` · `difficult_to_place_ecommerce` · `merchants_requiring_expensive_payment_routes` · `merchants_with_elevated_refund_or_chargeback_exposure` · `functional_mushrooms` · `adult` · `other` ### `commercial.expected_monthly_processing_volume` A **band, not a number**. Sending `50000` is `422`. | Value | Range (EUR / month) | |---|---| | `range_0_1k` | up to 1,000 | | `range_1k_10k` | 1,000 – 10,000 | | `range_10k_50k` | 10,000 – 50,000 | | `range_50k_250k` | 50,000 – 250,000 | | `range_250k_plus` | 250,000 and above | ### The rest | Field | Values | |---|---| | `commercial.card_type` | `eea` · `non_eea` · `mixed` | | `commercial.settlement_option` | `fiat_eur_iban` · `crypto_usdt` | | `commercial.fee_handling` | `customer_pays` · `niftipay_absorbs` | | `commercial.requested_payment_methods` | array of `card` · `bank_transfer` · `crypto` · `other` (max 10, **at least 1** at submit) | | `document_type` (on upload) | `certificate_of_incorporation` · `proof_of_address` · `director_id` · `bank_statement` · `processing_statement` | ## Required documents **All five**, and submission is blocked until every one is present: `certificate_of_incorporation` · `proof_of_address` · `director_id` · `bank_statement` · `processing_statement` Upload them with [Upload a document](/api/documents/upload.html). **There is no delete.** Uploads append, and submission takes the **latest of each type** — so replacing a rejected document means uploading a new one of the same type, and the old one stays in the record. That is deliberate: evidence that was reviewed is part of the audit trail, and a partner able to remove it could quietly rewrite what a decision was made on. Practically: never wait for a delete that is not coming, and do not treat a second upload of the same type as an error. It is the supported way to correct one. While any are missing, `POST /verifications` answers `200` with `"blocked_by": "missing_documents"` and lists exactly which in `missing_documents`. That is the expected middle of the flow, not an error. ## Directors and UBOs Both are arrays of the same person shape, max 20 each. ```json { "full_name": "Ada Lovelace", "date_of_birth": "1815-12-10", "nationality": "GB", "country": "GB", "email": "ada@example.com", "phone": "+44…", "address_line1": "…", "city": "…", "postal_code": "…" } ``` `full_name`, `date_of_birth` and `nationality` are required on every person. Everything else is optional. `date_of_birth` must parse as a date. A UBO carries one extra field: | Field | Rule | |---|---| | `ownership_percent` | number, **25 to 100**. Decimals are accepted (`33.3`). | ### The rules you asked about, stated plainly **Percentages do not have to total 100.** There is no sum check. The 25% floor is the whole rule — a UBO *is* a 25%-or-more shareholder, so anyone below that threshold is not a UBO and should not be listed. A company whose ownership is spread across five 20% holders therefore has no UBOs to report — see the next rule for how to say so. **A company with no qualifying UBO declares it.** If nobody holds 25% or more — five 20% holders, a foundation, a widely-held entity — send: ```json { "ubos": [], "ubo_declaration": "no_qualifying_ubo" } ``` `ubo_declaration` is `has_ubos` by default, and that default is what requires at least one UBO. It is a **declaration, not an inference from an empty list**: "not filled in yet" and "genuinely nobody" are different facts, and a file that cannot tell them apart is worth nothing to the reviewer. Declaring none *and* listing someone is refused as the contradiction it is. At least one **director** is required either way — with no UBO to report, the senior managing officials are what the file rests on. The value is returned on reads as `ubo_declaration`, beside `ubos`. **Overlap is allowed and expected.** `directors` and `ubos` are independent lists with no cross-check and no deduplication. A founder who is both a director and a 100% shareholder belongs in **both** arrays, with the same details. We do not infer one from the other. ## Your own fields Our schema is fixed, and fields outside it are `422`. If your underwriting needs values we do not have, send them in `additional_data`: ```json { "business": { "legal_name": "Example SL" }, "additional_data": { "entity_type": "sarl", "regulated_since": 2019, "markets": ["FR", "BE"] } } ``` We **store, return and relay** them. We never render or interpret them — you collected them in your own UI, from your own user, under your own privacy notice, and that is what keeps this simple for both of us. | Rule | | |---|---| | Scope | Yours alone. Another partner on the same merchant cannot read your keys, and you cannot read theirs. | | Values | string · number · boolean · null · or an array of those | | Nesting | **Not accepted.** Flatten the key instead — `owner_name`, not `owner: { name }`. | | Limits | 50 keys · 100 chars per key · 2000 chars per string · 50 items per array | | Writing | Merged, not replaced. Send `null` for a key to clear it; omit it to leave it alone. | | Over-long values | **Refused, never truncated** — a shortened answer means you believe your underwriter has a value it does not have. | Returned on `GET /verifications` as `additional_data`, containing your slice only. The nesting limit is the boundary that keeps this a *field bag* rather than a document store. If you need structure, flatten. ## Required at submission A verification can be created and updated with any subset. These are what must be present before the case leaves draft: | Group | Fields | |---|---| | Business | `legal_name`, `registration_number`, `industry`, `business_registration_country`, `incorporation_country`, `incorporation_date` | | Registered address | `line1`, `city`, `postal_code`, `country` | | Contact | `first_name`, `last_name`, `email` (must be a valid email) | | Commercial | `expected_monthly_processing_volume`, `card_type`, `settlement_option`, `fee_handling`, `requested_payment_methods` (≥1) | | People | `directors` (≥1); `ubos` (≥1) **unless** `ubo_declaration` is `no_qualifying_ubo` | | Consent | `consent.version` | | Evidence | all five document types | Optional throughout: `trading_name`, `website` (must be a valid URL if sent), `registered_address.line2`, `contact.phone`. `incorporation_date` must parse **and not be in the future**. `consent.marketing_opt_in` is genuinely optional and defaults to `false`. It is deliberately not a precondition of onboarding — consent that is required is not freely given, and a partner submitting on a customer's behalf cannot honestly assert it. The mandatory gate is [agreement acceptance](/api/agreements/accept.html), recorded at submit. ## How completeness is reported You never have to guess what is outstanding. Every `POST /verifications` attempts submission and tells you what stopped it: ```json { "data": { "verification": { … }, "submitted": false, "blocked_by": "validation_failed", "missing_documents": [], "incomplete_fields": [ { "path": ["ubos"], "message": "At least one UBO is required, or declare uboDeclaration = no_qualifying_ubo if no person holds 25% or more" } ] } } ``` So the integration loop is: send what you have, read `incomplete_fields` and `missing_documents`, send more. An empty POST is a valid way to re-attempt submission once evidence is uploaded. --- # List verifications Source: https://partners.niftipay.com/api/verifications/list.html # List verifications GET `/api/v1/partner/customers/{customerId}/verifications` > Required scope: `kyb:read` (global tier). Returns the customer's single KYB case plus its status-transition history. A customer that has never started a verification gets `404`, not an empty case. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/pc-1/verifications \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "verifications": [ { "verification_id": "app-1", "partner_customer_id": "pc-1", "type": "kyb", "status": "unverified", "submitted_at": null, "verified_at": null, "valid_until": null, "source": "niftipay", "reusable": false, "reuse_policy": "none", "document_access": "restricted", "created_at": "2026-08-01T00:00:00.000Z", "updated_at": null, "attributes": { "legal_name": "Example SL", "registration_number": "B123", "trading_name": null, "website": null, "industry": null, "registered_address_line1": null, "registered_address_line2": null, "registered_city": null, "registered_postal_code": null, "registered_country": null, "incorporation_country": null, "incorporation_date": null, "business_registration_country": null }, "directors": [], "ubos": [] } ], "history": [ { "status": "kyb_submitted", "occurred_at": "2026-08-10T10:00:00.000Z" }, { "status": "kyb_approved", "occurred_at": "2026-08-12T14:08:00.000Z" } ] } } ``` `verifications` always holds at most one entry (a merchant has a single KYB case). `attributes` exposes only fields the merchant supplied; `valid_until` is always `null` (coinx records no KYB expiry). ## Errors | Status | code | Meaning | |--------|---------------------|-------------------------------------------------| | 404 | resource_not_found | No verification has been started for this customer. | | 404 | not_found | Customer is not yours / relationship missing. | --- # Retrieve a verification Source: https://partners.niftipay.com/api/verifications/retrieve.html # Retrieve a verification GET `/api/v1/partner/customers/{customerId}/verifications/{verificationId}` > Required scope: `kyb:read` (global tier). Returns one KYB case and its status-transition history. A customer has exactly **one** KYB case, so this and [List verifications](/api/verifications/list.html) return the same case — the list wraps it in an array, this returns it directly. Use whichever fits how you stored it: the list if you only kept the customer id, this if you kept the `verification_id`. > Earlier revisions of this page noted that no per-id route existed and told you > to use the collection instead. It exists now. The collection has not changed. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/pc-1/verifications/app-1 \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "verification": { "verification_id": "app-1", "partner_customer_id": "pc-1", "type": "kyb", "status": "approved", "submitted_at": "2026-08-10T10:00:00.000Z", "verified_at": "2026-08-12T14:08:00.000Z", "valid_until": null, "source": "niftipay", "reusable": false, "reuse_policy": "none", "document_access": "restricted", "created_at": "2026-08-01T00:00:00.000Z", "updated_at": "2026-08-12T14:08:00.000Z", "attributes": { "legal_name": "Example SL", "registration_number": "B123", "trading_name": null, "website": null, "industry": null, "registered_address_line1": null, "registered_address_line2": null, "registered_city": null, "registered_postal_code": null, "registered_country": null, "incorporation_country": null, "incorporation_date": null, "business_registration_country": null }, "directors": [], "ubos": [] }, "history": [ { "status": "kyb_submitted", "occurred_at": "2026-08-10T10:00:00.000Z" }, { "status": "kyb_approved", "occurred_at": "2026-08-12T14:08:00.000Z" } ] } } ``` Note the shape difference from the collection: `verification` (an object) rather than `verifications` (an array). `history` is identical in both. `status` is one of `unverified` | `pending` | `approved` | `rejected`. `expired` is never returned; a past-due case shows `valid_until: null`. `history` carries only the transition status and its timestamp — reviewer notes and reason codes are withheld. ## Why a case was rejected When `status` is `rejected`, `rejection_reason` carries one of a closed list. It is `null` on every other status, and `null` on a rejection we were given no reason for — absent rather than guessed. | Reason | What the merchant should do | |---|---| | `documents_illegible` | Re-upload clearer scans. | | `documents_missing` | Something required was absent or unreadable. | | `documents_expired` | Provide current versions. | | `industry_not_accepted` | The declared industry cannot be onboarded. Not fixable by resubmitting. | | `entity_unverifiable` | The company could not be matched in the registry. Check the legal name and registration number. | | `ownership_unclear` | The director/UBO structure did not reconcile. | | `sanctions_or_pep` | A screening hit. Contact us; do not resubmit. | | `duplicate_application` | This entity already exists on Niftipay. | | `other` | A reason outside the list. Contact us with the `verification_id`. | The same value arrives on the `verification.rejected` webhook, so listening and polling agree. Reviewer notes are **not** exposed on any surface — the list is deliberately coarse, and free text is never forwarded. For a rejected **document** rather than a whole case, `document.reviewed` and [List documents](/api/documents/list.html) carry a per-document `rejection_reason` with more detail. ## Every miss is the same `404` Three different situations answer identically: - no verification has been started for this customer - a case exists and `verificationId` names a different one - `verificationId` is another merchant's real case The third is why the first two share its wording. Distinguishing it would confirm that a case id exists and belongs to somebody, which is the merchant discovery the relationship guard exists to prevent. The case is resolved from the **relationship**, and the id you supply is only ever compared against it. ## Errors | Status | code | Meaning | |--------|-------------------------|------------------------------------------------------| | 403 | insufficient_scope | Token was not minted with `kyb:read`. | | 404 | resource_not_found | No such case on this customer — see above. | | 503 | temporarily_unavailable | Transient database failure. Retry. | --- # Upload a document Source: https://partners.niftipay.com/api/documents/upload.html # Upload a document POST `/api/v1/partner/customers/{customerId}/documents` > Required scope: `documents:write`. **No** `Idempotency-Key`. Contributes one piece of KYB evidence for your customer. This is the endpoint that carries [Flow A](/guides/quickstart.html) from "the business fields are filled in" to "the case can be reviewed": a verification is only submitted once the required documents exist. ## multipart/form-data, not base64 JSON The body is `multipart/form-data`. Evidence is routinely a 15 MB scan, and base64 inflates it by a third before it reaches a body parser. ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/documents \ -H "Authorization: Bearer " \ -F "document_type=certificate_of_incorporation" \ -F "file=@/path/to/incorporation.pdf;type=application/pdf" ``` | Part | Required | Notes | |-----------------|----------|-----------------------------------------------------------| | `document_type` | yes | One of the five types below. | | `file` | yes | The bytes. 1 byte to 15 MB. | | `file_name` | no | Overrides the uploaded part's filename. Basename only — directory segments are stripped. | **`document_type`** — `certificate_of_incorporation` · `proof_of_address` · `director_id` · `bank_statement` · `processing_statement` · `other` `other` is for evidence with no name of its own — a shareholder register, a licence, a bank letter — usually in response to a [requirement](/api/requirements/list.html) that asked for one. It is accepted on upload and is **not** required at submit, so uploading one never changes what completeness means. **Content type** must be `application/pdf`, `image/jpeg` or `image/png`. Anything else is `415`. ## Uploading twice is safe Uploads **append**; they do not replace. Multiple documents of the same type are allowed and submission takes the latest of each. That is also why the endpoint needs no `Idempotency-Key` — a retried upload leaves the newest row winning, which is the same outcome as the one that did not need retrying. To replace a rejected document, upload a new one of the same type. There is no delete. ## Response `201` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "document": { "document_id": "ba189d7e-702d-4139-9a46-8c0a8e8a1c00", "document_type": "certificate_of_incorporation", "file_name": "incorporation.pdf", "mime_type": "application/pdf", "file_size_bytes": 459, "status": "uploaded", "rejection_reason": null, "submitted_by_partner_id": "client-1", "uploaded_at": "2026-08-27T09:44:52.433Z" } } } ``` ## Order of operations A verification must exist before evidence can attach to it. Uploading to a customer whose case has never been started is `404`, with a message that says so — call [Create a verification](/api/verifications/create.html) first, even with only partial business fields. Once the case is **under review** the endpoint is `409`: evidence cannot change underneath a reviewer. Either wait for the outcome and upload again if something was rejected, or — when you are holding a document nobody has asked for yet, say an identity document for a second UBO — raise a [requirement](/api/requirements/list.html) for it first. An open requirement reopens the case for exactly what it names, so the upload is then accepted without the whole application being resubmitted. ## Errors | Status | code | Meaning | |--------|-------------------------|----------------------------------------------------------------| | 400 | invalid_request | Body was not multipart/form-data. | | 403 | insufficient_scope | Token was not minted with `documents:write`. | | 404 | resource_not_found | Customer is not yours, or no verification has been started yet. | | 409 | verification_in_progress | The case is under review. Raise a requirement to reopen it for this document. | | 413 | invalid_request | Empty file, or larger than 15 MB. | | 415 | unsupported_media_type | Content type is not PDF, JPEG or PNG. | | 422 | invalid_request | Unknown `document_type`, or no `file` part. | --- # List documents Source: https://partners.niftipay.com/api/documents/list.html # List documents GET `/api/v1/partner/customers/{customerId}/documents` > Required scope: `documents:read` (global tier). Returns evidence **metadata** for the customer. Bytes require the same `documents:read` scope **and** a separate download call (see *Download a document*). The `blobUrl` is never returned here. An empty list is a valid answer — it means the customer has contributed no evidence yet. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/pc-1/documents \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "documents": [ { "document_id": "doc-1", "document_type": "proof_of_address", "file_name": "poa.pdf", "mime_type": "application/pdf", "file_size_bytes": 1024, "status": "uploaded", "rejection_reason": "Illegible scan", "submitted_by_partner_id": "client-1", "uploaded_at": "2026-08-18T09:00:00.000Z" } ] } } ``` | Field | Type | Notes | |--------------------------|--------|--------------------------------------------------| | `document_id` | string | | | `document_type` | string | Enum (see *Download a document* allowed types). | | `file_name` | string | | | `mime_type` | string | | | `file_size_bytes` | number | | | `status` | string | e.g. `uploaded` \| `rejected`. | | `rejection_reason` | string\|null | Shared so you know which file to replace. | | `submitted_by_partner_id`| string\|null | The contributing partner's client id. | | `uploaded_at` | string | ISO-8601 timestamp. | ## Errors | Status | code | Meaning | |--------|---------------------|------------------------------------------| | 403 | insufficient_scope | Token was not minted with `documents:read`. | | 404 | resource_not_found | Customer is not yours / relationship missing. | --- # Download a document Source: https://partners.niftipay.com/api/documents/download.html # Download a document GET `/api/v1/partner/documents/{documentId}/download` > Required scope: `documents:read` (separate from `kyb:read`). Returns a **short-lived URL** to the bytes rather than the bytes themselves. The URL is scoped to the calling partner: a document belonging to another partner (or an unknown id) returns the same `404`, so existence cannot be probed. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/documents/doc-1/download \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "document": { "document_id": "doc-1", "document_type": "certificate_of_incorporation", "file_name": "cert.pdf", "mime_type": "application/pdf", "file_size_bytes": 1024, "status": "uploaded", "rejection_reason": null, "submitted_by_partner_id": "client-1", "uploaded_at": "2026-08-18T09:00:00.000Z" }, "download_url": "https://www.trapyfy.com/api/platform/onboarding/documents/download?applicationId=app-1&documentId=doc-1&token=tok", "expires_at": "2026-08-19T10:15:00.000Z" } } ``` `download_url` is time-limited; fetch it promptly. The `document` object mirrors *List documents*. `blobUrl` is never returned. ## Errors | Status | code | Meaning | |--------|---------------------|----------------------------------------------------------| | 404 | resource_not_found | No partner-visible document exists for this id (covers "not yours" and "unknown" identically). | --- # List agreements Source: https://partners.niftipay.com/api/agreements/list.html # List agreements GET `/api/v1/partner/customers/{customerId}/agreements` > Required scope: `agreements:read` (global tier). Returns every catalogued agreement and where this customer stands on each. "Assigned" currently means all customers on the current version. `status` answers the only question you have: `required` (no valid acceptance), `accepted` (current version accepted), or `superseded` (an old version was accepted and must be re-collected). ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/pc-1/agreements \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "agreements": [ { "agreement_id": "tos", "title": "Terms of Service", "version": "1.0", "effective_date": "2026-01-01", "document_url": "https://.../tos.pdf", "content_hash": "a1b2c3…64hex", "parties": [ { "role": "provider", "name": "Niftipay" }, { "role": "customer", "name": "Example SL" } ], "disclosure": "This agreement is between you and Niftipay …", "status": "required", "current_acceptance": null, "previous_acceptances": [] } ] } } ``` | Field | Type | Notes | |----------------------|--------|---------------------------------------------------------| | `agreement_id` | string | | | `title` | string | | | `version` | string | Current version in force. | | `effective_date` | string | | | `document_url` | string | Where to render the document text. | | `content_hash` | string | 64-hex SHA-256 of the document body. | | `parties` | array | At least two: `{ role, name }`. | | `disclosure` | string | Always names Niftipay (no silent terms). | | `status` | string | `required` \| `accepted` \| `superseded`. | | `current_acceptance` | object\|null | The acceptance of the current version, if any. | | `previous_acceptances`| array | Acceptances of superseded versions. | The `current_acceptance` object mirrors *Accept an agreement*'s body (sans the wrapping `acceptance` key). ## Errors | Status | code | Meaning | |--------|---------------------|------------------------------------------| | 404 | not_found | Customer is not yours / relationship missing. | --- # Accept an agreement Source: https://partners.niftipay.com/api/agreements/accept.html # Accept an agreement POST `/api/v1/partner/customers/{customerId}/agreements/{agreementId}/acceptances` > Required scope: `agreements:accept`. Records that the customer accepted this agreement. The partner rendered the document in its own UI, so the record must carry the exact `agreement_version`, a `content_hash` is captured server-side, and the `acceptance_channel` — these are the only evidence that the act happened. ## Request ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/agreements/tos/acceptances \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agreement_version": "1.0", "acceptance_channel": "partner_web", "accepted_at": "2026-08-19T09:00:00.000Z", "evidence": { "external_session_id": "sess_88213" } }' ``` | Field | Type | Required | Notes | |----------------------|--------|----------|--------------------------------------------------------------| | `agreement_version` | string | yes | Must match the version presented to the customer. | | `acceptance_channel` | string | yes | One of `partner_web` \| `partner_app` \| `partner_api`. | | `accepted_at` | string | no | ISO-8601; defaults to now. Must not be in the future (60s clock-skew tolerance). | | `evidence` | object | no | Free-form object; non-object values are dropped, not stored. | ## Response `201` (new) / `200` (replay) A genuine first acceptance returns `201`; an idempotent replay returns `200` with the original acceptance so retries need no special handling. ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "acceptance": { "acceptance_id": "acc-1", "agreement_id": "tos", "agreement_version": "1.0", "content_hash": "a1b2c3…64hex", "acceptance_channel": "partner_web", "accepted_at": "2026-08-19T09:00:00.000Z", "recorded_at": "2026-08-19T09:00:01.000Z", "accepted_via_partner": true, "evidence": { "external_session_id": "sess_88213" } } } } ``` ## Errors | Status | code | Meaning | |--------|-------------------------------|-------------------------------------------------------------------------------| | 400 | invalid_request | Body missing or not a JSON object. | | 422 | invalid_request | `agreement_version` missing, or `accepted_at` malformed / in the future. | | 422 | invalid_request | `acceptance_channel` not one of the allowed values. | | 404 | resource_not_found | Agreement id is not in the catalogue. | | 404 | not_found | Customer is not yours / relationship missing. | | 409 | agreement_version_mismatch | Submitted version is superseded (`details`: `[{ current_version, document_url }]`). | --- # Requirements Source: https://partners.niftipay.com/api/requirements/list.html # Requirements GET `/api/v1/partner/customers/{customerId}/requirements` POST `/api/v1/partner/customers/{customerId}/requirements` GET `/api/v1/partner/customers/{customerId}/requirements/{requirementId}` PATCH `/api/v1/partner/customers/{customerId}/requirements/{requirementId}` > Scopes: `kyb:read` to read, `kyb:write` to raise or change. No > `Idempotency-Key` — a duplicate requirement is visible, harmless and > cancellable. A requirement is one thing still outstanding on a KYB case, **after** it was submitted. Before this existed, the only way to ask for anything post-submission was to reject the whole case: you received one coarse `rejection_reason`, so "an identity document for the second UBO" arrived as `documents_missing` and you had to guess. A requirement names its own target, carries its own status and resolves on its own — so several can be open at once and each is a row in your UI the merchant can act on. ## An open requirement reopens the case This is the point. Asking for one more document must not mean rejecting an entire application, and a merchant who cannot edit cannot supply what was asked for. While anything is `open` or `fulfilled`: - `POST /verifications` accepts edits again - `POST /documents` accepts evidence again An **approved** case does not reopen. Documents on an approved merchant can still be replaced through the normal upload path. ## The object ```json { "requirement_id": "102b4aa0-05f2-439a-88fd-aabb0adf6e7d", "kind": "document", "target": "ubo:1", "document_type": "director_id", "message": "Identity document for the second UBO", "status": "open", "raised_by": "you", "document_id": null, "due_at": null, "created_at": "2026-08-27T12:28:19.696Z", "updated_at": null, "resolved_at": null } ``` | Field | Notes | |---|---| | `kind` | `document` · `field` · `clarification` | | `target` | **Your** vocabulary — `ubo:1`, `director:0`, `field:registration_number`. Opaque to us: the party that raised it is the party that knows what it points at. | | `document_type` | Only when `kind` is `document`, and **optional**. One of the five named types, or omit it — see below. | | `message` | What the merchant is shown. Max 500 characters, rejected rather than truncated. | | `raised_by` | `you` · `partner` · `niftipay`. Never another partner's client id — which competitor also serves this merchant is not your business. | | `due_at` | Optional ISO-8601. Advisory; nothing enforces it. | ## Asking for a document we have no name for `document_type` is optional. Omit it and the requirement is simply "a document", described by your `message`: ```json { "kind": "document", "target": "company", "message": "Shareholder register, dated within 3 months" } ``` The merchant uploads it with `document_type: "other"`. `other` is accepted **on upload** and is never **required at submit** — the five named types are what submission checks for, and adding a sixth there would make it mandatory for every merchant on the platform. So an `other` document satisfies your requirement without changing what completeness means. ## Statuses, and who may set them ``` open ──▶ fulfilled ──▶ satisfied │ └──────────────────▶ cancelled ``` | Status | Meaning | |---|---| | `open` | The merchant has to do something. | | `fulfilled` | They did. The party that raised it has not accepted yet. | | `satisfied` | Accepted. Closed. | | `cancelled` | Withdrawn. Closed. | **The asymmetry is deliberate.** The side *providing* evidence may set `fulfilled` and may **not** set `satisfied`. Only the party that raised a requirement accepts or cancels it — otherwise you could close your own requirements and carry an unreviewed case to submission. A closed requirement never reopens. Raise a new one. Attempting a transition you are not entitled to is `409 invalid_state`, with `details[0]` naming the `from` and `to`. ## Raising one ```bash curl -X POST https://www.niftipay.com/api/v1/partner/customers/pc-1/requirements \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "kind": "document", "target": "ubo:1", "document_type": "director_id", "message": "Identity document for the second UBO", "due_at": "2026-09-10T00:00:00.000Z" }' ``` `message` is required — it is what the merchant reads, and a requirement nobody can read is not a requirement. Max **50** open requirements per customer. ## Listing `?status=open` returns only what is outstanding (`open` and `fulfilled`). Without it you get everything, oldest first — a merchant works down a list, and what was asked for first is usually what blocks the rest. ## Webhooks `requirement.created` and `requirement.resolved` carry the same fields as the object, including `message`. They fan out to every partner covering the merchant, **including the one that raised it** — a partner with more than one system integrated should not have to special-case its own writes. ## Errors | Status | code | Meaning | |---|---|---| | 400 | invalid_request | Bad `kind`, missing/over-long `message`, bad `document_type`, unparseable `due_at`. | | 403 | insufficient_scope | Token lacks `kyb:read` / `kyb:write`. | | 404 | resource_not_found | Not your customer, no verification started, or no such requirement. | | 409 | invalid_state | Transition not allowed for this caller, or the requirement is closed. | | 409 | limit_reached | 50 open requirements already. | | 503 | temporarily_unavailable | Transient. Retry. | --- # Chargebacks Source: https://partners.niftipay.com/api/chargebacks.html # Chargebacks Two read endpoints, one per question: every dispute against a customer, and the disputes on one order. Both are **read-only**, and the next section explains why that is not an oversight. > Required scope: `payments:read` (global tier), on both endpoints. Both also > require the `payments.fiat_card` capability on the relationship — the same > pair the fiat order reads use, because a chargeback is a fact about an order > you can already see. ## Responding to a chargeback There is no API for it, and none is coming until a processor offers one. Not one card processor we route through exposes representment — submitting evidence to contest a dispute — so an endpoint here could only accept your evidence and sit on it. We do not publish endpoints that cannot keep their promise. To contest a dispute, send the evidence to your Niftipay contact by email and we file it with the processor on the merchant's behalf. Quote the `order_key` and the chargeback `id` from these endpoints. ## What we know, and what we might not A chargeback row exists because a processor told us about the dispute. Not every card rail we route through delivers dispute notifications today, and one delivers them as a periodic file rather than live. So: - An empty list is **not** proof that no dispute exists. - `disputed_at` can lag the real dispute date on the rails that batch. - Reconcile against the merchant's processor statements for anything financial; treat these endpoints as the operational view, not the book of record. We would rather you knew that than discover it during a reconciliation. ## List a customer's chargebacks GET `/api/v1/partner/customers/{customerId}/chargebacks` Newest dispute first. Scoped to the relationship: a merchant you share with another partner never shows you that partner's disputes. ```bash curl "https://www.niftipay.com/api/v1/partner/customers/pc-1/chargebacks?limit=25" \ -H "Authorization: Bearer " ``` `?limit=` is clamped, never refused: the default is 25 and the maximum is 100, so asking for 5000 returns 100 rather than an error. There is no cursor on this rail yet — one page is all it offers. ## List one order's chargebacks GET `/api/v1/partner/fiat-orders/{orderKey}/chargebacks` The disputes on a single order, newest first, with no customer id in the path — a partner holding nothing but the `order_key` can ask. An order you cannot see answers with an empty list rather than a `404`: either way the answer is "no chargebacks", so there is no second signal worth protecting here. ```bash curl https://www.niftipay.com/api/v1/partner/fiat-orders/1001/chargebacks \ -H "Authorization: Bearer " ``` ## Response `200` Identical shape from both endpoints: ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "chargebacks": [ { "id": "cbe-1", "order_key": "1001", "psp": "niftipayp1", "order_status": "chargeback", "amount_cents": 10000, "currency": "EUR", "fee_cents": 3500, "fee_currency": "EUR", "dispute_amount_cents": 8487, "dispute_currency": "GBP", "disputed_at": "2026-09-01T08:00:00.000Z", "created_at": "2026-09-02T10:00:00.000Z" } ] } } ``` | Field | Meaning | |-----------------|--------------------------------------------------------------------------------------------------| | `id` | The dispute record's id. Quote it when you send evidence by email. | | `order_key` | The disputed order, as a string. | | `psp` | Our public name for the processing rail. It is a stable label, not the processor's own name. | | `order_status` | The **order's** status, which a dispute turns to `chargeback`. See below. | | `amount_cents` | The order amount as we booked it, frozen onto the record when the dispute landed, or `null`. | | `currency` | Currency of `amount_cents`, or `null`. | | `fee_cents` | What the processor charged for the dispute itself. | | `fee_currency` | Currency of `fee_cents` — the merchant's settlement currency, which need not be the order's. | | `dispute_amount_cents` | What the cardholder disputed, as the processor stated it. `null` on the rails that send no amount. | | `dispute_currency` | Currency of `dispute_amount_cents`. Often not the order's — see below. `null` with the amount. | | `disputed_at` | When the processor opened the dispute. Falls back to `created_at` when it does not say. | | `created_at` | When we recorded it. | ### Two amounts, and why they differ `amount_cents` is the order as we booked it. `dispute_amount_cents` is what the cardholder disputed, in the currency the processor disputed it in — and the two pairs disagree whenever the acquirer settles in a currency other than the order's. An 87.00 EUR order settled through a GBP acquirer comes back as a 73.84 GBP dispute. Do not derive one from the other. The rate is the acquirer's, applied on its own date, and it is not the settlement rate on the order either. Read the pair, or read neither: `dispute_currency` is never implied by `currency`. Both dispute fields are `null` together when the processor sent a dispute notice with no amount, and on every record we captured before we began storing them. `null` means the processor never said, not `0`. ### Why `order_status` and not `status` Because the order's status is the only status we have. A dispute here carries no lifecycle of its own — no `open`, `under_review`, `won` or `lost` — for the same reason there is no endpoint to respond: the processors do not report one. A field called `status` next to a chargeback would have read as that lifecycle, and branching on it would have been branching on a promise we never made. ## The order's own figures An order carries its own dispute total. On [Retrieve a fiat order](/api/fiat-orders/retrieve.html), `chargeback_cents` is what has been charged back and `refunded_cents` is what was refunded — and a chargeback reverses the **whole** order, so a disputed order reports its full amount in `chargeback_cents` and `0` in `refunded_cents`, even if a partial refund came first. The cents are never counted as both. ## Errors | Status | code | Meaning | |--------|-------------------------|---------------------------------------------------------------------| | 403 | insufficient_scope | Token minted without `payments:read` | | 403 | capability_not_enabled | `payments.fiat_card` not granted (customer-scoped endpoint only) | | 404 | resource_not_found | The customer id is not one of your relationships | | 500 | internal_error | Unexpected failure | | 503 | temporarily_unavailable | Transient failure. **Retry** — the only code that means that | ## The event `payment.chargeback` fires when a dispute lands. It carries the order's identifiers, not the dispute's figures — read these endpoints for those. See [Webhooks](/api/webhooks.html). --- # Onboarding schema Source: https://partners.niftipay.com/api/onboarding-schema.html # Onboarding schema GET `/api/v1/partner/onboarding/schema` > Required scope: `partner:read`. No customer in the path — the contract is > identical for every partner. Our KYB field set, as data. Build your onboarding form from this response instead of hardcoding seventeen industry strings that drift the day we add one. ## Read this first: it describes a fixed schema, it does not accept one The response says so itself: ```json "dynamic": { "custom_fields": false, "conditional_fields": false, "partner_defined_fields": false, "partner_defined_documents": false, "repeating_groups": true } ``` If you are looking for a system where **you** define the fields and Niftipay renders them, that is not this and does not exist. This is the opposite direction: we publish our fixed contract so you can render it. `repeating_groups: true` is the one honest yes — `directors[]` and `ubos[]` are repeating groups. Their **shape** is fixed; you may send more people, never more fields. Anything outside this schema is `422`, not stored. ## Shape ```json { "schema_version": "2026-08-27", "dynamic": { … }, "groups": [ { "key": "business", "fields": [ … ] }, { "key": "contact", "fields": [ … ] }, { "key": "commercial", "fields": [ … ] } ], "repeating_groups": [ { "key": "directors", "max_items": 20, "min_items_at_submit": 1, "fields": [ … ] }, { "key": "ubos", "max_items": 20, "min_items_at_submit": 1, "fields": [ … ] } ], "declarations": [ … ], "documents": { "required_at_submit": [ … ], "delete_supported": false, … }, "requirements": { "supported": true, … } } ``` A field: ```json { "key": "industry", "type": "enum", "required_at_submit": true, "options": ["ecommerce_retail", "cannabis", … ], "notes": "The selection IS the risk classification. No free text beyond `other`." } ``` | Property | Meaning | |---|---| | `type` | `string` · `enum` · `boolean` · `number` · `date` · `url` · `email` · `array` | | `required_at_submit` | Whether it must be present before the case leaves draft. Everything is optional on the way there — `POST /verifications` takes any subset. | | `options` | Present for `enum`, and for `array` whose items are enum values. | | `max_length` | For strings. | | `notes` | Rules a type cannot express. Read them. | ## Version it, do not poll it `schema_version` is a date, and it changes only when a partner could **notice** — a field added or removed, an enum value gained or lost, a rule tightened. Never for wording. Cache the response against it. Re-fetch on deploy, or when a `422` surprises you. ## What it will not tell you - **Nothing merchant-specific.** No per-customer requirements, no progress. Use [Retrieve a verification](/api/verifications/retrieve.html) and [Requirements](/api/requirements/list.html) for those. - **Nothing about layout.** No ordering hints, no field groupings beyond the three coarse ones, no labels. The `key` is a key, not a caption — you write merchant-facing wording in your own language and voice. - **Nothing conditional.** There are no "show B only if A" rules to express, because we have none. The prose version of the same contract, with the reasoning, is the [KYB field reference](/api/verifications/fields.html). The endpoint is the one to build against; the page is the one to read. --- # List capabilities Source: https://partners.niftipay.com/api/capabilities.html # List capabilities GET `/api/v1/partner/customers/{customerId}/capabilities` > Required scope: `customers:read` (global tier). Capabilities are a sub-resource > of the customer, so no separate scope is needed. Reports, per capability, two independent facts: - `enabled` — the **grant**: whether your partner integration is permitted to use this capability on this customer. See *How grants are set* below. - `available` — merchant-side **truth**, which can flip on its own (e.g. KYB expiring, a chargeback threshold landing) with nobody touching the partner's configuration. Reporting both lets you explain a disabled action in your own words ("payments unavailable — verification pending") instead of guessing from a `409`. ## Request ```bash curl -X GET https://www.niftipay.com/api/v1/partner/customers/pc-1/capabilities \ -H "Authorization: Bearer " ``` No request body. ## Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "capabilities": [ { "capability": "payments.crypto", "enabled": false, "available": true, "reason": null }, { "capability": "payments.fiat_card", "enabled": true, "available": false, "reason": "chargeback_threshold" }, { "capability": "payouts", "enabled": false, "available": true, "reason": null }, { "capability": "payout_wallets", "enabled": false, "available": true, "reason": null }, { "capability": "refunds", "enabled": false, "available": true, "reason": null }, { "capability": "wallets", "enabled": false, "available": true, "reason": null }, { "capability": "withdrawals", "enabled": false, "available": true, "reason": null } ] } } ``` | Capability | Gates | |-----------------------|--------------------------------------------------| | `payments.crypto` | Crypto order creation (`payments.crypto` scope). | | `payments.fiat_card` | Fiat card payments. | | `payouts` | Payouts. | | `payout_wallets` | Managing the customer's fiat payout wallets. | | `refunds` | [Refunding a fiat order](/api/fiat-orders/refund.html) (`refunds:write` scope). A missing grant answers `404` there, not `403`. | | `wallets` | Wallet operations. | | `withdrawals` | Withdrawals. | All seven capabilities are always returned. When a capability's availability check itself fails, the entry degrades to `{ "available": true, "reason": "availability_unknown" }` — distinguishable from a healthy resolve — so you can render the uncertainty rather than assert a state you have no basis for. The internal error is logged, never returned. ## How grants are set Two arrangements exist, and which one applies to you is a property of your partner integration rather than of any individual customer. **Auto-granted.** A customer you create starts with the same capability set a merchant signing up directly receives: | Capability | On creation | Notes | |----------------------|-------------|------------------------------------| | `payments.crypto` | granted | | | `payouts` | granted | | | `refunds` | granted | | | `wallets` | granted | | | `payments.fiat_card` | **held** | Granted when KYB is approved. | | `withdrawals` | **held** | Granted when KYB is approved. | | `payout_wallets` | **never** | Only Niftipay staff grant it, per customer. | The two held capabilities are the money-out and card rails. They are granted automatically the moment the customer's [verification](/api/verifications/create.html) is approved, and withdrawn again if it is later rejected — you do not need to ask anyone. **Manually granted.** Every capability starts at `false` and is turned on by Niftipay staff per customer. Either way, the check below is the same, and it is the one to build against: read `enabled` and `available` rather than assuming a starting state. ## `enabled` and `available` are two separate gates Turning a grant on does not make a capability usable. Calling a rail you have been granted but whose merchant is not eligible returns `409 capability_unavailable`, not a `403` — the two failures need different handling: | `enabled` | `available` | Calling the rail gives | What to do | |-----------|-------------|------------------------|-----------------------------------| | `false` | any | `403 capability_not_enabled` | Ask Niftipay, or complete KYB. | | `true` | `false` | `409 capability_unavailable` | Wait, or act on `reason`. | | `true` | `true` | The rail runs | — | ## Errors | Status | code | Meaning | |--------|---------------------|-----------------------------------------------| | 403 | insufficient_scope | Token was not minted with `customers:read`. | | 404 | resource_not_found | Customer is not yours / relationship missing. | --- # Webhooks Source: https://partners.niftipay.com/api/webhooks.html # Webhooks Niftipay pushes partner-relevant events to the webhook URL configured on the integration. Each delivery is a signed `POST`. ## Reading your subscription GET `/api/v1/partner/webhooks` > Required scope: `partner:read`. No customer in the path — this is your own > configuration, not a customer's. Answers "am I subscribed, and to what" without a support ticket. Everything it returns is a fact you supplied. ```bash curl https://www.niftipay.com/api/v1/partner/webhooks \ -H "Authorization: Bearer " ``` ### Response `200` ```json { "request_id": "req-1", "api_version": "2026-08-01", "data": { "webhooks": [ { "webhook_id": "wh_3f9a2c10", "url": "https://you.example.com/hooks/niftipay", "event_types": ["verification.approved", "payment.confirmed"], "status": "active", "created_at": "2026-08-01T10:00:00.000Z" } ], "available_event_types": ["customer.updated", "verification.pending", "..."], "signature": { "algorithm": "hmac-sha256", "header": "x-signature", "format": "v1=", "signed_payload": "." } } } ``` **An empty `event_types` means every type.** That rule is resolved server-side, so what you read back is the explicit list — you never have to reimplement it. **The signing secret is never returned.** A partner that has lost it needs a rotation, not a lookup; an endpoint that hands back secrets to anyone holding a token would undo the reason registration is staff-operated. ### Registration is staff-operated There is no `POST` here. Destinations and event subscriptions are set up by Niftipay — ask, and read the result back through this endpoint. ## Verifying a webhook Every delivery carries two headers you must check before trusting the body: | Header | Meaning | | --- | --- | | `x-signature` | `v1=` | | `x-timestamp` | Unix seconds at send time | | `x-webhook-id` | The id of the destination webhook | Recompute `hmac-sha256(secret, ".")` and compare it to the `v1=` value in `x-signature`. Reject mismatches, and reject timestamps that fall outside a small clock-skew window. Always return `2xx` quickly and process asynchronously — a slow handler is retried as if it failed. ## Event shape ```json { "event_id": "evt_3f9a...", "event_type": "payment.confirmed", "created_at": "2024-01-01T00:00:00.000Z", "partner_id": "client_abc123", "data": { "partner_customer_id": "pc_1", "order_id": "1001", "status": "paid" } } ``` `event_id` identifies the EVENT, not the attempt — see *Dedupe* below. The `data` field carries only identifiers and status — never raw documents or PII. Re-read detail through the API where scopes still apply. The events Niftipay can emit today: - `customer.updated` - `verification.pending` - `verification.approved` - `verification.rejected` - `agreement.accepted` - `document.reviewed` - `capability.updated` - `requirement.created` - `requirement.resolved` - `payment.pending` — an order was created and is awaiting payment - `payment.confirmed` — paid in full - `payment.underpaid` — funds arrived but short of the amount due (crypto; may repeat as more arrives) - `payment.expired` — nothing arrived before the order expired - `payment.cancelled` — cancelled before payment - `payment.refunded` — refunded, fully or partially (re-read the order for `refunded_cents`) - `payment.chargeback` — the buyer disputed a card payment (re-read the order for `chargeback_cents`) - `refund.completed` — a fiat refund went through ([Refund a fiat order](/api/fiat-orders/refund.html)) `refund.completed` is keyed on the refund itself, so it fires once per refund — `payment.refunded` fires alongside it as the order's status twin, and both carry the order's identifiers and status rather than the refunded amount. A partial refund therefore emits both events and leaves the order's status unchanged. For the amounts, use one of three things, in rising order of detail: [Retrieve a fiat order](/api/fiat-orders/retrieve.html) for `refunded_cents` and `chargeback_cents` from our own records; the refund call's own response; or [List an order's refunds](/api/fiat-orders/refunds-list.html) for the processor's per-refund breakdown. After `payment.chargeback`, the dispute record itself — fee included — is on [Chargebacks](/api/chargebacks.html). Every `payment.*` event is the twin of an order webhook the merchant receives, so you learn everything about an order that the merchant's own endpoint does. You receive events for orders you created and for the merchant's own orders — never for orders another partner created for the same merchant. `payment.*` payloads carry `rail`: `crypto` payloads have `order_id`, `reference`, `chain`, `asset`, `amount`, `currency`, `tx_id`, `block_number`, `received`; `fiat` payloads have `order_id`, `order_key`, `merchant_reference`, `status`, `currency`, `total_cents`. Fields a given event cannot know (e.g. `tx_id` on `payment.pending`) are `null`. ## Retries Failed deliveries are retried up to six times with exponential backoff: 1m, 5m, 25m, 2h, 10h, 24h. A `2xx` response ends delivery; a non-retryable `4xx` is dead-lettered; a `5xx` or network timeout is retried on the schedule above. Use `event_id` to dedupe across retries. ## `verification.rejected` carries a reason Its `data` includes `rejection_reason` — one of the closed list documented in [Retrieve a verification](/api/verifications/retrieve.html), or `null` when we were not given one. It is the only thing that lets you tell a merchant what to fix without a support ticket. ## Dedupe `event_id` is generated **once per event** and stored with the delivery, so every retry of that delivery carries the same value and the same body. Treating it as an idempotency key is correct. Earlier revisions of this page called it "unique per delivery", which contradicted the sentence above it. It is unique per event; the delivery attempt is not separately identified on the wire. One consequence worth designing for: if you register **more than one** destination, an event fans out to all of them carrying the **same** `event_id`. Dedupe per destination, or accept that two endpoints will see the same id. ## Clock skew `x-timestamp` is regenerated on **every attempt**, not once per event, so your tolerance only has to cover network transit and clock drift — never the retry backoff, which reaches 24 hours. We do not mandate a window; you are the verifier and it is your call. **±300 seconds is a sensible default** and is what we would pick. Anything under ±60s starts rejecting deliveries over ordinary NTP drift. > Webhooks are delivered only for the customers this partner onboarded, and only > to destinations it supplied. A partner never receives another partner's events. --- # Quickstart Source: https://partners.niftipay.com/guides/quickstart.html # Quickstart A full payment in five calls. 1. **Authenticate** — send your partner API key as a Bearer token ([Authentication](/getting-started/authentication.html)). 2. **Create a customer** — `POST /api/v1/partner/customers` ([Customers](/api/customers/create.html)). 3. **Create a fiat order** — `POST /api/v1/partner/customers/{id}/fiat-orders` with `return_url` **and** `failure_url` both required and https ([Fiat Orders](/api/fiat-orders/create.html)). 4. **Redirect** the buyer to `pay_url`; they return to your `return_url` (or `failure_url` on decline). 5. **Receive** the outcome via [webhook](/api/webhooks.html). ## Going live `payments:create` is granted only after the partner migration (`2026-08-25T00-00-00_fiat_order_partner_customer.sql`) is applied to the database — the column must exist and be written before the first grant. Partners that previously omitted `failure_url` must now send it or calls will be refused with `400 invalid_request`. --- # Card payments, end to end Source: https://partners.niftipay.com/guides/fiat-lifecycle.html # 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](/guides/crypto-lifecycle.html) 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 ```bash curl -X POST https://www.niftipay.com/api/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "client_credentials", "client_id": "", "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](/getting-started/authentication.html). --- ## 1. Create the customer ```bash 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" }' ``` ```json { "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 ```bash 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: ```bash 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. ```bash 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: ```json { "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](/api/verifications/fields.html), or [`GET /onboarding/schema`](/api/onboarding-schema.html) to build your form from data rather than from a page. ### Upload the five documents `multipart/form-data`, not base64: ```bash 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](/api/requirements/list.html) rather than a rejection: the case reopens, the merchant supplies it, and you carry on. ### What approval turns on ```json { "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](/api/capabilities.html) 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. ```bash 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" }' ``` ```json { "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 ```bash 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" }' ``` ```json { "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](/api/webhooks.html)), or **poll**: ```bash 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. ```bash 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" }' ``` ```json { "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](/api/fiat-orders/refund.html) 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](/api/capabilities.html); 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. --- # Crypto payments, end to end Source: https://partners.niftipay.com/guides/crypto-lifecycle.html # Crypto payments, end to end Every call from "we have a new merchant" to "the buyer paid", in order. The first three steps are identical to [Card payments](/guides/fiat-lifecycle.html) — same customer, same agreements, same KYB loop. This page covers them briefly and then diverges. --- ## The shape of it ``` 1 create the customer ← once per merchant 2 accept the agreements ← once per merchant 3 run the KYB loop ← once per merchant, and see below 4 (optional) provision wallets ← once per merchant 5 create an order ← per purchase 6 follow it to paid ← per purchase ``` ## Crypto does not wait for KYB The important difference. `payments.crypto` is granted **at customer creation**, alongside `wallets`, `payouts` and `refunds`. Only `payments.fiat_card` and `withdrawals` are KYB-gated. So a merchant can take crypto **immediately** — steps 1, 2 and 5, and you are live. Step 3 still matters if they will ever take cards or withdraw, and it is worth starting early, but it does not block this rail. ```json { "capability": "payments.crypto", "enabled": true, "available": true } { "capability": "payments.fiat_card", "enabled": false, "available": false, "reason": "kyb_required" } ``` That is a brand-new customer, before any verification. --- ## 0. Get a token ```bash curl -X POST https://www.niftipay.com/api/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "client_credentials", "client_id": "", "client_secret": "", "scope": "customers:write orders:create orders:read wallets:read wallets:write" }' ``` Token is at **`data.access_token`**. Scope requests are all-or-nothing. ## 1–3. Customer, agreements, KYB Identical to the card rail — see [Card payments, steps 1–3](/guides/fiat-lifecycle.html). In short: ```bash POST $BASE/customers → partner_customer_id GET $BASE/customers/pc_…/agreements → two required POST $BASE/customers/pc_…/agreements/{id}/acceptances → accept each POST $BASE/customers/pc_…/verifications → the KYB loop ``` You can start selling crypto after step 1 and 2. KYB can run in parallel. ## 4. Wallets (optional) Every customer is provisioned with deposit wallets. You rarely need to touch them — `POST /orders` mints a per-order address by itself — but you can read or create them: ```bash curl $BASE/customers/pc_…/wallets -H "Authorization: Bearer $TOKEN" ``` ```json { "data": { "wallets": [ { "chain": "BTC", "address": "bc1q…", "balance": "0", "network": "mainnet" }, { "chain": "ETH", "address": "0x…", "balance": "0", "tokens": { "USDT": "0", "USDC": "0" }, "network": "mainnet" } ] } } ``` `POST /customers/{id}/wallets` provisions any chain that is missing and returns the full set. It is safe to call repeatedly. ## 5. Create the order ```bash curl -X POST $BASE/customers/pc_…/orders \ -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "reference": "CRY-1042", "network": "ETH", "asset": "USDT", "amount": 100, "currency": "EUR", "firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com" }' ``` ```json { "data": { "order": { "id": "7d1dcdd4-…", "reference": "CRY-1042", "network": "ETH", "asset": "USDT", "amount": "116.751879", "totalToSend": "116.751879", "currency": "EUR", "fiatAmount": "100", "address": "0x49d83b0d…", "paymentUri": "ethereum:0x49d83b0d…?contract=0xdAC17F…&amount=116.751879", "qrUrl": "https://www.niftipay.com/api/qr?data=…", "status": "pending", "expiresAt": "2026-08-27T21:12:02.923Z" }, "replayed": false } } ``` ### Reading that response **You price in fiat; the buyer pays in crypto.** You send `amount: 100` with `currency: "EUR"`; we return `amount` as the **token** amount at the current rate. `fiatAmount` echoes what you asked for. **Show `totalToSend`, not `amount`.** They are equal when there are no network fees and differ when there are. `totalToSend` is what actually has to arrive. **Three ways to present it**, all equivalent — `address` for copy-paste, `paymentUri` for a wallet deep link, `qrUrl` for a scannable code. **`expiresAt` is a real deadline** — typically twelve hours. The rate is held until then. After it, create a new order. ### Duplicate `reference` behaves differently here The card rail refuses a duplicate `reference` with `409 reference_conflict`. This rail **replays** it: you get `201` with the **original** order and `"replayed": true`. That is deliberate — a crypto order holds a rate and an address, and minting a second one for the same purchase would split the payment across two addresses. Check `replayed` if you care whether you just created something. `Idempotency-Key` is still required, and still the right thing to reuse on a retry. ## 6. Follow it **Listen** for the `payment.*` events (`payment.confirmed`, `payment.expired`, `payment.refunded`, … — see [Webhooks](/api/webhooks.html)), or **poll**: ```bash curl $BASE/orders/7d1dcdd4-… -H "Authorization: Bearer $TOKEN" ``` Note the shape difference from the card rail: a crypto order is read by its **uuid** at `/orders/{orderId}`, while a fiat order is read by its short integer `order_key` at `/fiat-orders/{orderKey}`. To reconcile rather than follow one order, page the customer's orders newest first — [List crypto orders](/api/crypto-orders/list.html). It is the only partner list endpoint with a real cursor: page on `next_cursor` until it comes back `null`. `status` runs `pending` → `confirmed`, or `expired`. An underpayment is its own state rather than a silent failure. --- ## What goes wrong, and what it means | Response | Cause | Do | |---|---|---| | `403 capability_not_enabled` | `payments.crypto` off for this customer | Rare — it is granted at creation. Check [capabilities](/api/capabilities.html). | | `400 invalid_request` | Bad `network`/`asset` pair, or a missing field | The message names which. `network` is the chain, `asset` is the token. | | `400 idempotency_key_required` | No key on the POST | Always send one. | | `409 idempotency_key_reuse` | Same key, different body | Use a new key for a genuinely different order. | | `503 temporarily_unavailable` | Transient | **The only code that means retry.** | --- ## Card or crypto: how they differ | | Card | Crypto | |---|---|---| | Capability | `payments.fiat_card` — **KYB-gated** | `payments.crypto` — **granted at creation** | | Scope | `payments:create` | `orders:create` | | Needs an integration | **Yes** | No | | Endpoint | `POST /customers/{id}/fiat-orders` | `POST /customers/{id}/orders` | | Read by | `order_key` (integer) at `/fiat-orders/{key}` | `id` (uuid) at `/orders/{id}` | | Amount | `amount_cents`, minor units | `amount`, major units, fiat-priced | | Buyer pays via | `pay_url` — a hosted page | An address, URI or QR | | Duplicate `reference` | `409 reference_conflict` | Replayed, `201` with the original | | Expiry | Set by the payment link | `expiresAt`, ~12 hours, holds the rate | Both rails share the customer, the agreements, the KYB case, the webhooks and the error envelope. If you are integrating both, do steps 1–3 once. --- # Integration models Source: https://partners.niftipay.com/guides/integration-models.html # Integration models What this API can do, what it cannot, and how the pieces fit — for a partner deciding how to build against it. Written for the questions partners actually arrive with, in the order they usually arrive. --- ## The short version | Question | Answer | |---|---| | Server-to-server, both directions? | **Yes** — you call us, we webhook you. | | Can we render the onboarding ourselves? | **Yes.** The API is headless by design. | | Do you host a merchant-facing onboarding UI we can hand off to? | **No.** | | Can you reopen onboarding after submission? | **Yes** — built for it. | | Can you ask for something specific post-submission? | **Yes** — [requirements](/api/requirements/list.html). | | Can you render fields from *our* schema? | **No.** Ours is fixed. We publish it instead. | | Dynamic fields? | **We carry yours, we do not render them.** | | Repeating groups? | **Yes**, fixed shape — `directors[]`, `ubos[]`. | | Partner-defined document types? | **Unnamed ones, yes** — upload as `other`. | The rest of this page is why, and what to do about each. --- ## 1. Which direction are you building? Two integrations are possible, and they are not the same product. Decide which one you mean before writing a contract — the question has caused more confusion than any technical detail on this page. ### A. You consume our API — the default You hold the merchant relationship, you render onboarding in your own product, and you call us. This is what the API is designed for, what every endpoint in the reference assumes, and what needs no new work on our side. ``` your product ──▶ Niftipay Partner API (you call us) your product ◀── webhooks (we call you) ``` ### B. We consume your API — underwriting, or similar You underwrite, price, or decide something we need. We hand you a case and act on your answer. This direction is **partial** today. See §2. ### Both at once Also fine, and increasingly common: you consume our API for the merchant lifecycle, and we consume yours for a decision. Nothing in the design prevents it; just be explicit about which half you mean in any given sentence. --- ## 2. Server-to-server **Us → you** works today through **partner webhooks**. You register one or more HTTPS endpoints; we sign each delivery with HMAC-SHA256 over `.`, retry six times over roughly two days (1m · 5m · 25m · 2h · 10h · 24h), and keep a delivery log with replay. ``` customer.updated verification.pending requirement.created verification.approved requirement.resolved verification.rejected document.reviewed capability.updated agreement.accepted payment.* (pending, confirmed, underpaid, expired, cancelled, refunded, chargeback) ``` See [Webhooks](/api/webhooks.html), and two details partners consistently ask about: - **`event_id` is stable across retries.** It identifies the event, not the attempt — use it as an idempotency key. Register two destinations and both receive the **same** `event_id`. - **`x-timestamp` is regenerated per attempt**, so your clock-skew window only has to cover transit and drift — never the 24-hour backoff. We do not mandate a value; ±300s is what we would pick. **You → us** is the ordinary Partner API: OAuth client credentials, a short-lived JWT, scoped per endpoint. See [Authentication](/getting-started/authentication.html). ### What does not exist yet A **per-partner outbound API client** — Niftipay calling arbitrary endpoints on your side, in-line with a decision. Our one precedent posts to a single globally-configured underwriting endpoint and receives status callbacks. Generalising that per-partner is contained work, but it is work. If your side can consume webhooks and expose the two callbacks in §6, nothing new is needed. --- ## 3. Headless: you render the onboarding There is **no merchant-facing Niftipay onboarding UI to hand off to**. If you onboard merchants, you render the form. That is deliberate rather than a gap, and the API is shaped for it: - **`POST /verifications` accepts any subset.** Save per step, per field, per keystroke. Nothing has to be buffered client-side and lost on a failure. - **Every response says what is outstanding.** `incomplete_fields` carries field paths and messages; `missing_documents` names the exact types. You never guess. - **`blocked_by` separates "not finished" from "failed."** The middle of the flow is not an error. - **Capabilities self-gate.** Card payments and withdrawals turn on when KYB is approved, so there is no go-live to coordinate. The integration loop is: send what you have, read what is missing, send more. ```json { "data": { "submitted": false, "blocked_by": "missing_documents", "missing_documents": ["director_id", "bank_statement"], "incomplete_fields": [] } } ``` To build the form itself, read the field set from the API rather than from a page — see §5. --- ## 4. Asking for something after submission Underwriting rarely finishes in one pass. A requirement is one outstanding item on a submitted case, tracked and resolved on its own. ``` merchant completes onboarding POST /verifications → submitted webhook: verification.pending underwriting needs an ID for the second UBO POST /customers/{id}/requirements { "kind": "document", "target": "ubo:1", "document_type": "director_id", "message": "Identity document for the second UBO" } → the case REOPENS automatically → webhook: requirement.created merchant uploads POST /customers/{id}/documents (multipart) PATCH /customers/{id}/requirements/{requirementId} { "status": "fulfilled" } the raiser accepts PATCH … { "status": "satisfied" } ``` Full reference: [Requirements](/api/requirements/list.html). Three properties worth designing against: **An open requirement reopens a submitted case.** Asking for one more document must not mean rejecting an entire application, and a merchant who cannot edit cannot supply what was asked for. While anything is `open` or `fulfilled`, both `POST /verifications` and `POST /documents` accept writes again. An *approved* case does not reopen. **Only the raiser closes one.** The side providing evidence may set `fulfilled` and may not set `satisfied`. Enforced, not a convention — otherwise either side could close its own requirements and carry an unreviewed case forward. **`target` is yours.** `ubo:1`, `director:0`, `field:registration_number` — we store it and hand it back untouched. Whoever raised a requirement is who knows what it points at, so we do not interpret it. ### Whole-case rejection carries a reason `verification.rejected` includes `rejection_reason` from a closed list: `documents_illegible`, `documents_missing`, `documents_expired`, `industry_not_accepted`, `entity_unverifiable`, `ownership_unclear`, `sanctions_or_pep`, `duplicate_application`, `other`. Two are **not** fixable by resubmitting — `industry_not_accepted` and `sanctions_or_pep` — so do not offer a retry on them. --- ## 5. Dynamic fields: what we have, and what we do not Partners often propose that Niftipay render *their* onboarding schema: they publish a field contract, we build the form from it. **We cannot do that.** Niftipay's onboarding is a fixed schema with fixed enums and fixed steps. There is no field engine, nowhere to store answers to fields we have no columns for, and no conditional-display system. Building one is a project, not a change. What exists is the **inverse**, which often serves the same purpose: GET `/api/v1/partner/onboarding/schema` > Required scope: `partner:read`. returns *our* field set as data — every enum inline, `required_at_submit` per field, repeating groups with their shapes, the document rules. If you render Niftipay onboarding inside your product, build the form from that response and you will not drift the day we add an industry. See [Onboarding schema](/api/onboarding-schema.html). ### Point by point | Concept | Niftipay | Detail | |---|---|---| | Fields we render for you | ✗ | No form engine. This is the part that stays no. | | **Fields you carry** | **✓** | [`additional_data`](/api/verifications/fields.html) — your own keys on the case. We store, return and relay them; we never render or interpret them. | | Conditional fields | ✗ | No "show B if A" rules exist to express. | | **Repeating groups** | **✓** | `directors[]`, `ubos[]`, max 20 each. Fixed element shape — more people, never more fields. | | **Unnamed documents** | **✓** | A [requirement](/api/requirements/list.html) can ask for a document with no `document_type`; the merchant uploads it as `other`. | So the honest line is not "no dynamic fields" — it is **we will carry your fields, we will not render them**. That distinction is the whole of it: you collected the values in your own UI, from your own user, under your own privacy notice. Rendering partner-authored fields to our merchants under our logo is a GDPR and phishing question before it is an engineering one, and it is why that row stays ✗. Need a field in the *core* schema rather than your own bag? Ask — a schema change with a `schema_version` bump, not a runtime one. ### Our field set, in brief Machine-readable: [`GET /onboarding/schema`](/api/onboarding-schema.html). Prose with reasoning: [KYB field reference](/api/verifications/fields.html). **Industry** — 17 values, and the selection *is* the risk classification: `ecommerce_retail` · `cannabis` · `cbd` · `hemp` · `seeds` · `growshops` · `cannabis_clubs` · `standard_wellness_products` · `peptides` · `kratom` · `higher_risk_supplements` · `difficult_to_place_ecommerce` · `merchants_requiring_expensive_payment_routes` · `merchants_with_elevated_refund_or_chargeback_exposure` · `functional_mushrooms` · `adult` · `other` **Expected monthly volume** — a **band, not a number**. Sending `50000` is a `422`: `range_0_1k` · `range_1k_10k` · `range_10k_50k` · `range_50k_250k` · `range_250k_plus` **Other enums** — `card_type` (`eea` · `non_eea` · `mixed`) · `settlement_option` (`fiat_eur_iban` · `crypto_usdt`) · `fee_handling` (`customer_pays` · `niftipay_absorbs`) · `requested_payment_methods` (`card` · `bank_transfer` · `crypto` · `other`) **Documents** — all five required. PDF, JPEG or PNG, 15 MB max: `certificate_of_incorporation` · `proof_of_address` · `director_id` · `bank_statement` · `processing_statement`. **There is no delete** — uploads append and submission takes the latest of each type, so a second upload of the same type is the supported correction. **People** — `directors[]` and `ubos[]`, max 20 each. `full_name` + `date_of_birth` + `nationality` per person; a UBO adds `ownership_percent`, **25–100**, decimals fine. Three UBO rules that are easy to guess wrong: - Percentages **do not have to total 100**. The 25% floor is the whole rule. - `directors` and `ubos` are independent — **no cross-check, no dedup**. A founder who is both belongs in both. - A company with nobody at 25% sends `"ubos": []` with `"ubo_declaration": "no_qualifying_ubo"`. A declaration, not an inference from an empty array: "not filled in yet" and "genuinely nobody" are different facts. --- ## 6. If we need to consume your API For the underwriting direction, the smallest contract that works is the mirror of what we already do. Expose: ``` POST {you}/partner/v1/cases a submitted case POST {you}/partner/v1/cases/{id}/documents evidence, or a signed pull link ``` and consume: ``` POST /v1/partner/customers/{id}/requirements raise one PATCH /v1/partner/customers/{id}/requirements/{id} accept or cancel GET /v1/partner/customers/{id}/verifications current state ``` with your decisions arriving as `verification.*` status changes. We would rather adapt to a contract you already have than invent a parallel one. Send it over. --- ## 7. Building against the docs Everything on this site is available as Markdown and as an OpenAPI 3.1 document — see [Machine-readable docs](/getting-started/machine-readable.html). ```bash curl -s https://partners.niftipay.com/llms-full.txt # whole API, one file curl -s https://partners.niftipay.com/openapi.json # 33 operations ``` --- ## Known limits, collected Stated here so you meet them on this page rather than mid-build: - No hosted merchant onboarding UI to hand off to. - No per-partner outbound API client (webhooks only). - No form engine: we will not render partner-defined fields to merchants. - No conditional fields. - No document delete — uploads append. - No withdrawal-wallet delete — the allowlist only grows. - Cursor paging exists on [crypto orders](/api/crypto-orders/list.html) only (`?before=`, `next_cursor`). Every other list endpoint returns a single clamped page. - Reviewer notes are never exposed. Rejection reasons are a coarse closed list. If one of these blocks you, say which. Several of them are contained work and have been done for partners who asked. --- # Integration models (moved) Source: https://partners.niftipay.com/guides/temble-integration.html # This page moved It is now [Integration models](/guides/integration-models.html) — the same content, written for any partner rather than one. The old address keeps working because it was shared before the rename, and a link that was handed out should not stop resolving.