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):
transactions:readgrantsbalances:read,deposits:read,withdrawals:readandexchanges:read.transactions:writegrantswithdrawals:writeandexchanges:write— and nothing else.
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 status —
api_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 template — GET /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:
- A legacy Unbound key has no customer owner. Reissue it as a subject-bound key.
- API-key subject-binding enforcement is temporarily disabled. Registration is unavailable for that incident window; retry after enforcement is restored.
- A tenant-wide key is used in a Direct-distribution tenant. Use a subject-bound key so the endpoint is scoped to one customer.
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.