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:
- Sign a short-lived JWT, called the assertion, with your private key.
- Send the assertion to
POST /oauth/tokento get an access token. - Send that token as a bearer credential with every
/v1request.
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:
| Claim | Value |
|---|---|
iss | Your client ID, oc_… |
sub | The same client ID |
aud | The token endpoint's absolute URL: https://api.aletaplanet.com/oauth/token |
iat | Current time |
exp | No more than five minutes ahead. Two minutes is enough |
jti | Unique 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.
| Scope | Grants |
|---|---|
cardholders:read / cardholders:write | Read; create, update, and report KYC outcomes |
accounts:read / accounts:write | Read balances; open, credit, and debit |
cards:read / cards:write | Read masked cards; issue, activate, freeze, replace, and close |
cards:secrets | Set and verify PINs. Every use is individually audited, and this scope is not granted by default |
transactions:read | Authorizations and settled transactions |
webhooks:write | Manage webhook endpoints |
authentications:read / authentications:write | Read 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_…— sandboxat_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:
- Client ID. It should be the
oc_…value from Cockpit and must be used for bothissandsub. A revoked client produces the same response. - Private key.
client-key.pemmust 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. kid. It must identify a key in the registered set. Leaving it out tries every registered key; specifying the wrong one tries none.aud. It must be exactlyhttps://api.aletaplanet.com/oauth/token, with no trailing slash, different scheme, or port.- Clock.
expis checked against our clock with one minute of tolerance. A slow clock can make the assertion appear expired; a fast clock can pushexpbeyond the five-minute limit. - 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'sjtiwas already usedinvalid_scope— the requested scope is not assigned to the clientinvalid_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:
- Generate a new key pair with a new
kid, such ask2if the current key isk1. Store the private key where the deployment can access it. - Add the new public key in Cockpit. Update the client's JWK Set so it contains both
k1andk2. - Deploy the new private key and change the assertions to use
kid: k2. Both keys continue to work during the rollout. - Check that traffic has moved. The client's last-used timestamp is updated on each token request, and the request log shows the
kidused by each assertion. Wait untilk1has not been used for longer than your slowest deployment. - Remove the old key. Update the JWK Set so only
k2remains.
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.
| Where | What | Cause |
|---|---|---|
| Saving the set | Refused: private material | The document contains d, a private key. Paste only the public half |
| Saving the set | Refused: kid required | More than one key is registered and not every key has a kid |
| Saving the set | Refused: too many keys | Four or more keys were provided |
| Token request | 401 invalid_client | The 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.
- 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.
- Open the client in Cockpit and choose Revoke.
- 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."
| What | Cause |
|---|---|
Everything fails at once with invalid_access_token | The systems were all using the revoked client and no replacement was deployed |
| A token request still succeeds | A different client was revoked. Check the oc_… client ID |
| Revocation appears not to take effect | The 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
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:
| Status | code | Cause |
|---|---|---|
401 | missing_credentials | No Authorization header |
401 | invalid_access_token | Token is expired, unknown, or belongs to a revoked client |
403 | environment_mismatch | The token's environment does not match the environment serving the request |
403 | insufficient_scope | The token is valid but does not have the required scope |
403 | ip_not_allowed | The source address is not permitted |
At /oauth/token, the API follows RFC 6749's {"error", "error_description"}:
| Status | error | Cause |
|---|---|---|
401 | invalid_client | Unknown or revoked client, invalid signature, wrong environment, or address not permitted |
400 | invalid_grant | The assertion has already been redeemed |
400 | invalid_scope | The requested scope is not assigned to the client |
400 | invalid_request | The JWT is malformed or a required form field is missing |
400 | unsupported_grant_type | The grant type is not client_credentials |
500 | server_error | Server-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.