Authentication & scopes

Sending your key

Every route under /api/v1/partner/... and /api/v2/public/... requires a tenant-scoped API key. Send it as X-Api-Key: <key>, or as Authorization: Bearer <key> — not both on the same request, and each header at most once.

curl https://api.kwiikpay.io/api/v1/partner/me -H "X-Api-Key: kp_live_..."

Key anatomy

A key is kp_<environment>_<16 lowercase hex>.<secret> — for example kp_live_0123456789abcdef.5m3xQ…. The part before the dot is the prefix: it is stored in clear, returned by GET /api/v1/partner/me as api_key_prefix, and safe to log or quote to support. The secret after the dot is shown once, at creation, and never again. The environment segment records the environment the key was minted in: live (Production), sandbox, test, dev, or key when minted outside a known environment. What is enforced is the environment recorded on the key, not the segment string: a key minted for one environment is refused by another with a plain 401, and a key with no environment recorded works in all of them.

Headers this surface reads, and one it refuses

Header Direction Rule
X-Api-Key or Authorization: Bearer request Exactly one of them, once
Idempotency-Key request Required on every POST/PUT/PATCH/DELETE; 8–256 visible ASCII characters — see Idempotency
X-Correlation-Id request, echoed in response Optional; at most one, ≤128 visible ASCII characters, else 400. If omitted we generate one. Present on every response — log it and quote it to support
X-Tenant-Id request Refused with 400 if present at all, even empty, and before the key is checked. The tenant is derived from the key
Retry-After response on 429 Whole seconds to wait
WWW-Authenticate: Bearer response on 401 Always set

There are no X-RateLimit-* headers.

Server-to-server only

The partner surface is not for browsers. Cross-origin requests are allowed only from origins Kwiikpay has explicitly allowlisted (the Kwiikpay dashboard); any other browser origin fails at the CORS preflight with no API-level error. Never ship an API key to a browser or a mobile app — call this API from your own backend.

Scopes

Each key carries one or more scopes. A route requires every scope listed under security in the reference (usually one); a key missing any of them gets 403 api_key_not_authorized. Scope names are accepted in either spelling: end-customers:read and EndCustomersRead are the same scope.

Route-to-scope matrix

Computed from the same rules the runtime applies; the reference's security block on each operation is the authority for that operation.

Customer API v1 — /api/v1/partner

Method Path Scope
GET /api/v1/partner/me none — any valid key
GET /api/v1/partner/test onboarding:read
GET /api/public/partner/config onboarding:read
GET /api/v1/partner/customers onboarding:read
GET /api/v1/partner/customers/{customerId} onboarding:read
PATCH /api/v1/partner/customers/{customerId} onboarding:write
POST /api/v1/partner/customers/{customerId}/end-customers end-customers:write
GET /api/v1/partner/customers/{customerId}/end-customers end-customers:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId} end-customers:read
POST /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/onboarding end-customers:write
POST /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/accounts accounts:write
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/accounts accounts:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/accounts/{accountId} accounts:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/balance end-customers:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/documents end-customers:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/documents/{documentId} end-customers:read
POST /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/consolidate end-customers:write
POST /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/internal-transfers end-customers:write
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/internal-transfers end-customers:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/internal-transfers/{transferId} end-customers:read
GET /api/v1/partner/customers/{customerId}/end-customers/{endCustomerId}/internal-transfers/by-client-reference/{clientReference} end-customers:read
GET /api/v1/partner/customers/{customerId}/onboarding/status onboarding:read
GET /api/v1/partner/customers/{customerId}/onboarding/kyc/status onboarding:read
GET /api/v1/partner/customers/{customerId}/onboarding/kyb/status onboarding:read
POST /api/v1/partner/customers/{customerId}/onboarding/kyc/initiate onboarding:write
POST /api/v1/partner/customers/{customerId}/onboarding/kyc/token onboarding:write
POST /api/v1/partner/customers/{customerId}/onboarding/kyb/initiate onboarding:write
POST /api/v1/partner/customers/{customerId}/onboarding/kyb/token onboarding:write
GET /api/v1/partner/onboarding/requirements onboarding:read
GET /api/v1/verification/callback onboarding:write
GET /api/v1/partner/transfer/allowed-destinations exchanges:read
GET /api/v1/partner/transfer/price exchanges:read
GET /api/v1/partner/customers/{customerId}/banking/conversion-requests exchanges:read
GET /api/v1/partner/customers/{customerId}/banking/conversion-requests/{conversionRequestId} exchanges:read
POST /api/v1/partner/customers/{customerId}/banking/conversion-requests exchanges:write
GET /api/v1/partner/customers/{customerId}/banking/accounts accounts:read
GET /api/v1/partner/customers/{customerId}/banking/accounts/{accountId} accounts:read
GET /api/v1/partner/customers/{customerId}/banking/accounts/form-schema accounts:read
GET /api/v1/partner/customers/{customerId}/banking/accounts/{accountId}/transactions accounts:read
POST /api/v1/partner/customers/{customerId}/banking/accounts accounts:write
GET /api/v1/partner/customers/{customerId}/banking/beneficiaries accounts:read
GET /api/v1/partner/customers/{customerId}/banking/beneficiaries/{beneficiaryId} accounts:read
POST /api/v1/partner/customers/{customerId}/banking/beneficiaries accounts:write
PATCH /api/v1/partner/customers/{customerId}/banking/beneficiaries/{beneficiaryId} accounts:write
DELETE /api/v1/partner/customers/{customerId}/banking/beneficiaries/{beneficiaryId} accounts:write
GET /api/v1/partner/customers/{customerId}/banking/balance balances:read
GET /api/v1/partner/customers/{customerId}/crypto/wallets/{asset}/balance balances:read
GET /api/v1/partner/customers/{customerId}/banking/deposits deposits:read
GET /api/v1/partner/customers/{customerId}/banking/deposits/{depositId} deposits:read
GET /api/v1/partner/customers/{customerId}/crypto/deposits deposits:read
GET /api/v1/partner/customers/{customerId}/crypto/deposits/{depositId} deposits:read
GET /api/v1/partner/customers/{customerId}/banking/withdrawals withdrawals:read
POST /api/v1/partner/customers/{customerId}/banking/withdrawals withdrawals:write
GET /api/v1/partner/customers/{customerId}/crypto/withdrawals withdrawals:read
POST /api/v1/partner/customers/{customerId}/crypto/withdrawals withdrawals:write
GET /api/v1/partner/customers/{customerId}/crypto/withdrawal-addresses withdrawal-addresses:read
POST /api/v1/partner/customers/{customerId}/crypto/withdrawal-addresses withdrawal-addresses:write
GET /api/v1/partner/customers/{customerId}/payout-batches payout-batches:read
GET /api/v1/partner/customers/{customerId}/payout-batches/{batchId} payout-batches:read
POST /api/v1/partner/customers/{customerId}/payout-batches payout-batches:write
GET /api/v1/partner/customers/{customerId}/crypto/wallets wallets:read
POST /api/v1/partner/customers/{customerId}/crypto/wallets/{asset}/address wallets:write
GET /api/v1/partner/commercial-config fees:read
GET /api/v1/partner/customers/{customerId}/fees fees:read

Customer API v2 — /api/v2/public

Method Path Scope
GET /api/v2/public/customers onboarding:read
POST /api/v2/public/access-requests onboarding:write
POST /api/v2/public/crm/leads crm-leads:write
GET /api/v2/public/fiat/accounts accounts:read
GET /api/v2/public/fiat/accounts/{accountId} accounts:read
GET /api/v2/public/webhook-spec webhooks:read
GET /api/v2/public/webhook-subscriptions webhooks:read
GET /api/v2/public/webhook-subscriptions/{subscriptionId} webhooks:read
GET /api/v2/public/webhook-subscriptions/{subscriptionId}/delivery-attempts webhooks:read
POST /api/v2/public/webhook-subscriptions webhooks:write
PATCH /api/v2/public/webhook-subscriptions/{subscriptionId} webhooks:write
DELETE /api/v2/public/webhook-subscriptions/{subscriptionId} webhooks:write
POST /api/v2/public/webhook-subscriptions/{subscriptionId}/secret-rotations webhooks:write

Under /api/v1/partner/customers/…, any subpath not listed above defaults to onboarding:read for GET and onboarding:write otherwise. That fallback exists only inside the customer tree — the top-level v1 paths (/me, /transfer/…, /commercial-config) are matched explicitly, and anything else matches no plan at all. Scopes that can be granted but that no published operation demands: compliance:read, open-banking:read, open-banking:write, end-user-fees:write — holding them buys nothing on the published surface today.

Two umbrella scopes, and what they do not cover

transactions:read and transactions:write are accepted as shorthand when a key is created, but they are narrower than they sound, and the key stores the expanded set (so /me never shows them):

In particular transactions:write does not grant payout-batches:write. A batch payout pays many beneficiaries in one operation, so it carries its own scope deliberately, and a key holding only the umbrella scope gets 403 on every payout-batch call. Ask for payout-batches:write by name.

What your key can reach: three bindings

A key is bound in one of three ways. GET /api/v1/partner/me reports which (subject_binding).

Binding By-id read List Create
subject — one customer or business Its own subject and the direct end-customers that subject onboarded (delegated_customer_ids, one level deep — a grandchild is not reachable). Anything else is 404 Silently narrowed to that same set — never refused Acts for that subject
tenant — the whole tenant Any customer on the tenant Unfiltered Must name the customer explicitly (customer_id is null on /me)
unbound — legacy, provenance unknown 404 on everything Empty list, 200 Refused

The 404 for "not mine" is byte-identical to "does not exist". This is deliberate: a subject-bound key cannot use response codes to enumerate other customers. If you compare a list to a by-id read and they disagree, the key's binding — not the data — is the first thing to check.

An unbound key is the trap: it authenticates, every call is 200, and every list is empty, which looks exactly like "you have no customers yet". If /me says unbound, ask Kwiikpay to reissue the key; it cannot be fixed from your side.

If you're issuing a key to an end customer of yours (for example, so their own systems can read their own balance), always use a subject-bound key scoped to just them.

To operate your own children with the parent's key, follow Sub-customers & parent access. It explains when customerId means the parent, when it means the child, and the separate onboarding eligibility requirement.

Authentication failures

All authentication failures are application/problem+json with one code per statusapi_key_authentication_failed (401) or api_key_not_authorized (403). The granular reason is recorded on our side and deliberately not returned:

Status detail you will see Covers
401 A specific sentence (Provide X-Api-Key or Authorization: Bearer., Use either X-Api-Key or bearer authorization, not both., Only one X-Api-Key header is allowed., Authorization header must use the Bearer scheme., or a format message) The request itself was wrong — decided before any stored key is consulted
401 API key authentication failed. Unknown key, wrong secret, revoked, expired, not yet active, rotated past its overlap window, minted for another environment, or the tenant is suspended / API access has been switched off
403 API key is not authorized for this request. Missing scope, or the request IP is outside the key's allowlist
429 Too many failed authentication attempts. Your IP exceeded the failed-authentication budget (below)

Rate limits

In production, the public-api policy allows 600 requests per fixed 60-second window for each API key, method and route templateGET /customers/{a} and GET /customers/{b} share one counter; GET and POST on the same path do not. The window is aligned to clock minutes, so up to twice the budget can land across a boundary. POST /api/v2/public/access-requests has its own, tighter policy.

A second, tenant-wide ceiling allows 6,000 requests per 60 seconds across all of your traffic: every API-key call on both surfaces and your dashboard sessions. A burst spread across different routes — or a colleague's dashboard use — can therefore hit the aggregate limit even when no individual route is over its own.

Failed API-key authentications have a separate ceiling: 20 failed authentications per 60 seconds per source IP, shared by everyone behind that address. A refusal after that budget returns 429 instead of 401/403, and waiting does not clear it — fix the credential. Successful calls do not consume this failure-only budget.

Every 429 carries Retry-After (whole seconds). See Errors for the wire contract.

IP allowlists and rotation

An IP address or CIDR allowlist is mandatory when a key is created. There is currently no API operation that updates an existing key's allowlist. If an egress address changes, create a replacement key with the correct allowlist, move the integration to it, and revoke the old key.

Rotation is for replacing secret material, not changing authorization. A rotation mints a new key (new prefix, new secret) and keeps the old one valid for an overlap window — 24 hours by default, up to 720 hours (30 days) — after which the old key answers 401. The replacement preserves the original scopes and IP allowlist. If either is wrong, create a correctly scoped and allowlisted key instead of rotating it. An expired key cannot be rotated. Key creation, rotation and revocation are dashboard operations; no /api/v1/partner or /api/v2/public route manages keys.

Webhook registration

/api/v2/public is a supported customer API surface, including POST /api/v2/public/webhook-subscriptions. Use webhooks:write to create or change a subscription and webhooks:read to list subscriptions and delivery attempts. A subject-bound key creates a subject-owned endpoint, so it receives only that customer's events (and any explicitly delegated child-subject events).

Registration deliberately returns 403 in three cases:

Versioning and change

/api/v1/partner is the supported surface for money movement and /api/v2/public is its companion for reads and webhooks; neither has an announced end of life, and there is no published deprecation policy, Deprecated/Sunset header, or changelog feed yet. What you can rely on today: the documents at /openapi/v1.json and /openapi/v2public.json describe what is served, and changes to them are additive in practice (new fields, new codes). Fields you do not recognise should be ignored; codes you do not recognise should be treated as a refusal. The api_version value on webhook envelopes (2026-05-legacy) is a constant and is not a negotiation mechanism.