# Authentication

The Partner API uses two separate controls on each request:

1. **A bearer token.** You get it from the token endpoint. To get it, you sign a short-lived JWT with a private key that only you hold. Jiko never sees your private key.
2. **A signed request.** Each call has an `x-jiko-idempotency` header with a new UUID, and an `x-jiko-signature` header. The signature is an HMAC-SHA256 over the request, made with your shared secret. The shared secret is never sent over the network. If someone intercepts your bearer token, they cannot use it without the shared secret.


All requests must use HTTPS. The API does not accept plain HTTP.

Replacing username and password login
Earlier integrations get a bearer token from `POST /api/v1/login/` with a username and password. That endpoint still works, but new integrations should use the token endpoint in this guide. See [Migrating from username and password](#migrating-from-username-and-password).

## How it works

1. You make an Ed25519 or P-256 keypair, and register the public key in the [partner portal](https://partners.jiko.io).
2. Your service signs a JWT **assertion** with the private key.
3. You send the assertion to `POST /token/`. You get a bearer token, valid for one hour.
4. You call the API with the bearer token and a signed request.
5. Before the bearer token expires, you sign a new assertion and exchange it for a new token.


This is the OAuth 2.0 JWT bearer grant ([RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523)), also known as `private_key_jwt`. Most OAuth client libraries support it.

## Before you start

Sign in to the [partner portal](https://partners.jiko.io) and open **Developers → API keys & sandbox**. Select an environment. Each environment has its own values:

| Value | Where it is used |
|  --- | --- |
| **API username** | The `iss` and `sub` claims of your assertion |
| **Token endpoint** | Where you post the assertion, and the `aud` claim of your assertion |
| **Base URL** | The prefix for all other API calls |
| **Shared secret** | The HMAC key for the `x-jiko-signature` header |
| **Registered public keys** | The keys that can sign your assertions |


Sandbox and production are separate. A key, token or shared secret from one environment does not work in the other. Production credentials unlock when your partnership is ready to go live.

In the examples below, set these values as environment variables:

```bash
export JIKO_USERNAME="<API username>"
export JIKO_TOKEN_URL="<Token endpoint>"
export JIKO_BASE_URL="<Base URL>"
export JIKO_SHARED_SECRET="<Shared secret>"
export JIKO_KID="<kid of your registered key>"
export JIKO_PRIVATE_KEY_PATH="./private.pem"
```

## Step 1: Make a keypair

Make the keypair on your own systems. Use Ed25519 (`EdDSA`) or NIST P-256 (`ES256`). The API does not accept RSA keys.

Ed25519 (recommended)
```bash
openssl genpkey -algorithm ed25519 -out private.pem
openssl pkey -in private.pem -pubout
```

P-256
```bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem
openssl pkey -in private.pem -pubout
```

The first command writes the private key to `private.pem`. The second command prints the public key.

Keep the private key private
Do not send `private.pem` to Jiko or to anyone else. Keep it in a secrets manager, KMS or HSM. Anyone with the private key can get bearer tokens as you.

## Step 2: Register the public key

1. In the partner portal, open **API keys & sandbox** and select the environment.
2. Click **Register public key**.
3. Optional: add a label, for example `payments-service-2026`.
4. Paste the public key. Include the `-----BEGIN PUBLIC KEY-----` and `-----END PUBLIC KEY-----` lines.


The key then shows in **Registered public keys** with its `kid`. The `kid` is the [RFC 7638](https://datatracker.ietf.org/doc/html/rfc7638) thumbprint of the public key. It is not a secret. To find which row matches a key file, use **Find a key**. It calculates the `kid` in your browser and does not send the key to Jiko.

Each key row also has an **example assertion**, with the header and claims filled in for that key.

## Step 3: Sign an assertion

An assertion is a JWT signed with your private key.

**Header**

| Field | Value |
|  --- | --- |
| `alg` | `EdDSA` for an Ed25519 key, or `ES256` for a P-256 key. It must be the algorithm of the registered key. |
| `kid` | The `kid` of the registered key |


**Claims**

| Claim | Value |
|  --- | --- |
| `iss` | Your API username |
| `sub` | Your API username (the same as `iss`) |
| `aud` | The token endpoint of the environment, exactly as shown in the portal |
| `iat` | The time you sign, in Unix seconds |
| `exp` | The expiry, in Unix seconds. Not more than 5 minutes after `iat`. We recommend 60 seconds. |
| `jti` | A new unique ID, for example a UUID. You can exchange each assertion only one time. |


Make a new assertion each time you need a token. Do not keep assertions or use them again.

## Step 4: Exchange the assertion for a bearer token

Send the assertion to the token endpoint as `application/x-www-form-urlencoded`:

| Parameter | Value |
|  --- | --- |
| `grant_type` | `urn:ietf:params:oauth:grant-type:jwt-bearer` |
| `assertion` | The signed JWT |


```bash
curl -X POST "$JIKO_TOKEN_URL" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
  --data-urlencode "assertion=$ASSERTION"
```

The token endpoint does not need request signing.

A successful response is `200`:

```json
{
  "access_token": "LBHyVQggqNZvbYriaL8omZSFnQfS4yMFEO8hpVmgrtn=",
  "token_type": "Bearer",
  "expires_in": 3600
}
```

`expires_in` is the number of seconds the token is valid. Keep the token and use it for all calls until it is almost expired. Then get a new one. Do not get a new token for each request. The token endpoint is rate limited by IP address.

## Step 5: Call the API with signed requests

Each call to the API needs these headers:

| Header | Value |
|  --- | --- |
| `Authorization` | `Bearer <access_token>` |
| `x-jiko-idempotency` | A new UUID for each request. The API refuses a value that it has already received as a duplicate. This means that if you are not sure a request arrived, you can send it again with the same value and it cannot be processed twice. |
| `x-jiko-signature` | `base64(HMAC-SHA256(shared_secret, idempotency + path + body))` |


To make the signature:

1. Join these values, in this order, with no separator:
  - the `x-jiko-idempotency` value
  - the URL path, with no host and no query string (for example `/api/v1/customers/`)
  - the request body, as the exact bytes you send. For a request with no body, such as a `GET`, use an empty value.
2. Calculate the HMAC-SHA256 of the result. Use the shared secret as the key.
3. Encode the HMAC in Base64.


If the signature is missing or incorrect, the API returns `403`.

## Full examples

Python
Needs `pip install "pyjwt[crypto]" requests`.

```python
import base64
import hashlib
import hmac
import json
import os
import time
import uuid

import jwt
import requests

USERNAME = os.environ["JIKO_USERNAME"]
TOKEN_URL = os.environ["JIKO_TOKEN_URL"]
BASE_URL = os.environ["JIKO_BASE_URL"]
SHARED_SECRET = os.environ["JIKO_SHARED_SECRET"]
KID = os.environ["JIKO_KID"]
with open(os.environ["JIKO_PRIVATE_KEY_PATH"]) as f:
    PRIVATE_KEY = f.read()


def get_token() -> tuple[str, float]:
    now = int(time.time())
    assertion = jwt.encode(
        {
            "iss": USERNAME,
            "sub": USERNAME,
            "aud": TOKEN_URL,
            "iat": now,
            "exp": now + 60,
            "jti": str(uuid.uuid4()),
        },
        PRIVATE_KEY,
        algorithm="EdDSA",  # "ES256" for a P-256 key
        headers={"kid": KID},
    )
    response = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
            "assertion": assertion,
        },
    )
    response.raise_for_status()
    body = response.json()
    return body["access_token"], time.time() + body["expires_in"]


def signed_request(method: str, path: str, token: str, payload: dict | None = None):
    idempotency = str(uuid.uuid4())
    body = json.dumps(payload).encode() if payload is not None else b""
    message = idempotency.encode() + path.encode() + body
    signature = base64.b64encode(
        hmac.new(SHARED_SECRET.encode(), message, hashlib.sha256).digest()
    ).decode()

    return requests.request(
        method,
        BASE_URL + path,
        data=body,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "x-jiko-idempotency": idempotency,
            "x-jiko-signature": signature,
        },
    )


token, expires_at = get_token()
response = signed_request("GET", "/api/v1/customers/", token)
print(response.status_code, response.json())
```

Node.js
Needs Node.js 18 or later and `npm install jose`.

```javascript
import { createHmac, randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { importPKCS8, SignJWT } from 'jose';

const {
  JIKO_USERNAME,
  JIKO_TOKEN_URL,
  JIKO_BASE_URL,
  JIKO_SHARED_SECRET,
  JIKO_KID,
  JIKO_PRIVATE_KEY_PATH,
} = process.env;

const ALG = 'EdDSA'; // 'ES256' for a P-256 key
const privateKey = await importPKCS8(
  readFileSync(JIKO_PRIVATE_KEY_PATH, 'utf8'),
  ALG,
);

async function getToken() {
  const assertion = await new SignJWT({})
    .setProtectedHeader({ alg: ALG, kid: JIKO_KID })
    .setIssuer(JIKO_USERNAME)
    .setSubject(JIKO_USERNAME)
    .setAudience(JIKO_TOKEN_URL)
    .setIssuedAt()
    .setExpirationTime('60s')
    .setJti(randomUUID())
    .sign(privateKey);

  const response = await fetch(JIKO_TOKEN_URL, {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion,
    }),
  });
  if (!response.ok) throw new Error(JSON.stringify(await response.json()));
  const { access_token, expires_in } = await response.json();
  return { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
}

async function signedRequest(method, path, token, payload) {
  const idempotency = randomUUID();
  const body = payload === undefined ? '' : JSON.stringify(payload);
  const signature = createHmac('sha256', JIKO_SHARED_SECRET)
    .update(idempotency + path + body)
    .digest('base64');

  return fetch(JIKO_BASE_URL + path, {
    method,
    body: body || undefined,
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      'x-jiko-idempotency': idempotency,
      'x-jiko-signature': signature,
    },
  });
}

const { token } = await getToken();
const response = await signedRequest('GET', '/api/v1/customers/', token);
console.log(response.status, await response.json());
```

To try requests without writing code, open **Try requests** from the partner portal. It signs you in to the API reference with your sandbox credentials.

## Errors from the token endpoint

The token endpoint uses the OAuth 2.0 error format ([RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)), not the usual Partner API error format. All errors return `400`:

```json
{
  "error": "invalid_grant",
  "error_description": "expired"
}
```

| `error` | Cause |
|  --- | --- |
| `invalid_request` | `grant_type` or `assertion` is missing, the assertion is too long, or the body is not form-encoded |
| `unsupported_grant_type` | `grant_type` is not `urn:ietf:params:oauth:grant-type:jwt-bearer` |
| `invalid_grant` | The assertion was rejected. See `error_description`. |


For `invalid_grant`, `error_description` tells you why:

| `error_description` | Possible cause |
|  --- | --- |
| `malformed` | The assertion is not a JWT, the header has no `kid`, or a required claim is missing |
| `bad_alg` | The header `alg` is not `EdDSA` or `ES256` |
| `expired` | `exp` is in the past. Make sure the clock on the signing machine is correct. |
| `lifetime_too_long` | `exp` is more than 5 minutes ahead |
| `aud_mismatch` | `aud` is not the token endpoint of this environment. Sandbox and production have different token endpoints. |
| `invalid_assertion` | The `kid` is unknown or revoked, the signature is from a different private key, `alg` is not the algorithm of the key, `iss` or `sub` is not your API username, or the `jti` was already used |


For security, `invalid_assertion` does not tell you which of these causes applies.

## Rotating and revoking keys

You can register more than one key in each environment. To rotate a key with no downtime:

1. Make a new keypair and register the new public key.
2. Change your service to sign with the new private key and its `kid`.
3. When no service uses the old key, revoke it: open the **Key actions** menu on the key row and click **Revoke**.


When you revoke a key, the token endpoint immediately refuses assertions signed with it. Revoked keys stay in the **Revoked keys** list for your records.

If a private key is compromised, revoke it immediately.

## Security recommendations

- Keep the private key and the shared secret in a secrets manager. Do not put them in source code, logs or client-side applications.
- Call the API only from your servers. Do not call it from browsers or mobile apps.
- Use a different key for each service or each deployment, so that you can revoke one without effect on the others.
- Add the IP addresses of your production servers to the **IP allowlist** in the partner portal.
- Keep the clock on your signing machines correct, for example with NTP.


## Migrating from username and password

If your integration uses `POST /api/v1/login/`:

1. Make a keypair and register the public key ([Step 1](#step-1-make-a-keypair) and [Step 2](#step-2-register-the-public-key)).
2. Change the code that calls `/api/v1/login/` to sign an assertion and call the token endpoint ([Step 3](#step-3-sign-an-assertion) and [Step 4](#step-4-exchange-the-assertion-for-a-bearer-token)). The response field is `access_token` with a relative `expires_in` in seconds, not `token` with an absolute `expires` time.
3. Do not change your request signing. The bearer token is the same kind of token, and the shared secret, headers and signature are the same.
4. When all your services use the token endpoint, remove the password from your systems.


Please reach out to your Jiko contact if you require access to the Partner Portal.