- Partners
- Building a platform integration
Onboard merchants with the partner API
Create Kustom merchants from your platform — sign a JWT for an OAuth 2.0 token, call /accountmanagement/v1, handle STATUS_UPDATE and acknowledgements.
The partner onboarding API lets a platform create Kustom merchant accounts for its own merchants and follow them through review, without anyone filling in a signup form at Kustom. It is partner-only: the credentials come from Kustom as part of a partnership, and they are not the API key a merchant uses for Checkout. This page covers the parts the API reference can’t — getting a token, and the callbacks Kustom sends you back.
Before you start
Section titled “Before you start”-
A partner username per environment, issued by Kustom. Playground and production are separate, and a token for one never works against the other:
Environment Username API host Playground {partner}.moma.playgroundhttps://api.playground.kustom.coProduction {partner}.moma.productionhttps://api.kustom.co -
An RSA key pair you generate yourself. Send Kustom the public key in PEM format; the private key never leaves your system. Kustom uses the public key to check the JWT you sign when you ask for a token.
-
A callback URL and a confirmation callback URL, configured once per partner during onboarding. Use HTTPS for both.
-
A shared secret, which Kustom gives you with the callback setup. Every callback is signed with it, and you use it to answer the acknowledgement request in step 5.
Step by step
Section titled “Step by step”1. Sign a JWT
Section titled “1. Sign a JWT”A token request carries a JWT signed with your private key, using RS256:
{ "alg": "RS256", "typ": "JWT" }Send all five claims in the payload:
| Claim | Value |
|---|---|
iss | Your partner username for the environment, such as acme.moma.playground |
scope | moma.* |
aud | The token endpoint, https://auth-bp.kustom.co/oauth2/v1/token |
iat | Issued-at, in seconds since the Unix epoch |
exp | Expiry, in seconds since the Unix epoch. Keep it short — ten minutes at most |
iss is what Kustom looks your public key up by, and the signature and exp are what it
checks.
Base64url-encode the header and the payload (RFC 4648 §5),
join them with a ., sign that string with SHA256withRSA (RSASSA-PKCS1-v1_5 with SHA-256) and
append the base64url-encoded signature:
import { createSign } from 'node:crypto';import { readFileSync } from 'node:fs';
const b64url = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const now = Math.floor(Date.now() / 1000);const header = { alg: 'RS256', typ: 'JWT' };const claims = { iss: 'acme.moma.playground', scope: 'moma.*', aud: 'https://auth-bp.kustom.co/oauth2/v1/token', iat: now, exp: now + 300,};
const input = `${b64url(header)}.${b64url(claims)}`;const signature = createSign('RSA-SHA256') .update(input) .sign(readFileSync('partner-private.pem')) .toString('base64url');const assertion = `${input}.${signature}`;2. Exchange it for an access token
Section titled “2. Exchange it for an access token”Post the signed JWT to Kustom’s authorization server, https://auth-bp.kustom.co, as a form
rather than JSON:
POST /oauth2/v1/tokenContent-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=eyJhbGciOiJSUzI1NiIs...A 200 returns the token:
{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 300, "scope": "moma.*"}The token currently lasts five minutes. Cache it and reuse it until expires_in runs out,
rather than asking for a new one per call — and read the lifetime from expires_in instead of
hard-coding it.
There is one token endpoint for both environments: the username in iss decides which one the
token is for. When the request fails:
| Status | Meaning |
|---|---|
400 | grant_type or assertion is missing, or grant_type isn’t the jwt-bearer value. The body says which |
401 | The JWT doesn’t verify: the wrong private key, an exp in the past, a clock out of sync, or a malformed token |
403 | Kustom has no public key for that iss — a typo in the username, or the key hasn’t been registered yet |
409 | Your key is registered but your partner account isn’t fully set up on Kustom’s side. Contact Kustom |
415 | The body was JSON instead of a form |
3. Call the onboarding endpoints
Section titled “3. Call the onboarding endpoints”Send the token on every request:
POST /accountmanagement/v1/merchants/Authorization: Bearer <access_token>Content-Type: application/json| Call | What it does |
|---|---|
POST /accountmanagement/v1/merchants/ | Creates the merchant. 201 with merchant_id, created_at and the merchant’s first credentials (username, password) |
POST /accountmanagement/v1/merchants/{merchantId}/widget-url-request | A widget_url for the Kustom onboarding widget, where the merchant completes KYC. language is optional and defaults to en-GB |
GET /accountmanagement/v1/merchants/{merchantId} | The merchant’s status with its history, business and store details, bank accounts and settlement schedule |
POST /accountmanagement/v1/merchants/{merchantId}/credentials-request | A new API username and password for the merchant. 202 |
PATCH /accountmanagement/v1/merchants/{merchantId}/store-information/stores/{storeId} | Changes a store’s homepage URL. 204 |
Store the credentials from the create response straight away — that’s the only time they’re
returned. Sending the same create request twice is refused with 400 rather than creating a
second merchant.
A missing, invalid or expired token answers 401, and so does a token for the other
environment: get a new token and retry once. A 403 means the merchant doesn’t belong to your
partner account, so retrying won’t help.
4. Handle status callbacks
Section titled “4. Handle status callbacks”Kustom posts to your callback URL as a merchant moves through onboarding:
{ "merchant_id": "A100001", "notification_type": "STATUS_UPDATE", "status": { "current": "TRANSACTIONS_ENABLED", "reason": "APPROVED", "last_modified_at": "2026-10-12T09:14:03.218Z" }}and once more, separately, when the first order is created for the merchant:
{ "merchant_id": "A100001", "notification_type": "LIVE_TRANSACTION", "occurred_at": "2026-10-14T15:23:12.004Z"}The statuses a callback carries:
status.current | Meaning |
|---|---|
PENDING | The merchant exists and KYC isn’t complete yet |
TRANSACTIONS_ENABLED | KYC is complete and the merchant can take payments |
PAYOUTS_ENABLED | Payouts are enabled |
SUSPENDED | The merchant was rejected during onboarding, or disabled afterward |
Branch on current. reason is for your records only: APPROVED on TRANSACTIONS_ENABLED,
a short text on PENDING, and often empty. Each status is sent once per merchant. The API’s
status list is longer than this — ADDITIONAL_REVIEW_1, ADDITIONAL_REVIEW_2,
TRANSACTIONS_DISABLED, PRE_ASSESSMENT_PENDING, DORMANT, and KYC_NEEDED for some
partners — so treat a status you don’t recognize as “not live” rather than failing on it.
A callback is sent once and not retried. Kustom waits up to 9 seconds for an answer and
counts any status below 400 as delivered; anything else, or no answer, and that notification
is lost — it is never sent again. So answer as soon as you’ve stored the payload, do the real
work afterward, and don’t make the callback your only source: read the merchant with
GET /accountmanagement/v1/merchants/{merchantId} to catch up after an outage. That read has two
limits of its own — status.current is null until the merchant’s first status is recorded,
and it doesn’t report SUSPENDED, so a suspension only ever reaches you as a callback.
Every callback carries a klarna-signature header you can check it with: an HMAC-SHA512, keyed
with your shared secret, over the request body followed by the secret, in lowercase hex.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody is the request body exactly as received — not parsed and re-serialized.const expected = createHmac('sha512', SHARED_SECRET) .update(rawBody + SHARED_SECRET, 'utf8') .digest('hex');const received = req.headers['klarna-signature'] ?? '';const genuine = received.length === expected.length && timingSafeEqual(Buffer.from(received), Buffer.from(expected));5. Answer the acknowledgement request
Section titled “5. Answer the acknowledgement request”When Kustom creates a merchant’s credentials, it asks you to acknowledge the change, and it does that once per merchant, as part of creating the merchant. The request comes to your callback URL; the outcome then goes to your confirmation callback URL.
- Kustom → Your callback URLREQUEST_FOR_ACKNOWLEDGEMENTYour callback URL → Kustom200, JSON with the acknowledgement hashchecks the hash against its ownKustom → Your confirmation URLACKNOWLEDGEMENT_RESULTYour confirmation URL → Kustom200
The request names the merchant and what changed:
{"notification_type":"REQUEST_FOR_ACKNOWLEDGEMENT","changed_data":"CREDENTIALS","merchant_id":"A100001","requested_at":"2026-10-12T09:14:03.218Z"}Answer 200 with Content-Type: application/json — Kustom ignores a body of any other type —
carrying a SHA-512 in lowercase hex. What you hash is the request body with your secret added
to it as one more JSON member, "shared_secret", at the end: not the body and the secret
concatenated.
import { createHash } from 'node:crypto';
// rawBody is the request body exactly as received. Keep its key order and spacing:// re-serializing it can reorder keys, and then the hash never matches.const withSecret = rawBody.trim().replace(/}$/, `,"shared_secret":${JSON.stringify(SHARED_SECRET)}}`);const acknowledgement = createHash('sha512').update(withSecret, 'utf8').digest('hex');
res.status(200).json({ acknowledgement });For the request above, the string you hash is
{"notification_type":"REQUEST_FOR_ACKNOWLEDGEMENT","changed_data":"CREDENTIALS","merchant_id":"A100001","requested_at":"2026-10-12T09:14:03.218Z","shared_secret":"<your secret>"}.
The outcome follows at your confirmation callback URL, and needs a 200 as well:
{ "merchant_id": "A100001", "notification_type": "ACKNOWLEDGEMENT_RESULT", "result": "ACCEPTED"}result is ACCEPTED or DECLINED. CREDENTIALS is the only changed_data value today.
Check that it works
Section titled “Check that it works”In Playground:
- Request a token with your
.moma.playgroundusername and confirm a200with anaccess_tokenandexpires_in. - Create a merchant and confirm a
201with amerchant_idandcredentials. - Confirm the acknowledgement request reached your callback URL, your answer was
200with a JSON body, andACKNOWLEDGEMENT_RESULTreached your confirmation URL. - Generate a widget URL and complete KYC through it. Risk approval is automatic in
Playground, so a
STATUS_UPDATEwithTRANSACTIONS_ENABLEDshould follow; confirm your code answered it with a200. - Call any endpoint with an expired token and confirm your client gets a new token on the
401instead of failing.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
403 from the token endpoint | Kustom has no public key for that iss. Check the username, including the .moma.playground or .moma.production suffix |
401 from the token endpoint | The signature doesn’t verify or the JWT has expired — the wrong private key, a clock out of sync, or a malformed token |
401 from /accountmanagement/v1 | The token is missing, expired or for the other environment |
403 from /accountmanagement/v1 | The merchant isn’t one of yours |
| A status callback never arrived | It was sent once and your endpoint didn’t answer in time or answered 4xx/5xx. It won’t be resent, so read the merchant instead |
| Your acknowledgement hash doesn’t match | You hashed the body and secret concatenated, re-serialized the body, or answered without Content-Type: application/json |
Related
Section titled “Related”- Native Partner Onboarding API reference — every field on the merchant, credential, widget and store calls
- Activate Kustom Checkout on your platform — what a merchant does once their account exists
- Authentication and environments — the merchant-side credentials this API issues
- Webhooks — how Kustom’s other callbacks behave
This page is also available as Markdown — append .md to its URL — and is indexed in llms.txt.