# 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](/api/partners/native-onboarding/) can't — getting a token, and the callbacks
Kustom sends you back.

## 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.playground` | `https://api.playground.kustom.co` |
  | Production | `{partner}.moma.production` | `https://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

### 1. Sign a JWT

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

```json
{ "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](https://datatracker.ietf.org/doc/html/rfc4648#section-5)),
join them with a `.`, sign that string with SHA256withRSA (RSASSA-PKCS1-v1_5 with SHA-256) and
append the base64url-encoded signature:

```js
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

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

```http
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:

```json
{
  "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

Send the token on every request:

```http
POST /accountmanagement/v1/merchants/
Authorization: Bearer <access_token>
Content-Type: application/json
```

| Call | What it does |
|---|---|
| [`POST /accountmanagement/v1/merchants/`](/api/partners/native-onboarding/create-merchant/) | 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`](/api/partners/native-onboarding/create-widget-url/) | 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}`](/api/partners/native-onboarding/get-merchant-details/) | The merchant's status with its history, business and store details, bank accounts and settlement schedule |
| [`POST /accountmanagement/v1/merchants/{merchantId}/credentials-request`](/api/partners/native-onboarding/create-merchant-credentials/) | A new API username and password for the merchant. `202` |
| [`PATCH /accountmanagement/v1/merchants/{merchantId}/store-information/stores/{storeId}`](/api/partners/native-onboarding/update-home-page-url/) | 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

Kustom posts to your callback URL as a merchant moves through onboarding:

```json
{
  "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:

```json
{
  "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.

```js
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

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**.

```mermaid
sequenceDiagram
    participant Kustom
    participant Callback as Your callback URL
    participant Confirm as Your confirmation URL

    Kustom->>Callback: REQUEST_FOR_ACKNOWLEDGEMENT
    Callback-->>Kustom: 200, JSON with the acknowledgement hash
    Note over Kustom: checks the hash against its own
    Kustom->>Confirm: ACKNOWLEDGEMENT_RESULT
    Confirm-->>Kustom: 200
```

The request names the merchant and what changed:

```json
{"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.

```js
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:

```json
{
  "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

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.

## 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

- [Native Partner Onboarding API reference](/api/partners/native-onboarding/) — every field on the merchant, credential, widget and store calls
- [Activate Kustom Checkout on your platform](/partners/activate-kustom-checkout/) — what a merchant does once their account exists
- [Authentication and environments](/api/authentication/) — the merchant-side credentials this API issues
- [Webhooks](/basics/conventions/webhooks/) — how Kustom's other callbacks behave
