Skip to main content

Authentication

Every request to the AletaPlanet API must be authenticated. There are no anonymous requests, and there is no shared secret involved.

How it works​

You generate a private key and register its public key with a client in Cockpit.

To make an API request:

  1. Sign a short-lived JWT, called the assertion, with your private key.
  2. Send the assertion to POST /oauth/token to get an access token.
  3. Send that token as a bearer credential with every /v1 request.

Access tokens last fifteen minutes. When one expires, create a new assertion and exchange it for another token. The client itself does not change.

The client ID determines both your organization and environment. Neither is passed as a request parameter.

Sandbox tokens start with at_test_; production tokens start with at_live_. Both environments use the same base URL. The token tells the API which environment the request belongs to, so there is no separate production or sandbox host to configure.

The client ID does not identify a program. An organization can have multiple programs, and the same client can work with all of them. The program for an account is selected when the account is created, using program_id on POST /v1/accounts. See core concepts.

Why use asymmetric credentials?​

An API key is a shared secret: both sides have the same value. If that value ends up in a log, proxy, screenshot, or other system, whoever gets it can use it.

With asymmetric authentication, your private key stays on your systems. We never receive it, send it over the network, or store it. We only have the public key, which can verify signatures but cannot create them.

For a card issuer, that gives you two useful properties:

  • A copy of our credential store does not give someone a credential they can use. It contains public keys.
  • We cannot act as your client. When an operation needs to be attributed to the client or the platform, the signature provides a cryptographic record of who made it.

The assertion itself is also short-lived and single-use. Its jti is recorded and cannot be used twice, its exp is only a few minutes away, and its aud identifies the intended recipient. An assertion created for AletaPlanet therefore cannot simply be replayed somewhere else.

The assertion​

The assertion is a JWT signed with ES256 or RS256. It contains:

ClaimValue
issYour client ID, oc_…
subThe same client ID
audThe token endpoint's absolute URL: https://api.aletaplanet.com/oauth/token
iatCurrent time
expNo more than five minutes ahead. Two minutes is enough
jtiUnique for each assertion. A UUID works well

The JWT header contains alg and, when used, kid, which identifies the key that signed the assertion.

If you have more than one registered key, each key must have a kid. When the assertion includes a kid, the token endpoint checks that key. If you leave it out, all registered keys are tried.

It is still a good idea to send kid. It makes key rotation easier to follow in your logs. If the kid does not match a registered key, the response is the same generic invalid_client used for other authentication failures.

Clock skew is a common cause of failed assertions. If your signing host is more than sixty seconds out of sync, the assertion may be rejected. The response will still be invalid_client; it won't tell you that the clock was the problem.

Keep the signing machine's clock synchronized rather than relying on the error response to diagnose it.

The token​

Access tokens are opaque. They are not JWTs, and there is nothing useful to decode from them.

Each API request looks the token up, which means revocation takes effect immediately. If you revoke a client in Cockpit, its existing tokens stop working on the next request rather than continuing until their fifteen-minute lifetime expires.

Scopes are fixed when the token is issued.

If you leave scope out of the token request, the token receives all scopes held by the client. If you provide a list, the token receives only those scopes.

Changing a client's scopes in Cockpit affects the next token it receives. Revoking the client affects its existing tokens immediately.

ScopeGrants
cardholders:read / cardholders:writeRead; create, update, and report KYC outcomes
accounts:read / accounts:writeRead balances; open, credit, and debit
cards:read / cards:writeRead masked cards; issue, activate, freeze, replace, and close
cards:secretsSet and verify PINs. Every use is individually audited, and this scope is not granted by default
transactions:readAuthorizations and settled transactions
webhooks:writeManage webhook endpoints
authentications:read / authentications:writeRead 3-D Secure challenges; approve and decline them. Out-of-band only, and not issued until that release ships

GET /v1/programs is the exception. It does not require a scope; a valid access token is enough.

You need a program_id to open an account, so you need to be able to discover a program before you can use accounts:write. The endpoint is still limited to your organization and environment, and it requires a valid token like every other /v1 request.

Keys and clients in Cockpit​

Create clients under Developers → OAuth clients.

When you create a client, you provide:

  • a label
  • the scopes the client can use
  • an optional IP allowlist
  • the public half of your key pair as a JWK Set

A JWK Set looks like this:

{"keys": [{"kty": "EC", "crv": "P-256", "kid": "k1", "x": "…", "y": "…"}]}

You can register up to three keys at once.

There is no secret to copy from Cockpit and nothing that is shown only once. The only secret is the private key you generate and keep yourself.

Cockpit rejects JWK Sets containing private key material such as d, p, or q. If you accidentally paste a private key, assume that key has been exposed and replace it.

Once the client is created, Cockpit shows its client ID, such as oc_….

That ID becomes the iss and sub value in every assertion you create. It is also the only client credential you need to take from Cockpit.

For key changes, see rotating a key. To permanently disable a client, see revoking a client.

Getting a token​

At this point you should have:

  • a JWK Set registered in Cockpit
  • a client ID
  • the private key generated alongside the JWK Set

The remaining steps are to sign an assertion, exchange it for a token, and use that token.

Set the values used by the examples as environment variables:

export ALETA_CLIENT_ID="oc_…" # from Cockpit
export ALETA_BASE_URL="https://api.aletaplanet.com" # both environments

1. Sign an assertion​

Create a JWT using the claims described above and sign it with your private key.

Include the kid you assigned to the public key, such as k1. The aud value must be the absolute token endpoint URL, and it is compared exactly, so use the same URL you will send the token request to.

In Python, after pip install "PyJWT[crypto]":

import time, uuid, jwt, os

client_id = os.environ["ALETA_CLIENT_ID"]
token_url = os.environ["ALETA_BASE_URL"] + "/oauth/token"
now = int(time.time())

assertion = jwt.encode(
{"iss": client_id, "sub": client_id, "aud": token_url,
"iat": now, "exp": now + 120, "jti": str(uuid.uuid4())},
open("client-key.pem").read(),
algorithm="ES256",
headers={"kid": "k1"},
)

In Node.js, after npm install jose:

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

const clientId = process.env.ALETA_CLIENT_ID;
const tokenUrl = process.env.ALETA_BASE_URL + '/oauth/token';
const key = await importPKCS8(
readFileSync('client-key.pem', 'utf8'),
'ES256'
);

const assertion = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: 'k1' })
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(tokenUrl)
.setIssuedAt()
.setExpirationTime('2m')
.setJti(randomUUID())
.sign(key);

Two minutes is intentional. The assertion is used once and immediately, so there is little reason to give it a longer lifetime. Anything longer than five minutes is rejected.

2. Exchange the assertion​

Send the assertion to POST /oauth/token as a form-encoded request.

Do not send JSON. You also do not need an Authorization header, client secret, or environment parameter. The signature authenticates the request, and the client ID determines the environment.

curl -s -X POST "$ALETA_BASE_URL/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=$ASSERTION"

To request only specific scopes, add:

--data-urlencode "scope=cards:read cards:write"

If you leave scope out, the token receives all scopes assigned to the client.

If you request a scope the client does not have, the entire request is rejected rather than silently reducing the scope list.

In Python:

import json, urllib.parse, urllib.request

form = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
}).encode()

with urllib.request.urlopen(
urllib.request.Request(token_url, data=form)
) as r:
token = json.load(r)

And in Node.js:

const res = await fetch(tokenUrl, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: assertion,
}),
});

const token = await res.json();

A successful exchange returns 200 with Cache-Control: no-store:

{
"access_token": "at_test_…",
"token_type": "Bearer",
"expires_in": 900,
"scope": "cardholders:read cardholders:write accounts:read …"
}

The token prefix tells you which environment the client belongs to:

  • at_test_… — sandbox
  • at_live_… — production

The scope value tells you exactly what that token can do. If it contains fewer scopes than expected, check the scopes assigned to the client in Cockpit.

3. Use the token​

Send the access token as a bearer credential with every /v1 request:

export ALETA_ACCESS_TOKEN="at_test_…"

curl -s "$ALETA_BASE_URL/v1/cards?limit=1" \
-H "Authorization: Bearer $ALETA_ACCESS_TOKEN"

A 200 response, including an empty list, means the authentication chain is working: the key pair, registered JWK Set, client ID, assertion, and token are all valid.

From here, the quickstart continues with creating a cardholder and a card.

Keeping a token fresh​

Access tokens last fifteen minutes and there is no refresh token.

When a token expires, sign a new assertion and exchange it for a new token. You can do this before expires_in runs out or after receiving the first 401 invalid_access_token.

Every exchange needs a new jti. An assertion can only be redeemed once. Presenting the same assertion again returns 400 invalid_grant, even if the assertion has not expired.

For a long-running service, keep the token and its expiry in memory, request a new one when there are fewer than about sixty seconds remaining, and treat a 401 from /v1 as a signal to obtain a new token and retry the request once.

The client itself does not change between exchanges, and a client can have multiple active tokens.

When authentication fails​

Every pre-signature failure at /oauth/token returns the same 401 invalid_client. The response deliberately does not tell you which part of the authentication failed.

Check these in order:

  1. Client ID. It should be the oc_… value from Cockpit and must be used for both iss and sub. A revoked client produces the same response.
  2. Private key. client-key.pem must correspond to one of the public keys registered for the client. If you generated the key pair twice, make sure the private key on disk matches the JWK Set in Cockpit.
  3. kid. It must identify a key in the registered set. Leaving it out tries every registered key; specifying the wrong one tries none.
  4. aud. It must be exactly https://api.aletaplanet.com/oauth/token, with no trailing slash, different scheme, or port.
  5. Clock. exp is checked against our clock with one minute of tolerance. A slow clock can make the assertion appear expired; a fast clock can push exp beyond the five-minute limit.
  6. IP address. If the client or organization has an IP allowlist, the token request must come from an allowed address.

A 400 response is more specific:

  • invalid_grant — the assertion's jti was already used
  • invalid_scope — the requested scope is not assigned to the client
  • invalid_request — a required form field is missing or the JWT could not be parsed

Rotating a key​

Key rotation lets you replace the private key used by an integration without interrupting token requests.

A client can have up to three public keys. If an assertion includes kid, the token endpoint checks that key. If no kid is provided, it tries all registered keys.

The safe rotation process is:

  1. Generate a new key pair with a new kid, such as k2 if the current key is k1. Store the private key where the deployment can access it.
  2. Add the new public key in Cockpit. Update the client's JWK Set so it contains both k1 and k2.
  3. Deploy the new private key and change the assertions to use kid: k2. Both keys continue to work during the rollout.
  4. Check that traffic has moved. The client's last-used timestamp is updated on each token request, and the request log shows the kid used by each assertion. Wait until k1 has not been used for longer than your slowest deployment.
  5. Remove the old key. Update the JWK Set so only k2 remains.

Any deployment still using k1 will then fail on its next token request. Existing tokens remain valid until they expire, for up to fifteen minutes.

To test the new key, request a token using an assertion signed with k2. A successful response with token_type: Bearer confirms that the new key works.

After removing k1, an assertion signed with the old key returns 401 invalid_client.

WhereWhatCause
Saving the setRefused: private materialThe document contains d, a private key. Paste only the public half
Saving the setRefused: kid requiredMore than one key is registered and not every key has a kid
Saving the setRefused: too many keysFour or more keys were provided
Token request401 invalid_clientThe assertion's kid is not in the registered set

Revoking a client​

Revoking a client immediately prevents it from obtaining or using tokens.

Every /v1 request looks up the token's client. Once that client is revoked, the lookup fails and the token is rejected on the next request.

There is no grace period. Revocation is intended for situations where access needs to stop immediately, such as a leaked private key, a decommissioned deployment, or an ended partner relationship.

Revocation cannot be undone. If you need to replace a key while keeping the client active, use key rotation instead.

  1. If the client is still in use, create and deploy its replacement first. This lets you switch over without an outage. Skip this step when immediate access removal is the goal.
  2. Open the client in Cockpit and choose Revoke.
  3. Check the request log. Any system still using the revoked client should start failing. The log includes the request address, which can help identify an unexpected deployment.

A system using an existing token receives:

401 invalid_access_token

A new token request receives:

401 invalid_client

with the message "This client has been revoked."

WhatCause
Everything fails at once with invalid_access_tokenThe systems were all using the revoked client and no replacement was deployed
A token request still succeedsA different client was revoked. Check the oc_… client ID
Revocation appears not to take effectThe caller may be using a cached token. Look for invalid_access_token on the next API request

If you revoked the client because you suspect a compromise, see responsible disclosure for the next steps.

IP allowlisting​

There are two separate IP allowlists:

  • Organization-wide network policy, configured per environment by an Admin under Settings → Network. It applies to every client in the organization, including clients created later.
  • Per-client allowlist, configured when a client is created. It can make the organization's policy more restrictive, but cannot make it less restrictive.

Both policies apply to /v1 requests and to the token request itself.

This means that even if someone obtains a client's private key, they cannot use it from an address that is not allowed.

A /v1 request from a blocked address returns:

403 ip_not_allowed

A blocked token request returns:

401 invalid_client

because the token endpoint uses its own error format.

IP addresses are taken from the last X-Forwarded-For entry, representing the address observed by the trusted proxy. They are not taken from the first entry.

Use stable egress addresses for allowlisting. A rotating cloud NAT address is not suitable.

Mutual TLS​

Planned, not yet available

Client-certificate authentication, including binding tokens to the connection presenting the certificate, is planned but not currently implemented. The API does not currently accept or validate client certificates. If mutual TLS is required for your program, discuss it with your account contact rather than building against an unsupported flow.

What errors look like​

Authentication errors use two different formats depending on where the failure occurs.

For /v1 requests:

StatuscodeCause
401missing_credentialsNo Authorization header
401invalid_access_tokenToken is expired, unknown, or belongs to a revoked client
403environment_mismatchThe token's environment does not match the environment serving the request
403insufficient_scopeThe token is valid but does not have the required scope
403ip_not_allowedThe source address is not permitted

At /oauth/token, the API follows RFC 6749's {"error", "error_description"}:

StatuserrorCause
401invalid_clientUnknown or revoked client, invalid signature, wrong environment, or address not permitted
400invalid_grantThe assertion has already been redeemed
400invalid_scopeThe requested scope is not assigned to the client
400invalid_requestThe JWT is malformed or a required form field is missing
400unsupported_grant_typeThe grant type is not client_credentials
500server_errorServer-side error. Log it and retry once

Before an assertion's signature has been verified, all invalid_client responses use the same description. This prevents an unauthenticated caller from using the error response to discover whether a client ID exists.

If a /v1 request returns 401 invalid_access_token, obtain a new token and retry the request once. Other authentication errors require the request or configuration to be fixed. Retrying a bad credential will not resolve the problem and may eventually hit the rate limits. For the complete list, see the error code catalog.