Skip to content

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.

  • A partner username per environment, issued by Kustom. Playground and production are separate, and a token for one never works against the other:

    EnvironmentUsernameAPI host
    Playground{partner}.moma.playgroundhttps://api.playground.kustom.co
    Production{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.

A token request carries a JWT signed with your private key, using RS256:

{ "alg": "RS256", "typ": "JWT" }

Send all five claims in the payload:

ClaimValue
issYour partner username for the environment, such as acme.moma.playground
scopemoma.*
audThe token endpoint, https://auth-bp.kustom.co/oauth2/v1/token
iatIssued-at, in seconds since the Unix epoch
expExpiry, 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}`;

Post the signed JWT to Kustom’s authorization server, https://auth-bp.kustom.co, as a form rather than JSON:

POST /oauth2/v1/token
Content-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:

StatusMeaning
400grant_type or assertion is missing, or grant_type isn’t the jwt-bearer value. The body says which
401The JWT doesn’t verify: the wrong private key, an exp in the past, a clock out of sync, or a malformed token
403Kustom has no public key for that iss — a typo in the username, or the key hasn’t been registered yet
409Your key is registered but your partner account isn’t fully set up on Kustom’s side. Contact Kustom
415The body was JSON instead of a form

Send the token on every request:

POST /accountmanagement/v1/merchants/
Authorization: Bearer <access_token>
Content-Type: application/json
CallWhat 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-requestA 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-requestA 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.

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.currentMeaning
PENDINGThe merchant exists and KYC isn’t complete yet
TRANSACTIONS_ENABLEDKYC is complete and the merchant can take payments
PAYOUTS_ENABLEDPayouts are enabled
SUSPENDEDThe 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));

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.

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.

In Playground:

  1. Request a token with your .moma.playground username and confirm a 200 with an access_token and expires_in.
  2. Create a merchant and confirm a 201 with a merchant_id and credentials.
  3. Confirm the acknowledgement request reached your callback URL, your answer was 200 with a JSON body, and ACKNOWLEDGEMENT_RESULT reached your confirmation URL.
  4. Generate a widget URL and complete KYC through it. Risk approval is automatic in Playground, so a STATUS_UPDATE with TRANSACTIONS_ENABLED should follow; confirm your code answered it with a 200.
  5. Call any endpoint with an expired token and confirm your client gets a new token on the 401 instead of failing.
SymptomLikely cause
403 from the token endpointKustom has no public key for that iss. Check the username, including the .moma.playground or .moma.production suffix
401 from the token endpointThe 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/v1The token is missing, expired or for the other environment
403 from /accountmanagement/v1The merchant isn’t one of yours
A status callback never arrivedIt 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 matchYou hashed the body and secret concatenated, re-serialized the body, or answered without Content-Type: application/json

This page is also available as Markdown — append .md to its URL — and is indexed in llms.txt.