# AletaPlanet Developer Documentation > Card issuing and money movement API. Guides, API reference, and webhooks for developer.aletaplanet.com. This file contains all documentation content in a single document following the llmstxt.org standard. ## AI-assisted integration If you use a coding assistant while working with this API, you can give it the same documentation we use instead of relying on generated or rendered pages. ## Available files | File | Contains | Approximate size | | --- | --- | --- | | [`/llms.txt`](pathname:///llms.txt) | Documentation index, following the llmstxt.org convention | 15 KB | | [`/llms-full.txt`](pathname:///llms-full.txt) | Full prose documentation | 355 KB | | [`/llms-api.txt`](pathname:///llms-api.txt) | Compact reference for every endpoint, generated from the API specification | 50 KB | | [`/openapi.yaml`](pathname:///openapi.yaml) | Full OpenAPI specification | 100 KB | The sizes are approximate and may change as the documentation is updated. Each documentation page is also available as raw Markdown. Just add `.md` to the URL: ```text /docs/get-started/quickstart.md ``` You can also send `Accept: text/markdown` with a request to receive the Markdown source instead of the rendered HTML. ## Which file should I use? For most tasks, start with `/llms.txt`. It provides an index of the documentation and links to the pages relevant to a particular topic. For endpoint-specific questions, use `/llms-api.txt`. It's generated from the API specification, so the endpoint details stay consistent with the reference documentation. It's useful for questions such as "How do I create a card?" or "What does this error mean?" For broader questions about how the API works, use `/llms-full.txt`. It covers things like the object model, authorization lifecycle, and balance model. Since it's much larger, it's better suited to questions that involve several parts of the API rather than a single request. > **Note:** `llms-full.txt` doesn't include the generated endpoint pages. Those pages are MDX built from React components, so including them would add a lot of markup without much useful context. The OpenAPI specification and `llms-api.txt` contain the endpoint information in a more useful format. ## Using the files with a coding assistant Most coding assistants can read a URL directly. For example: ```text Read https:///llms-api.txt and write a Node.js function that issues a virtual card with an idempotency key and retries correctly on 5xx. ``` If you're going to use the documentation repeatedly, it's usually better to download the files into your repository. That way the assistant can work from the local copy without fetching the documentation every time, and the version used by your project stays reproducible. ## A couple of things to watch for There are two mistakes we see fairly often in generated code. The first is idempotency keys. Generate the key before the first request, not inside the retry loop. The same key must be reused for every retry. Generating a new key for each attempt defeats the purpose of idempotency. See [idempotency](/api-reference/conventions/idempotency) for details. The other is handling money as a floating-point number. Amounts in the API are represented as integer minor units. Don't divide them by 100 and store the result as a float just to make them easier to work with. Floating-point arithmetic can introduce rounding errors that eventually show up in production. See [timestamps and money](/api-reference/conventions/timestamps-and-money). Both mistakes are easy to miss because the resulting code will usually compile and may even pass a basic smoke test. ## Client libraries There are no server-side client libraries, and we don't currently plan to provide them. The API uses standard HTTP with a bearer token, so you can use whatever HTTP client fits your stack, including `curl`, `httpx`, `requests`, OkHttp, `axios`, or Go's `net/http`. If you'd prefer a typed client, you can generate one from the [OpenAPI specification](/api-reference/conventions/openapi-spec). You can also give `/llms-api.txt` or the specification to your coding assistant and have it generate a client for your language. Generated clients only cover the server-side API. They don't handle device-side operations such as displaying a full card number or working with a PIN. Those flows require platform-specific cryptography on the cardholder's device, and the guides include examples for iOS, Android, and the browser. See [security](/docs/security/overview) for more information. --- ## 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. ```mermaid sequenceDiagram participant You participant Token as /oauth/token participant API as /v1 You->>You: sign assertion (private key) You->>Token: client_assertion Token->>Token: verify against your registered public key Token-->>You: access_token (15 min) You->>API: Authorization: Bearer at_… ``` 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](/docs/products/card-issuing/core-concepts#program). ## 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: ```json {"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](#rotating-a-key). To permanently disable a client, see [revoking a client](#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: ```bash 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]"`: ```python 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`: ```javascript 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. ```bash 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: ```bash --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: ```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: ```javascript 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`: ```json { "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: ```bash 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](./quickstart#step-2--create-a-cardholder) 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`. | 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](#rotating-a-key) 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: ```text 401 invalid_access_token ``` A new token request receives: ```text 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](/docs/security/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: ```text 403 ip_not_allowed ``` A blocked token request returns: ```text 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 :::note 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: | 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](/api-reference/conventions/rate-limits). For the complete list, see the [error code catalog](/api-reference/errors/error-code-catalog). --- ## Environments There are two environments: sandbox and production. Both use the same API version and object model. The difference is that sandbox does not use real money, cards, or authorizations. | | Sandbox | Production | | --- | --- | --- | | Base URL | `https://api.aletaplanet.com` — one host; the token selects the environment | Same | | Cockpit | One app, with an environment switcher | Same, switched | | Money | Synthetic | Real | | Cards | Test BIN, never authorize at a real merchant | Live on the network | | Identity verification | You report attestations as in production; a test-name shortcut seeds fixtures | You report attestations from real checks | | Authorizations | Triggered by you, via the simulator | Triggered by card use | | Physical fulfillment | Tracking is planned | Tracking is planned | | Access | Self-serve after email verification | KYB, signed contract, program setup | | [Rate limits](/api-reference/conventions/rate-limits) | Lower | Per agreement, from 1,000 req/min | The base URL is HTTPS-only. See [base URL and versioning](/api-reference/conventions/base-urls-and-versioning) for versioning and deprecation rules. ## Getting sandbox access Sandbox access is self-serve. Verify your email in Cockpit, then register an OAuth client. You do not need a contract, program setup, or a sales call first. The sandbox program is provisioned for you with a test BIN, one supported currency, and both card form factors enabled. ## Getting production access Production access is gated for regulatory reasons. You will need to complete business verification (KYB), sign a contract, and go through program setup, including the BIN range, card products, currencies, fee schedule, and limits. Work through the [go-live checklist](./go-live-checklist) before requesting production access. It is the same checklist we use during the setup process. ## Keeping the two apart The client determines the environment. There is no environment parameter or header, and a sandbox token cannot access production data, or the other way around. See [authentication](./authentication) for how clients and tokens work. Tokens are prefixed so you can tell which environment they belong to: ```text at_test_… sandbox at_live_… production ``` Two rules are important: - Never resolve a sandbox client and a production client from the same configuration value without an explicit environment switch. A common integration problem is deploying production traffic against sandbox and having it succeed without anyone noticing. - Object IDs are **not** portable between environments. `card_01HX8ZQ2K4M7P9RSTVWXYZAB` in sandbox has no relationship to the same string in production. This also applies to your `prog_` IDs: the same product has a different ID in each environment, so configuration needs a value for each environment rather than one shared constant. `GET /v1/programs` returns the IDs for the environment associated with the token used for the request. --- ## Go-live checklist Before we enable production access, we check the items below. Going through them yourself first usually makes the approval process faster. ## Commercial and regulatory - [ ] Business verification (KYB) complete - [ ] Contract signed - [ ] Program configured: BIN range, card products, currencies, fee schedule - [ ] Limits agreed: per-card, per-cardholder, per-program - [ ] Named operational contact and escalation path on file - [ ] Where you perform identity verification yourself, your [KYC policy](/docs/products/card-issuing/cardholders-and-kyc/kyc-lifecycle-and-statuses) reviewed ## Credentials and access - [ ] Production OAuth client registered, and its private key **never** committed to source control - [ ] Private key read from a secrets manager or environment, not from configuration files in the repository - [ ] Separate clients per deployment target, each labeled - [ ] [Key rotation](./authentication#rotating-a-key) procedure written down and tested at least once - [ ] [IP allowlisting](./authentication#ip-allowlisting) configured for the environment (self-serve in Cockpit), both the organization-wide policy and, where needed, a per-client allowlist - [ ] Access controls for Cockpit users agreed, including MFA and least-privilege roles - [ ] No shared logins or shared credentials between deployment targets ## Integration correctness - [ ] [`Idempotency-Key`](/api-reference/conventions/idempotency) sent on **every** POST, not just the ones that seem risky - [ ] Retry logic uses exponential backoff and respects [`Retry-After`](/api-reference/conventions/rate-limits) - [ ] [`401` and `403`](./authentication) are **not** retried - [ ] Money handled as [integer minor units](/api-reference/conventions/timestamps-and-money) end to end, with no floats anywhere in the path, including database columns and JSON parsing - [ ] [`available_balance`](/docs/products/card-issuing/accounts-and-funding/balances-ledger-vs-available) used for anything a user sees; `ledger_balance` used for reconciliation - [ ] [Unknown enum values](/api-reference/conventions/base-urls-and-versioning) tolerated rather than causing errors. We may add enum members in minor releases - [ ] [`Aleta-Request-Id`](/api-reference/conventions/request-and-response-format) captured and logged on every response, both success and failure ## Webhooks - [ ] Production endpoint reachable over HTTPS with a valid certificate - [ ] [Signature verified](/docs/webhooks/signature-verification) on every delivery, using a constant-time comparison - [ ] Timestamp checked to reject [replays](/docs/webhooks/signature-verification) outside the tolerance window - [ ] Handler responds [`2xx` **before** doing slow work](/docs/webhooks/overview-and-delivery-guarantees): acknowledge, then process asynchronously - [ ] [Duplicate deliveries](/docs/webhooks/overview-and-delivery-guarantees) handled idempotently, keyed on event `id` - [ ] [Out-of-order deliveries](/docs/webhooks/overview-and-delivery-guarantees) handled. Ordering is not guaranteed - [ ] [Events you do not recognize](/docs/webhooks/event-catalog) ignored without erroring - [ ] Someone is alerted when delivery failures accumulate ## Operational readiness - [ ] Someone owns the integration and is reachable outside office hours - [ ] [Support process](/docs/support/contacting-support) defined, and everyone who raises tickets knows that `request_id` is mandatory - [ ] Reconciliation runs on a schedule and someone reviews the breaks - [ ] Dashboards cover [transaction](/docs/products/card-issuing/transactions) volume, webhook delivery success, and API error rate - [ ] Card lifecycle actions ([freeze, replace, close](/docs/products/card-issuing/cards/freeze-replace-close)) reachable by your support staff without an engineer - [ ] Runbook exists for: a cardholder reporting a lost card, a disputed transaction, and a suspected key compromise ## Security and compliance - [ ] [PCI DSS scope](/docs/security/pci-dss-scope) understood and documented on your side - [ ] Full card numbers never logged, never stored, never sent to analytics - [ ] PAN masked by default in every internal tool - [ ] [Card reveal](/docs/products/card-issuing/cards/displaying-pan-and-cvv-securely), if you offer it, behind step-up authentication and audited - [ ] Cardholder PII encrypted at rest in your systems - [ ] Data retention and deletion policy agreed ## Final verification in sandbox Before requesting production, confirm the full flow in [sandbox](./sandbox-and-test-data): - [ ] Cardholder created and verification completed through the webhook path, not the synchronous response - [ ] Account funded, card issued, card activated - [ ] A card payment [settled](/docs/products/card-issuing/transactions) and posted from `amount`, which can differ from what the terminal displayed - [ ] Card frozen, unfrozen, replaced, and closed - [ ] Dispute opened and evidence submitted - [ ] Every webhook event type your integration subscribes to received and processed ## Requesting access When the list is complete, request production access from your relationship manager. We review it and either provide the credentials or let you know what is still outstanding. --- ## Introduction AletaPlanet gives you the infrastructure to issue payment cards and manage the money behind them without having to run a bank yourself. ## What the platform does You build the product and bring the customers. We handle the parts behind the scenes: the card program, accounts that hold customer funds, cards issued against those accounts, and the real-time authorization flow that decides whether a payment is approved. The API gives you: - **Cardholders** — the people your cards belong to. - **Accounts** — where customer funds are held. - **Cards** — virtual cards that can be used within seconds, or physical cards that are personalized and shipped. - **Spend controls** — rules and limits that are checked before an authorization is approved. - **Authorizations and transactions** — the payment lifecycle, from the merchant's initial request through clearing and settlement. - **Webhooks** — state changes sent to you asynchronously, with signatures and replay support. Read [core concepts](/docs/products/card-issuing/core-concepts) before you start building. It explains how these objects fit together, and the rest of the documentation builds on that model. ## Who it is for These docs are written for engineers integrating AletaPlanet into their products. You should be comfortable working with HTTP and JSON, but you don't need prior experience with card issuing. When a payments concept comes up — such as clearing, settlement, holds, or force posts — we'll explain it as part of the relevant API flow. Most teams use the platform in one of these ways: | You are building | What that looks like here | | --- | --- | | A consumer wallet or neobank | One cardholder per user, with one account and one or more cards | | A B2B spend platform | One cardholder per employee, with cards that can be issued, frozen, and controlled as needed | | A marketplace or payout platform | Accounts funded for each payout cycle, with cards used to spend from those balances | ## What makes this API different Banking APIs can sometimes expose a lot of the underlying banking infrastructure: teller IDs, branch codes, split date fields, or numeric response codes that don't match the HTTP status. You don't need to deal with that here. Instead: - **Identifiers are opaque and typed.** The prefix tells you what kind of object you're working with, without exposing our internal systems. - **Errors use one consistent shape.** You can build your error handling once and use it across endpoints. - **Money is always an object.** Each amount includes its currency and scale, so your code never has to guess what a number means. Financial amounts are never represented as floating-point values. - **Timestamps use one format.** You don't have to deal with different timestamp formats or local timezone conversions between endpoints. - **Booleans and enums are readable.** You won't find `"Y"` / `"N"` flags or numeric codes that require a separate lookup table. The exact formats are covered in [API conventions](/api-reference/conventions/base-urls-and-versioning). ## What the platform does not do There are a few important boundaries to understand when designing your integration. - **Authorization decisions are handled by us.** Every approve or decline goes through our decisioning path, which checks the card state, spend controls, and available balance. Passing authorization decisions to your own service is planned, but isn't available today. See [real-time decisioning](/docs/products/card-issuing/real-time-decisioning/) for the current model. - **Identity verification is usually handled by you.** For most programs, you verify the cardholder and send us an attestation of the result. Identity documents don't pass through this API. The exact ownership of the verification decision is agreed during program setup. See [cardholders and KYC](/docs/products/card-issuing/cardholders-and-kyc/). - **Programs are configured during onboarding.** BIN ranges, card products, and fee schedules aren't self-service. We configure these with you during onboarding, and the API provides access to the resulting configuration. ## Aleta Planet Aleta Planet is a Singapore-headquartered payments company, a Visa Principal Member, and a BIN sponsor. This allows us to support card programs directly rather than requiring another intermediary for the issuing relationship. It also means your program is configured with you during onboarding instead of being limited to a fixed, self-service setup. --- ## Quickstart In this guide, you'll create a client, get an access token, create and verify a cardholder, open an account, issue a virtual card, fund it, and simulate a purchase. There are seven steps, and you should be able to get through them in about ten minutes. ## Before you start You'll need: - A sandbox organization. Verify your email in Cockpit, then see [environments](./environments). - `curl` and [`jq`](https://jqlang.github.io/jq/). - Either Python 3 with `PyJWT[crypto]` and `cryptography`, or Node 18+ with `jose`. Two steps below require signing a JWT, so we recommend using one of these instead of trying to do it in bash. ### Generate a key pair Run one of the following once. The script prints the **public** key as a JWK Set and saves the **private** key to `client-key.pem`. The private key stays on your machine and should never be shared with us. ```python import json, jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec key = ec.generate_private_key(ec.SECP256R1()) jwk = jwt.algorithms.ECAlgorithm.to_jwk(key.public_key(), as_dict=True) jwk.update({"kid": "k1", "alg": "ES256", "use": "sig"}) print(json.dumps({"keys": [jwk]})) with open("client-key.pem", "wb") as f: f.write(key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), )) ``` ```javascript import { generateKeyPair, exportJWK, exportPKCS8 } from 'jose'; import { writeFileSync } from 'node:fs'; const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true }); const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'ES256', use: 'sig' }; console.log(JSON.stringify({ keys: [jwk] })); writeFileSync('client-key.pem', await exportPKCS8(privateKey)); ``` ### Register a client In Cockpit, open **Developers → OAuth clients → Create client**. Give the client a label, enable the scopes you need, and paste in the JWK Set printed by the script. Cockpit will show the new client ID (`oc_…`). Copy it for the next steps. ### Find your program ID You also need a `prog_…` ID. This identifies the program the account belongs to, and anything created from that account inherits the program. In Cockpit, open **Settings → Programs** and copy the **Program ID**. Make sure you're viewing the sandbox environment. Program IDs are different between sandbox and production, so a production ID won't work with a sandbox token. If your organization has more than one program, use the one configured for this integration. You can also get the same list from the API with `GET /v1/programs`. It requires an access token, so you'll do that in step 1. Set the client ID, program ID, and API base URL as environment variables: ```bash export ALETA_CLIENT_ID="oc_…" export ALETA_PROGRAM_ID="prog_…" export ALETA_BASE_URL="https://api.aletaplanet.com" ``` The commands below capture each new ID into a variable. Run them in order in the same terminal session so you don't have to copy IDs between steps. ## Step 1 · Get an access token AletaPlanet uses a signed client assertion to authenticate the OAuth client. The assertion is exchanged for an access token, which is valid for fifteen minutes. ```python import json, time, uuid, jwt, urllib.parse, urllib.request, 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"}, ) 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: print(json.load(r)["access_token"]) ``` ```javascript 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); 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, }), }); console.log((await res.json()).access_token); ``` Export the access token: ```bash export ALETA_ACCESS_TOKEN="at_test_…" ``` Sandbox tokens start with `at_test_`. Production tokens start with `at_live_`. The API base URL is the same for both environments; the token prefix tells you which environment you're using. If you didn't get your program ID from Cockpit, you can get it now: ```bash curl -s "$ALETA_BASE_URL/v1/programs" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" | jq ``` ```json title="Response" { "object": "list", "url": "/v1/programs", "data": [ { "id": "prog_01M4Q4A8QMBFZ7661PFWXTBPF6", "object": "program", "name": "Phase 1", "environment": "sandbox", "form_factors": [ "virtual" ], "currencies": [ "SGD" ] } ], "has_more": false, "next_cursor": null } ``` If your organization has one program, you can capture it directly: ```bash export ALETA_PROGRAM_ID=$(curl -s "$ALETA_BASE_URL/v1/programs" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" | jq -r '.data[0].id') ``` ## Step 2 · Create a cardholder A cardholder is the person the card belongs to. New cardholders start with a KYC status of `not_started`; you'll verify them in the next step. For this quickstart, include both an `identity_document` and an `address`. A card can't be issued without them. We store the document type and number, but don't accept or return document images. The address is the cardholder's own address and is used for the card registration. ```bash CARDHOLDER=$(curl -s -X POST "$ALETA_BASE_URL/v1/cardholders" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "individual", "first_name": "Test", "last_name": "Approved", "email": "test.approved@example.com", "phone": "+6591234567", "date_of_birth": "1990-04-12", "address": { "line1": "350 Orchard Road", "city": "Singapore", "postal_code": "238868", "country": "SGP" }, "identity_document": { "type": "passport", "number": "E12345678" } }') echo "$CARDHOLDER" | jq CARDHOLDER_ID=$(echo "$CARDHOLDER" | jq -r '.id') ``` ```json title="Response" { "id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "cardholder", "type": "individual", "kyc_status": "not_started", "email": "test.approved@example.com", "has_identity_document": true, "created_at": "2026-07-29T14:32:05Z" } ``` The `kyc_status` is `not_started` for now. The document is on file, which is reflected by `has_identity_document`, but the document number isn't returned. You'll use `CARDHOLDER_ID` in the remaining steps. ## Step 3 · Report an approved KYC attestation A cardholder must be verified before a card can be issued. In most programs, you perform the identity verification yourself or through your own provider, then send AletaPlanet an attestation of the result. The identity documents themselves don't pass through this API. The call below is the same flow you'll use in production. In sandbox, we're just using test data. ```bash curl -s -X POST "$ALETA_BASE_URL/v1/cardholders/$CARDHOLDER_ID/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Sandbox Test Provider", "method": "document_and_selfie" }' | jq ``` ```json title="Response" { "id": "kyc_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "kyc_attestation", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Sandbox Test Provider", "created_at": "2026-07-29T14:32:05Z" } ``` The cardholder's `kyc_status` is now `approved`. A `cardholder.kyc_approved` event is also emitted. In a real integration, you can use that event to trigger the next part of your flow instead of polling the cardholder. ## Step 4 · Open an account The account is where the balance lives and where the card spends from. This is also where you specify the `program_id`. The card, authorizations, and transactions created from the account inherit that program, so you don't need to provide it again later. ```bash ACCOUNT=$(curl -s -X POST "$ALETA_BASE_URL/v1/accounts" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d "{ \"program_id\": \"$ALETA_PROGRAM_ID\", \"cardholder_id\": \"$CARDHOLDER_ID\", \"currency\": \"SGD\" }") echo "$ACCOUNT" | jq ACCOUNT_ID=$(echo "$ACCOUNT" | jq -r '.id') ``` ```json title="Response" { "id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "account", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "program_id": "prog_01HX8ZQ2K4M7P9RSTVWXYZAB", "currency": "SGD", "status": "active", "ledger_balance": { "amount": 0, "currency": "SGD" }, "held_balance": { "amount": 0, "currency": "SGD" }, "available_balance": { "amount": 0, "currency": "SGD" }, "created_at": "2026-07-29T14:32:06Z" } ``` The account starts with all three balances at zero. The `program_id` comes from the account you created. Your access token identifies the organization and environment, not the program. ## Step 5 · Issue and activate a virtual card Now you can issue a card against the account. A card isn't usable the moment it's created. It starts as `pending` while the processor issues it, becomes `inactive` when you receive `card.issued`, and becomes `active` when you call the activation endpoint. Once the account has funds, an `active` card can be used. ```bash CARD=$(curl -s -X POST "$ALETA_BASE_URL/v1/cards" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d "{ \"cardholder_id\": \"$CARDHOLDER_ID\", \"account_id\": \"$ACCOUNT_ID\", \"form_factor\": \"virtual\", \"embossed_name\": \"A TAN\", \"delivery_address\": { \"line1\": \"350 Orchard Road\", \"city\": \"Singapore\", \"postal_code\": \"238868\", \"country\": \"SGP\" } }") echo "$CARD" | jq CARD_ID=$(echo "$CARD" | jq -r '.id') ``` ```json title="Response" { "id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "card", "status": "pending", "form_factor": "virtual", "created_at": "2026-07-29T14:32:08Z" } ``` Wait until the card has been issued. In your application you'd wait for the `card.issued` webhook. For this tutorial, poll the card until its status is `inactive`: ```bash until [ "$(curl -s "$ALETA_BASE_URL/v1/cards/$CARD_ID" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" | jq -r '.status')" = "inactive" ]; do sleep 5 done ``` Then activate it: ```bash curl -s -X POST "$ALETA_BASE_URL/v1/cards/$CARD_ID/activate" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" | jq ``` ```json title="Response" { "id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "card", "status": "active", "form_factor": "virtual", "created_at": "2026-07-29T14:32:08Z" } ``` :::note Card details come separately The card response doesn't include the full card number or CVV, and `last_four` and `expires_at` only appear once the processor reports them. Retrieving the number is a separate operation; see [displaying PAN and CVV securely](/docs/products/card-issuing/cards/displaying-pan-and-cvv-securely). ::: ## Step 6 · Fund the account Let's add S$250.00 to the account. API amounts use **minor units**, so S$250.00 is represented as `25000`. ```bash curl -s -X POST "$ALETA_BASE_URL/v1/accounts/$ACCOUNT_ID/credit" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount": { "amount": 25000, "currency": "SGD" }, "description": "Quickstart funding" }' | jq ``` ```json title="Response" { "id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "account", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "currency": "SGD", "status": "active", "ledger_balance": { "amount": 25000, "currency": "SGD" }, "held_balance": { "amount": 0, "currency": "SGD" }, "available_balance": { "amount": 25000, "currency": "SGD" }, "updated_at": "2026-07-29T14:32:11Z" } ``` The account now has S$250.00 available, and the virtual card can spend from it. ## If something fails Here are the most common errors you'll see while working through this guide: | Status | `code` | Fix | | --- | --- | --- | | `401` | `invalid_access_token` | The access token has expired. Run step 1 again | | `422` | `identity_document_required` | Step 2 is missing `identity_document`. A cardholder needs one before a card can be issued | | `422` | `address_required` | Step 2 is missing `address`. Add it with `PATCH /v1/cardholders/{id}` | | `402` | `insufficient_funds` | The account hasn't been funded, or the card is linked to a different account | | `422` | `kyc_not_approved` | The cardholder hasn't been verified. Run step 3 before issuing the card | See the [error code catalog](/api-reference/errors/error-code-catalog) for the complete list. --- ## Sandbox and test data Card issuing is asynchronous and event-driven. Most of the events your system needs to handle come from outside it: a merchant authorizing a payment, a transaction settling, a refund being issued, or a dispute being raised. In production, you don't control when those things happen. The sandbox gives you a way to trigger them yourself, so you can build and test those flows without waiting for a real transaction. ## Cardholder verification in sandbox The sandbox verification flow works the same way as production. Your system is responsible for performing verification and reporting the result. A sandbox cardholder starts with a `not_started` KYC status until you submit an attestation. ```bash curl -X POST "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Sandbox Test Provider" }' ``` Try this flow when testing your issuing integration. An approved attestation emits `cardholder.kyc_approved`, which lets you test the webhook and any logic that depends on a verified cardholder. ## Test card behavior Sandbox cards use a test BIN and can't be used at real merchants. For normal authorization testing, set up the card and account state you want to test. A frozen card, an unfunded account, or a spend limit below the transaction amount will produce the corresponding decline. The sandbox also supports a few amount-based decline scenarios. These are useful when you need to trigger a particular authorization response without changing the card or account first: | Authorization amount (minor units) | Outcome | | --- | --- | | `…13` (ends in 13) | Declined, `do_not_honor` | | `…51` | Declined, `insufficient_funds` — regardless of actual balance | | `…57` | Declined, `transaction_not_permitted` | | `…62` | Declined, `restricted_card` | | Any other value | Approved, subject to balance and controls | For example, `1013` declines while `1014` approves. These amount-based rules only apply when Aleta Planet makes the authorization decision. Some programs use a processor that owns the balance and makes the decision instead. In those cases, the processor's response takes precedence and these sandbox amounts have no special meaning. The decline reasons above are `decline_reason` values on the authorization. They aren't HTTP errors, so you won't find them in the error catalog. If the processor makes the authorization decision, these values aren't exposed to you either. See [transactions](/docs/products/card-issuing/transactions) for more about processor-managed transactions. --- ## Event catalog The shape every webhook arrives in, and where to find the events each product emits. You choose which events an endpoint receives in Cockpit under Developers → Webhook endpoints. | Product | Events | | --- | --- | | Card issuing | [Card issuing events](./events-card-issuing) | Event names follow the `resource.past_tense_verb` pattern and don't change once they're published. New event types may be added in minor releases, so your handler should ignore event types it doesn't recognize instead of treating them as errors. ## The envelope All webhook events use the same outer structure: ```json { "id": "evt_01HX8ZQ2K4M7P9RSTV", "object": "event", "type": "card.activated", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-07-29T14:35:02Z", "data": { "object": { "…": "the affected object" } } } ``` `environment` is included on every event, so the same endpoint can handle both sandbox and production traffic without needing separate URLs. `api_version` records the version used to serialize that particular payload. Don't assume it based on the version you're currently using to make API requests. For a given resource, every event type uses the same payload object. For example, every `cardholder.*` event contains a cardholder, and every `card.*` event contains a card. The event type tells you what happened; `data.object` tells you which object it happened to. The payload represents the object as it looked when the event was created. It isn't re-read when the webhook is delivered. There is one exception to the normal REST API shape: on `account.*` events, `ledger_balance`, `held_balance`, and `available_balance` are plain integers in minor units. The currency is provided separately in the payload's `currency` field. ## Test deliveries `data.object` is a `webhook_test` object containing `object`, `endpoint_id`, `message`, and `sent_at`. This is the one event payload that isn't based on a REST API resource. ```json title="webhook.test" { "id": "evt_01HX8ZQ2K4M7P9RSU3", "object": "event", "type": "webhook.test", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-07-29T14:30:00Z", "data": { "object": { "object": "webhook_test", "endpoint_id": "whe_01HX8ZQ2K4M7P9RSTV", "message": "This is a test event from AletaPlanet Cockpit.", "sent_at": "2026-07-29T14:30:00Z" } } } ``` Test events use the same outbox, signing, and retry path as normal events. That's useful for testing the complete webhook setup rather than just checking whether an endpoint is reachable. Handle `webhook.test` explicitly instead of letting it fall through to whatever your default event handler does. ### `webhook.test` {#webhook.test} A test event was sent from Cockpit. This event is only used for testing and never appears as part of normal card activity. ## Handling unknown event types Your webhook handler should always have a fallback for event types it doesn't know about: ```javascript switch (event.type) { case 'cardholder.kyc_approved': return onKycApproved(event.data.object); case 'card.closed': return onCardClosed(event.data.object); default: // New event types can be added in minor releases. // Acknowledge events you don't handle rather than failing the webhook. logger.debug({ type: event.type }, 'unhandled webhook event'); return; } ``` New event types shouldn't require you to deploy a new version of your integration just to keep receiving existing events. Log unknown types if they're useful to you, but acknowledge them normally. --- ## Card issuing events The events a card issuing program emits. The envelope they arrive in, test deliveries and the rule for unknown types are on the [event catalog](./event-catalog) and apply to every product. Each event has its own anchor. For example, `/docs/webhooks/events-card-issuing#card.frozen` goes straight to `card.frozen`. ## Cardholder `data.object` is a [cardholder](/api-reference/resources/get-cardholder) with `id`, `object`, `type`, `kyc_status`, `has_identity_document`, `created_at`, `updated_at`, plus `first_name`, `last_name`, `email`, and `reference` when those fields are set. Sensitive identity information isn't included in webhook payloads. The identity document number is never returned; `has_identity_document` only tells you whether a document is on file. The address, phone number, and date of birth aren't included either. ```json title="cardholder.kyc_approved" { "id": "evt_01HX8ZQ2K4M7P9RSTV", "object": "event", "type": "cardholder.kyc_approved", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-07-29T14:35:02Z", "data": { "object": { "id": "chd_01HX8ZQ2K4M7P9RSTV", "object": "cardholder", "type": "individual", "kyc_status": "approved", "has_identity_document": true, "first_name": "Test", "last_name": "Approved", "email": "test.approved@example.com", "reference": "user-4471", "created_at": "2026-07-29T14:32:05Z", "updated_at": "2026-07-29T14:35:02Z" } } } ``` ### `cardholder.created` {#cardholder.created} A new cardholder record was created. ### `cardholder.updated` {#cardholder.updated} A mutable cardholder field changed. This includes changes to `kyc_status`. ### `cardholder.kyc_approved` {#cardholder.kyc_approved} The cardholder's identity verification was approved. ### `cardholder.kyc_rejected` {#cardholder.kyc_rejected} The cardholder's verification was definitively rejected. ## Account `data.object` is an [account](/api-reference/resources/get-account) with `id`, `object`, `cardholder_id`, `program_id`, `currency`, `status`, `ledger_balance`, `held_balance`, `available_balance`, `created_at`, and `updated_at`, plus `reference` when set. The three balance fields are integers in the minor units of `currency`. This is different from the REST API representation, where monetary values use `{amount, currency}` objects. The processor's own account number is never included in an event payload. ```json title="account.funded" { "id": "evt_01HX8ZQ2K4M7P9RSTW", "object": "event", "type": "account.funded", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-07-29T14:36:10Z", "data": { "object": { "id": "acct_01HX8ZQ2K4M7P9RSTV", "object": "account", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTV", "program_id": "prog_01HX8ZQ2K4M7P9RSTVWXYZABCD", "currency": "SGD", "status": "active", "ledger_balance": 25000, "held_balance": 0, "available_balance": 25000, "created_at": "2026-07-29T14:32:06Z", "updated_at": "2026-07-29T14:36:10Z" } } } ``` The balances reflect the account at the time the event is created. For `account.funded`, that means the values have been read back after the credit was applied rather than coming from the account's previous state. ### `account.created` {#account.created} An account was opened. ### `account.updated` {#account.updated} The account changed. This includes status changes, such as an account being blocked or unblocked, as well as changes reported by the processor or a debit from the account. ### `account.funded` {#account.funded} Value was credited to the account. The balance fields contain the values after the credit was applied. ## Card `data.object` is a [card](/api-reference/resources/get-card) with `id`, `object`, `cardholder_id`, `account_id`, `program_id`, `form_factor`, `status`, `currency`, `is_default`, `created_at`, and `updated_at`, plus `last_four`, `embossed_name`, `expires_at`, and `reference` when set. ```json title="card.created" { "id": "evt_01HX8ZQ2K4M7P9RSTX", "object": "event", "type": "card.created", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-07-29T14:32:08Z", "data": { "object": { "id": "card_01HX8ZQ2K4M7P9RSTV", "object": "card", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTV", "account_id": "acct_01HX8ZQ2K4M7P9RSTV", "program_id": "prog_01HX8ZQ2K4M7P9RSTVWXYZABCD", "form_factor": "virtual", "status": "active", "currency": "SGD", "last_four": "4242", "expires_at": "2029-04-30T23:59:59Z", "is_default": true, "created_at": "2026-07-29T14:32:08Z", "updated_at": "2026-07-29T14:32:08Z" } } } ``` The full card number and CVV are never included in webhook payloads. `last_four` is the only part of the card number exposed here. `card.created` means the card request was accepted. `card.issued` means the card now exists and can be activated. `card.activated` means it can be used. Every card follows the same path, on every program: | Event | `status` in the payload | What to do | | --- | --- | --- | | `card.created` | `pending`, or `inactive` if the card was issued at once | Nothing yet | | `card.issued` | `inactive` | Call `POST /v1/cards/{id}/activate` when you want the card to start working | | `card.activated` | `active` | The card can be used | `card.created` fires for every card. `card.issued` fires when a card moves out of `pending`. A card that was issued at once is `inactive` already in the `card.created` payload and has no `card.issued` event, so use the `status` in the payload rather than relying on the event name. ### `card.created` {#card.created} Your request to issue a card was accepted and the card record now exists. It fires once for every card, on every program. The `status` tells you what happened: - `pending` — the request was accepted, but the card isn't ready yet. `card.issued` follows once it is. - `inactive` — the card was issued at once and is ready to be activated. ### `card.issued` {#card.issued} A `pending` card now exists at the processor. Its `status` is `inactive`: the card is ready to be activated, and can't be used yet. The card may take a few minutes to appear, and sometimes closer to an hour. We check for it for you and send the event once it's ready, so there's no need to poll. The payload carries `last_four` and `expires_at` when the processor reports them. Activate the card with `POST /v1/cards/{card_id}/activate` when you're ready for it to be used. ### `card.activated` {#card.activated} The card is now `active` and can be used. It fires when you activate a card with `POST /v1/cards/{card_id}/activate`, for virtual and physical cards alike. Unfreezing a card does not trigger `card.activated`. It triggers `card.unfrozen`. ### `card.frozen` {#card.frozen} The card was temporarily blocked. ### `card.unfrozen` {#card.unfrozen} A temporary card block was removed. ### `card.closed` {#card.closed} The card was permanently terminated. ## Authentication `data.object` is an [authentication](/api-reference/resources/get-authentication) with `id`, `object`, `status`, `method`, `card_id`, `cardholder_id`, `amount`, `created_at`, and `updated_at`, plus `merchant` and `expires_at` when the Access Control Server provides them. Unlike the account balance fields, `amount` uses the normal `{amount, currency}` format. ```json title="authentication.challenged" { "id": "evt_01HX8ZQ2K4M7P9RSTX", "object": "event", "type": "authentication.challenged", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-07-29T14:38:00Z", "data": { "object": { "id": "authn_01HX8ZQ2K4M7P9RSTV", "object": "authentication", "status": "pending", "method": "out_of_band", "card_id": "card_01HX8ZQ2K4M7P9RSTV", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTV", "merchant": { "name": "Kopi Roastery" }, "amount": { "amount": 4250, "currency": "SGD" }, "created_at": "2026-07-29T14:38:00Z", "updated_at": "2026-07-29T14:38:00Z" } } } ``` Only out-of-band challenges produce authentication events. Passcode challenges are handled entirely inside the ACS and don't create an authentication object or webhook event. An authentication can have a `status` of `pending`, `approved`, or `declined`. ### `authentication.challenged` {#authentication.challenged} A challenge is waiting for an answer from your application. The response window is only a few minutes, so this should be treated as a high-priority event. ### `authentication.approved` {#authentication.approved} The challenge was approved and the authentication now has an `approved` status. This doesn't mean the purchase is complete. The authorization happens separately and can still be declined because of the card state, available funds, or other controls. ### `authentication.declined` {#authentication.declined} The challenge was declined, either by the cardholder or because your step-up flow failed. The merchant transaction will fail as a result. ## Related - [Event catalog](./event-catalog) — the envelope, test deliveries, and unknown types - [Card issuing concepts](/docs/products/card-issuing/core-concepts) — the objects these events carry --- ## Idempotent event handling You will sometimes receive the same event more than once. This usually isn't a problem, but it can be if your handler charges an account twice or sends the same email twice. The fix is simple: dedupe using `event.id`, and make your handler safe to run more than once. ## Where duplicates come from There are three common reasons. **Your `2xx` response didn't reach us.** Your handler processed the event, but the response was lost. We retry it because we don't know the event was already handled. This is the most common case. **Someone pressed replay.** Cockpit can replay an old delivery. It creates a new delivery for the same event, with the same payload and `id`. This is useful when you're fixing a bug in your handler, so dedupe needs to use the event id, not the delivery. **Two endpoints listen for the same event.** Each endpoint gets its own delivery. If both endpoints point to the same service, your service will see the event twice. ## Deduplicating Every event has a unique `id`: ```json { "id": "evt_01M480J2CTN4T8D9E8D2TBN1KC", "type": "cardholder.created", "created_at": "2026-10-06T07:06:30Z" } ``` Store the id before doing the work: ```sql CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ```javascript async function handle(event) { const { rowCount } = await db.query( 'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING', [event.id], ); if (rowCount === 0) return; // already handled await applyEvent(event); } ``` The insert acts as the lock. If two copies of the same event arrive at the same time, only one insert succeeds. The other gets `ON CONFLICT` and stops. Don't use these as the key: - **The object id.** A card can have `card.created`, `card.activated`, and `card.frozen`. They all have the same `card_id`, but they're different events. - **A hash of the payload.** Two different events can have the same payload. For example, two identical updates can still be two separate events. - **The delivery id.** A replay creates a new delivery for the same event. You want to treat it as a duplicate. ## Insert first, or work first? Inserting the event id first has a downside. If the handler crashes halfway through, the event is already marked as handled. A retry will be ignored, even though the work wasn't finished. If the work is a database change you control, put both the insert and the work in the same transaction: ```javascript await db.transaction(async (tx) => { const { rowCount } = await tx.query( 'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING', [event.id], ); if (rowCount === 0) return; await applyEvent(tx, event); }); ``` If the handler crashes, both changes are rolled back. The retry can then run the event again. This doesn't help with things the database can't roll back, like: - charging a card - sending an email - calling another API For those, put the event on a durable queue as part of the transaction. Then let the worker handle idempotency for the external operation, using whatever idempotency key that service supports. ## Make the work idempotent too Dedupe is useful, but don't rely on it completely. The table could be cleared, truncated during a migration, or a replay could happen much later. Your handler should still be safe if the same event runs twice. Usually, that means writing the state from the event instead of applying a change: ```javascript // Safe to run twice. await db.query( 'UPDATE cards SET status = $1 WHERE id = $2', [event.data.object.status, event.data.object.id], ); // Not safe to run twice. await db.query( 'UPDATE accounts SET balance = balance - $1 WHERE id = $2', [event.data.object.amount, accountId], ); ``` `data.object` contains the full object, not just the changes. So for most events, you can just save the state it reports. Running the same event again won't change anything. For things that really need to accumulate, store the event id with the row and add a unique constraint: ```sql CREATE TABLE ledger_entries ( id BIGSERIAL PRIMARY KEY, event_id TEXT NOT NULL UNIQUE, amount BIGINT NOT NULL ); ``` If the same event tries to create the row again, the unique constraint catches it. ## Combining this with ordering Deduplication and ordering are two different problems. A duplicate event should be dropped. An old event should also be dropped if it would overwrite newer state. That old event won't be in `processed_events` because you haven't seen it before. Handle both: ```javascript async function handle(event) { if (await alreadyProcessed(event.id)) return; // duplicate if (await staleFor(event)) return; // out of order await applyEvent(event); } ``` The second check uses `created_at` from the event. See [retries, backoff and ordering](./retries-backoff-and-ordering). ## Not the same as `Idempotency-Key` The API's `Idempotency-Key` header is different. That one prevents a request **you send** from being applied twice when you retry it. Event deduplication prevents an event **we send you** from being applied twice. They solve different problems and don't share any state. Having one doesn't give you the other. ## How long to keep the ids Automatic retries stop after eight attempts within 45 minutes, so keeping ids for 24 hours is enough to cover those retries. Replays are different. Someone might replay an event from last month while debugging. If you only keep ids for one day, that event will be processed again. For most systems, it's easiest to keep the ids indefinitely. One small row per event is usually cheap. You can clean them up later if the table gets too large. ## Check it works Pick a cardholder event in Cockpit's delivery list and replay it. Your endpoint should return `200`, and nothing in your database should change: - no new row - no updated timestamp - no second email If something changes, your handler isn't fully idempotent yet. The replay should help you find where. ## Common mistakes | Mistake | What happens | | --- | --- | | Checking for the id, then inserting it after the work | Two copies can both pass the check | | Deduplicating on the object id | Real events for the same object get dropped | | Deduplicating per delivery instead of per event | Replay gets processed again | | Relying only on dedupe with `balance = balance - x` | Clearing the dedupe table can cause a double debit | | Returning `4xx` for a duplicate | The event gets retried. Return `200` instead | ## Next - [Retries, backoff and ordering](./retries-backoff-and-ordering) — where duplicates and out-of-order events come from. - [Testing locally](./testing-locally) — replay an event against your laptop. - [Card issuing events](./events-card-issuing) — every event type and its payload. --- ## Overview and delivery guarantees Card issuing is asynchronous. A card can take days to be personalized and shipped. An authorization happens when a cardholder taps a terminal you don't control. A dispute might not show up until weeks after the original transaction. You can't get that information from the response to an API request you made earlier. Webhooks are how those events reach your system. Polling isn't a good substitute. It's slower, and polling endpoints are heavily rate-limited. ## The event object Every webhook is delivered as a `POST` request with a body like this: ```json { "id": "evt_01M480J2CTN4T8D9E8D2TBN1KC", "object": "event", "type": "cardholder.created", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-10-06T07:06:30Z", "data": { "object": { "id": "chd_01M480J2CAQ99KCTMWDJTF9ENN", "type": "individual", "email": "weichun004@example.test", "object": "cardholder", "last_name": "User 1", "reference": "ctrip.1", "created_at": "2026-10-06T07:06:30Z", "first_name": "Ctrip", "kyc_status": "not_started", "updated_at": "2026-10-06T07:06:30Z", "has_identity_document": true } } } ``` | Field | Purpose | | --- | --- | | `id` | Unique per event. **Use this for deduplication.** | | `type` | Describes what happened. See the [event catalog](./event-catalog). | | `created_at` | When the event occurred, not when it was delivered. | | `api_version` | The API version used to serialize the payload. | | `data.object` | The affected object, using the same shape returned by the REST API. | `data.object` contains the full object rather than just the fields that changed. In most cases, you don't need to fetch the object again. If you need the object's current state rather than the state captured when the event was created, fetch it again. ## Headers Webhook requests also include a few headers: ```http POST /webhooks/aleta HTTP/1.1 Content-Type: application/json Aleta-Signature: t=1785508332,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Aleta-Event-Id: evt_01HX8ZQ2K4M7P9RSTV Aleta-Event-Type: card.frozen Aleta-Delivery-Attempt: 1 ``` `Aleta-Event-Id` and `Aleta-Event-Type` are also included in the request body. That's intentional: it lets a gateway route or deduplicate a request without having to parse the payload first. Don't trust these headers until you've verified the signature. Until then, treat everything in the request as untrusted input. ## What is guaranteed **At-least-once delivery:** An event is delivered at least once as long as your endpoint eventually returns a `2xx` response. We retain events and retry failed deliveries for 72 hours. **Signed payloads:** Every delivery includes an HMAC-SHA256 signature covering the timestamp and raw request body. See [signature verification](./signature-verification). **Durable events:** Events are stored for 30 days and remain available in Cockpit for replay, regardless of whether the original delivery succeeded. ## What is not guaranteed A few important things are deliberately not guaranteed. Most webhook bugs in production tend to come from assuming one of these is true when it isn't. **Events are not guaranteed to arrive in order.** For example, `card.activated` can arrive before `card.created`. Retries can change the order of delivery, and unrelated events can race with each other. > Use `created_at` when you need to reason about event order, and don't apply an > event if it represents a state older than the state you've already recorded. > Don't rely on a fixed event sequence. **Exactly-once delivery is not guaranteed.** You can receive the same event more than once. This can happen when your endpoint sends a `2xx` response but we don't receive it successfully. Use the event `id` to deduplicate. **Delivery time is not guaranteed.** Most events arrive in under a second, but a retry can arrive hours later. Don't make your UI wait for a webhook before it can continue. **You aren't guaranteed to receive a complete sequence within a particular time window.** If your system is offline for a while, you may come back to a burst of events covering several days. Process each event based on its own contents rather than assuming you're receiving a perfect, uninterrupted stream. ## Responding to a webhook Your endpoint should return a **`2xx` response within 10 seconds**. A `4xx`, `5xx`, timeout, or TLS failure is treated as a failed delivery and causes a retry. The most important rule is simple: > **Acknowledge first, process afterwards.** For example: ```javascript app.post('/webhooks/aleta', express.raw({ type: 'application/json' }), (req, res) => { const event = verifyAndParse(req); // fast, in-process queue.enqueue(event); // durable queue res.sendStatus(200); // acknowledge immediately }); ``` Keep the webhook handler small. Database writes, third-party API calls, emails, and other slow operations should happen after the event has been acknowledged. If one of those dependencies becomes slow or unavailable, doing the work inside the handler can cause webhook failures, which leads to more retries and eventually retry storms. An endpoint that keeps failing can also be disabled. The response body doesn't matter. We ignore it, so there's no need to put anything meaningful there. ## Endpoint requirements Your webhook endpoint must meet these requirements: - **HTTPS** with a certificate that validates against a public root. Self-signed certificates aren't accepted. - A **publicly resolvable** hostname. Private addresses, `localhost`, and internal-only DNS aren't accepted. If you try to register one, the API returns `invalid_webhook_url`. - A `POST` endpoint that returns a **`2xx` response within 10 seconds**. You can register up to 10 endpoints per environment. Each endpoint can subscribe to its own set of event types. It's also fine to send all the events to a single endpoint and filter them in your application. That's a common setup. ## Failure handling Failed deliveries are retried using exponential backoff for up to 72 hours. If an endpoint fails continuously for 72 hours, it is automatically disabled. The events aren't lost when this happens. They're still stored and can be replayed after you re-enable the endpoint. ## Next - [Signature verification](./signature-verification) — verify the webhook before doing anything else. - [Card issuing events](./events-card-issuing) — every event type and its payload. --- ## Retries, backoff and ordering Your endpoint will fail sometimes. A deploy might restart the process, a database might go down for a bit, or a bug might break your handler. Retries are there so a short problem doesn't make you lose an event. Retries don't guarantee order, though. In fact, a failed event can end up arriving after newer events. That's the part to watch out for. ## What counts as a failure Any `2xx` response means the delivery succeeded. Everything else is a failure and will be retried. | Outcome | Result | | --- | --- | | `2xx` | Success | | `3xx` | Failure. Redirects aren't followed | | `4xx` | Failure | | `5xx` | Failure | | No response within 10 seconds | Failure | | Connection refused, DNS failure, TLS error | Failure | We don't follow redirects because your endpoint URL is the address you gave us. Following a `302` could send a signed payload somewhere you didn't register. If you need to move your endpoint, update the URL in Cockpit. A `4xx` is retried just like a `5xx`. If your handler returns `400` because it doesn't understand an event, you'll get that same event seven more times. ## The retry schedule We make up to eight attempts. The wait between attempts gets longer, and the actual wait is randomized. | After attempt | Next attempt lands within | | --- | --- | | 1 | 30 seconds | | 2 | 50 seconds | | 3 | 90 seconds | | 4 | 3 minutes | | 5 | 6 minutes | | 6 | 11 minutes | | 7 | 22 minutes | | 8 | Never. The delivery is marked failed | All eight attempts happen within 45 minutes of the first attempt. The random delay is important. Imagine 1,000 deliveries fail at the same time. If they all retried at exactly the same time, they'd all hit your endpoint again together. Spreading them out avoids another traffic spike. The times above are limits, not an exact schedule. A retry might arrive quickly or much later in the window. Don't rely on a specific delay or try to guess the attempt number from the delay. ## Why events can arrive out of order New events are ready to be delivered immediately. Retries have to wait for their backoff period. For example, a card might be created and then frozen a second later: ```text 10:00:00 card.created attempt 1 → 500 from your endpoint 10:00:01 card.frozen attempt 1 → 200 10:00:18 card.created attempt 2 → 200 ``` Your handler sees `card.frozen` before `card.created`. There are also four deliveries running at the same time, so even events that don't fail can arrive in a different order. If two endpoints listen for the same events, they can also see those events in different orders. ### Handling out-of-order events Don't use arrival order to decide which event is newer. Use `created_at` from the event instead. It tells you when the event happened, not when it reached your endpoint. Before applying an event, compare its `created_at` with the timestamp you already have for that object. If the event is older, ignore it. For example: ```sql UPDATE cards SET status = $1, event_at = $2 WHERE id = $3 AND event_at < $2; ``` If the event is older, the update changes nothing. Your handler should still return `200`. If you need the object's current state rather than the state at the time the event was created, fetch the object from the API. The event can tell you that something changed without being the final source of truth. ## When an endpoint keeps failing After **20 consecutive failed deliveries**, the endpoint is disabled automatically. This prevents one broken endpoint from using delivery capacity for everyone else. A successful delivery resets the counter, so the failures have to happen 20 times in a row. While an endpoint is disabled, new events don't create deliveries for it. This is important: > Re-enabling an endpoint does not back-fill the time it was disabled. Events > still exist in the event log, but no deliveries were created for them, so > there's nothing to replay from the delivery list. If an endpoint was disabled, treat the missing period as a reconciliation job. List the affected objects through the API and compare them with your own data. Don't wait for those events to show up after re-enabling the endpoint. Cockpit shows endpoint health over the last 24 hours, so you can spot problems before they become customer-facing. ## Reading the delivery log Every delivery attempt is recorded. In Cockpit's webhook delivery list, you can see: - event id and type - attempt number - HTTP status - first 8 KB of the response body - request duration - any error that occurred The response body is there to help you debug. We don't parse it or use it to make delivery decisions. ## Common mistakes | Mistake | What happens | | --- | --- | | Returning `400` for an event you don't recognise | It gets retried 7 more times and counts toward the 20 failures that disable your endpoint. Return `200` and ignore it | | Doing the work before responding | Slow dependencies can cause timeouts and retries. Acknowledge the event quickly | | Assuming `card.created` always arrives before `card.activated` | It usually does, but it can arrive the other way around | | Treating a long gap as "this was attempt 6" | Retry delays are randomized, so the gap doesn't tell you the attempt number | | Waiting for events after re-enabling an endpoint | Nothing was queued while the endpoint was disabled. Reconcile through the API | ## Next - [Idempotent event handling](./idempotent-event-handling) — how to safely handle the same event more than once. - [Overview and delivery guarantees](./overview-and-delivery-guarantees) — what's guaranteed and what isn't. --- ## Signature verification Your webhook endpoint is public and can trigger changes in your system. Anyone who finds the URL can send it a request, so you need to verify that a webhook actually came from AletaPlanet before doing anything with it. Signature verification is required for every webhook. ## The signature header Each delivery includes an `Aleta-Signature` header: ```http Aleta-Signature: t=1785508332,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` The header contains two values: | Element | Meaning | | --- | --- | | `t` | Unix timestamp, in seconds, when the signature was generated. | | `v1` | The HMAC-SHA256 signature, encoded as hexadecimal. | You may see more than one `v1` value while a signing secret is being rotated. Accept the request if any of the signatures matches. ## How the signature is computed The signed value is the timestamp, followed by a literal `.` and the raw request body: ```text signed_payload = t + "." + raw_body signature = hex(hmac_sha256(signing_secret, signed_payload)) ``` The signature is calculated from the exact request body that was sent. Verify the signature against the original bytes. Don't parse the JSON and then serialize it again before verifying it. Changing whitespace, key order, escaping, or formatting changes the bytes and therefore changes the signature. If you're using Express, use `express.raw()` on the webhook route and make sure it runs before any global `express.json()` middleware. ## Verification steps The basic flow is: 1. Read `t` and every `v1` value from the `Aleta-Signature` header. 2. Reject the request if `t` is more than **5 minutes** away from your current time. 3. Calculate the HMAC using `t + "." + raw_body` and your webhook signing secret. 4. Compare the calculated signature with the received signatures using a **constant-time** comparison. 5. Only after verification succeeds should you parse the body and process the event. The timestamp check prevents replay attacks. Without it, someone who captures a valid webhook could send the same request to your endpoint over and over. The constant-time comparison helps prevent timing attacks that could otherwise reveal the expected signature one byte at a time. Don't use normal string comparisons such as `===` or `==` for this. Your signing secret is created with the endpoint in Cockpit under Developers → Webhook endpoints. It's shown only once. The signing secret is different from your API access token. Don't use one in place of the other. ## Implementations ```javascript import crypto from 'node:crypto'; import express from 'express'; const TOLERANCE_SECONDS = 300; function verify(rawBody, header, secret) { const parts = Object.fromEntries( header.split(',').map((kv) => kv.split('=').map((s) => s.trim())), ); const timestamp = Number(parts.t); if (!timestamp) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) { return false; } const expected = crypto .createHmac('sha256', secret) .update(`${parts.t}.${rawBody}`, 'utf8') .digest('hex'); const received = Buffer.from(parts.v1 ?? '', 'utf8'); const computed = Buffer.from(expected, 'utf8'); // timingSafeEqual throws on length mismatch, so check first. return ( received.length === computed.length && crypto.timingSafeEqual(received, computed) ); } const app = express(); app.post( '/webhooks/aleta', express.raw({ type: 'application/json' }), (req, res) => { const raw = req.body.toString('utf8'); if ( !verify( raw, req.get('Aleta-Signature') ?? '', process.env.ALETA_WEBHOOK_SECRET, ) ) { return res.sendStatus(400); } queue.enqueue(JSON.parse(raw)); res.sendStatus(200); }, ); ``` ```python import hashlib import hmac import os import time from flask import Flask, request TOLERANCE_SECONDS = 300 app = Flask(__name__) def verify(raw_body: bytes, header: str, secret: str) -> bool: try: parts = dict(kv.strip().split("=", 1) for kv in header.split(",")) timestamp = int(parts["t"]) except (ValueError, KeyError): return False if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode("utf-8"), f"{parts['t']}.".encode("utf-8") + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) @app.post("/webhooks/aleta") def handle(): raw = request.get_data() header = request.headers.get("Aleta-Signature", "") if not verify(raw, header, os.environ["ALETA_WEBHOOK_SECRET"]): return "", 400 queue.enqueue(request.get_json()) return "", 200 ``` ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "math" "net/http" "os" "strconv" "strings" "time" ) const toleranceSeconds = 300 func verify(rawBody []byte, header, secret string) bool { var ts, sig string for _, part := range strings.Split(header, ",") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "t": ts = kv[1] case "v1": sig = kv[1] } } timestamp, err := strconv.ParseInt(ts, 10, 64) if err != nil { return false } if math.Abs(float64(time.Now().Unix()-timestamp)) > toleranceSeconds { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(sig)) } func handler(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } if !verify( raw, r.Header.Get("Aleta-Signature"), os.Getenv("ALETA_WEBHOOK_SECRET"), ) { w.WriteHeader(http.StatusBadRequest) return } enqueue(raw) w.WriteHeader(http.StatusOK) } ``` ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.Arrays; import java.util.HexFormat; import java.util.Map; import java.util.stream.Collectors; public final class AletaWebhookVerifier { private static final long TOLERANCE_SECONDS = 300; public static boolean verify( byte[] rawBody, String header, String secret ) throws Exception { Map parts = Arrays.stream(header.split(",")) .map(String::trim) .map(kv -> kv.split("=", 2)) .filter(kv -> kv.length == 2) .collect(Collectors.toMap( kv -> kv[0], kv -> kv[1], (a, b) -> a )); String ts = parts.get("t"); String signature = parts.get("v1"); if (ts == null || signature == null) { return false; } long timestamp = Long.parseLong(ts); if (Math.abs(Instant.now().getEpochSecond() - timestamp) > TOLERANCE_SECONDS) { return false; } Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256" )); mac.update((ts + ".").getBytes(StandardCharsets.UTF_8)); String expected = HexFormat.of().formatHex(mac.doFinal(rawBody)); return MessageDigest.isEqual( expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8) ); } } ``` ```php TOLERANCE_SECONDS) { return false; } $expected = hash_hmac( 'sha256', $parts['t'] . '.' . $rawBody, $secret ); return hash_equals($expected, $parts['v1']); } $raw = file_get_contents('php://input'); $header = $_SERVER['HTTP_ALETA_SIGNATURE'] ?? ''; if (!aleta_verify( $raw, $header, getenv('ALETA_WEBHOOK_SECRET') )) { http_response_code(400); exit; } enqueue(json_decode($raw, true)); http_response_code(200); ``` ## Rotating the signing secret You can rotate a signing secret without taking your webhook endpoint offline: 1. Generate a second secret in Cockpit. Both secrets become active. 2. During the overlap, deliveries are signed with **both** secrets and the header contains two `v1` values. 3. Deploy code that accepts either signature. 4. Retire the old secret. The overlap period is what allows you to rotate safely without missing events. Because the header can contain multiple `v1` values, your parser should treat them as a list rather than assuming there will only ever be one. ## Common mistakes | Mistake | Symptom | | --- | --- | | Verifying against a parsed and re-serialized body | Every signature fails. | | Mounting a global JSON parser before the webhook route | Every signature fails. | | Using `==` or `===` for the comparison | The comparison works, but isn't constant-time. | | Skipping the timestamp check | Captured deliveries can be replayed. | | Treating `v1` as a single value | Verification breaks during secret rotation. | | Using the API access token as the signing secret | Every signature fails. | ## Next - [Card issuing events](./events-card-issuing) — every event type and its payload. --- ## Testing locally You can test your webhook handler on your own machine using a sandbox endpoint. You don't need a mock server or fixture files. You can use real event payloads, real signatures, and real retries. Sandbox is a little more relaxed than production: **plain `http://` endpoints are allowed in sandbox**. Production requires HTTPS. Everything else works the same way. ## Getting your handler a URL Pick the setup that matches how you're working. **Running the API locally.** The dispatcher runs in the same process, so it can reach your laptop directly. Register `http://localhost:3000/webhooks` and you're ready to go. **Using the hosted sandbox.** Deliveries come from our servers, so they need a hostname they can reach. A tunnel is the easiest option: ```bash # ngrok ngrok http 3000 # or cloudflared cloudflared tunnel --url http://localhost:3000 ``` Both commands give you a public URL. Register that URL as your webhook endpoint. Free tunnel URLs usually change when you restart them. Instead of changing the endpoint every time, you can point the tunnel at a small local proxy, or keep a second endpoint registered and switch between them. ## Registering the endpoint Webhook endpoints are managed in Cockpit under **Developers → Webhooks**. There is no API for creating them. You'll need to set three things: - **URL** — your tunnel URL or `localhost`, including the handler path. - **Events** — use `*` while developing. You can narrow this down later. - **Description** — give it a name that tells you which machine it's for. Save the endpoint, then copy its signing secret: ```bash export ALETA_WEBHOOK_SECRET=whsec_... ``` Your handler needs this secret to verify webhook signatures. Signature verification can't be turned off. ## Testing webhook event Click **Send test event** on the endpoint. This sends a test delivery directly to that endpoint, so it doesn't matter which events the endpoint is subscribed to. The button only tells you that the event was `queued`. The dispatcher should pick it up within a second or so, and the result will appear in the delivery log. The event looks like this: ```json { "id": "evt_01M480J2CTN4T8D9E8D2TBN1KC", "object": "event", "type": "webhook.test", "api_version": "2026-08-01", "environment": "sandbox", "created_at": "2026-10-06T07:06:30Z", "data": { "object": { "object": "webhook_test", "endpoint_id": "whe_01HX8ZQ2K4M7P9RSTVWXYZAB", "message": "This is a test event from AletaPlanet Cockpit.", "sent_at": "2026-10-06T07:06:30Z" } } } ``` A test event has a real signature, so it's useful for testing your signature verification. One thing to remember: you can choose any event type for a test, but `data.object` is always a `webhook_test` object. For example, if you send a test as `card.frozen`, `data.object.id` won't exist. Use `webhook.test` to check connectivity and signature verification. Use real events to test event-specific handlers. ## Debugging from the delivery log Each delivery attempt records what happened: - HTTP status - first 8 KB of the response body - request duration - transport error, if the request didn't complete Check the response body when debugging. If your framework returns a stack trace with a `500`, you'll usually find it right there in the delivery log. ## Before you go to production Create a separate production endpoint. Endpoints and signing secrets don't carry over between environments. Check these things first: - The production URL uses HTTPS and has a valid certificate. `http://` only works in sandbox. - Your handler returns `2xx` within 10 seconds. If it needs to do real work, put that work behind a queue. - Your handler returns `2xx` for event types it doesn't recognise. A `4xx` causes retries and counts toward the 20 consecutive failures that disable an endpoint. - Deduplication uses `event.id`. Test it by replaying a delivery. Keep the sandbox endpoint registered. It doesn't cost anything, and it'll be useful the next time you change your handler. ## Common issues | Symptom | Cause | | --- | --- | | Delivery log shows a transport error, but your handler gets nothing | The tunnel is down, or the endpoint still points to an old tunnel URL | | `signature does not match` on every delivery | JSON middleware changed the body before you verified it | | Signature fails only for an old saved payload | The 5-minute timestamp window has expired. Sign it again | | Test events arrive, but real events don't | The endpoint isn't subscribed to those events. Test events are sent directly to the endpoint | | `data.object.id` is undefined on a test event | Test events always contain a `webhook_test` object | | Endpoint stopped receiving events | 20 consecutive failures disabled it. Re-enable it and reconcile the missing period through the API | | Handler runs twice for one action | Two endpoints point to the same service, or the delivery was retried | ## Next - [Signature verification](./signature-verification) — the full verifier in several languages. - [Card issuing events](./events-card-issuing) — every event type and what its payload looks like. --- ## Audit logs Some API operations are audited, including retrieving card secrets, recording a KYC attestation, blocking an account, and issuing a refund. This page explains what that means in practice, and what it doesn’t mean. The audit log is meant to be reviewed by a person investigating an event, under an identity that is subject to access control. That means it lives in Cockpit rather than behind an API credential your services can use. Cockpit's audit log is part of the team and access-control work rather than the initial API release. If you need audit evidence for an assessment before then, your account contact can retrieve it. This is intentional. If the audit log were available through the API, anything with an access token could read it, which defeats much of the point of having an access-controlled audit trail. ## What is recorded There are two categories, and they exist for slightly different reasons. ### Actions taken through the API | Action | Why it is recorded | | --- | --- | | Reveal card secrets | A full card number was released. This is the most sensitive action on the platform | | PIN set and PIN verify | Records PIN changes and lockout history | | KYC attestations | Shows who asserted what and when — see [re-verification](/docs/products/card-issuing/cardholders-and-kyc/re-verification-and-remediation) | | Cardholder record changes | Shows when PII changed and which credential made the change | | Account block and unblock | Records when funds were made unavailable or available again | | Refunds | Includes the `reason` you supplied | | Card lifecycle actions | Activate, freeze, replace, and close | Each entry is tied to the credential that made the request. This is why [one client per deployment target](/docs/security/encryption-and-key-management) is more than just good housekeeping. If you share one client across everything, the audit trail can tell you that an action happened without telling you which of your systems did it. ### Actions taken in Cockpit Cockpit records more because a person can do things that an API credential can't: - **Reads of PII, not just writes:** Looking at a cardholder’s details is itself recorded. - **Card reveals:** Each one requires step-up authentication and is tied to a named user. - **Role and permission changes:** These can change who has access to sensitive data and actions. - **Support access by AletaPlanet staff:** If our support team needs to access or act on your organization, that appears in your audit record as well as ours. Check this during an evaluation rather than assuming it works this way. There’s a meaningful difference between a vendor where staff access is visible to you and one where you simply have to take their word for it. ## The half you have to keep yourself Our audit record answers **“what happened on the platform?”** It can’t answer **“who at your company caused it?”** From our side, every request from your backend comes from your backend. ```mermaid flowchart LR U["Your user or
support agent"] -->|"your log"| B["Your backend"] B -->|"API call"| A["AletaPlanet
our record"] A -.->|"Aleta-Request-Id"| B ``` The two sides are joined by `Aleta-Request-Id`, which is returned on **every** response, including successful ones. For sensitive actions, make sure you record these on your side: | Record | For | | --- | --- | | Which of your users triggered a card reveal, and the step-up result | Answering “who saw this number?” | | Which staff member or system submitted each KYC attestation | We record that your organization made the assertion; only you know who actually submitted it | | The ticket or case reference behind support-initiated actions | Connecting an event to the reason it happened | | The `Aleta-Request-Id` for all of the above | Joining your records to ours | ## Immutability Audit entries are append-only. Nothing in the API or Cockpit can edit or delete an existing entry. This also applies when something is superseded. For example, a new KYC attestation replaces the previous one for current-state purposes, but both attestations remain in the audit trail. That’s what makes the trail useful, but it also means one thing is worth keeping in mind: **Anything you put in a free-text field is permanent.** The `notes` and `reason` fields are recorded exactly as provided. Don’t use them for speculation about a cardholder, or for information that belongs somewhere else. ## Retention Retention periods are set per program and are covered in the security questionnaire provided under NDA rather than published here. There are two things to keep in mind when planning your own retention: - **Your retention obligation is separate from ours.** Your regulator determines what you need to keep. - **Set your retention floor based on the dispute window, not just operational needs.** A cardholder can raise a dispute well after a purchase, and you may still need the supporting evidence. Scheme deadlines also apply. ## Related guides - [Encryption and key management](/docs/security/encryption-and-key-management) — why credential separation matters here - [Contacting support](/docs/support/contacting-support) — where `request_id` is required - [Go-live checklist](/docs/get-started/go-live-checklist) — the audit-related items --- ## Encryption and key management Your integration deals with four kinds of key material. Each one has a different owner, lifetime, and failure mode if it's handled badly. This page covers the keys **you** hold or generate. Our internal key management — hardware security modules, key-encrypting keys, and rotation schedules for data at rest — is covered in the security questionnaire provided under NDA. Those details can change, so we'd rather keep the current answers there than let stale information live in the docs. ## Keys | Key | Who generates it | Lifetime | Rotation | | --- | --- | --- | --- | | **OAuth client key pair** | You | Until you remove it | Up to three registered at once | | **Webhook signing secret** | Us | Until retired | Two active, both signatures sent | **Every rotation uses an overlap period.** You don't need a flag day or any downtime. If your rotation runbook requires both sides to coordinate at an exact moment, you're probably making the process more complicated than it needs to be. ## Credentials ### OAuth client keys You generate the key pair. The public key is registered against a client in Cockpit as a JWK Set. The private key stays with you and never reaches us. There's nothing to copy out of Cockpit later and nothing we can recover for you — we never had the private key in the first place. Rotation is done with an overlap: a client can have up to three public keys registered at the same time. Add the new key first, switch over to it, then remove the old one. See [rotating a key](/docs/get-started/authentication#rotating-a-key) for the procedure. Revocation is immediate. Revoke the client and its tokens are refused on the next request with `401 invalid_access_token`. See [revoking a client](/docs/get-started/authentication#revoking-a-client). A few practical rules, roughly in the order people tend to get them wrong: 1. **Never put private keys in client-side code.** A leaked private key can issue cards. Don't put one in a mobile binary, browser bundle, or repository. 2. **Use one client per deployment target.** Give each client a useful label. If one is compromised, you should be able to revoke it without taking down every service you run. 3. **Keep keys in a secrets manager or environment variable.** Don't check them into a config file. 4. **Set your own rotation schedule.** Keys don't expire automatically. Every 90 days is a reasonable default, and there's no downside to rotating them. See [authentication](/docs/get-started/authentication). ## Webhook signing secret The webhook signing secret is symmetric and uses HMAC-SHA256. It's how you verify that a webhook actually came from us rather than someone who happened to find your endpoint. Rotation is where the implementation detail matters. During a rotation, the header contains more than one signature: ```http Aleta-Signature: t=1785508332,v1=5257a869…,v1=9b41f2c0… ``` While the old and new secrets overlap, we sign each delivery with **both** and include two `v1` values. Your verifier should accept the delivery if any valid `v1` signature matches. Handle multiple `v1` values from the start. That way rotation doesn't require a code change. See [signature verification](/docs/webhooks/signature-verification). ## Transport Use TLS 1.2 or newer on every endpoint. TLS 1.3 is preferred. Plain HTTP is refused rather than redirected. A redirect doesn't help if the credential has already been sent over an unencrypted connection. Webhook endpoints must also present a certificate that validates against a public root. Self-signed certificates are rejected when the endpoint is configured, rather than letting the problem show up later as failed deliveries. ## Suspected compromise If you think a credential has leaked: 1. **Revoke it first.** Revocation is immediate and doesn't require us. Do this before starting the investigation. 2. **Work out what was exposed.** Find out which service had the credential, what it could access, and what might have been done with it. 3. **Tell us.** Email it@aletaplanet.com with the client label and whatever you know. See [responsible disclosure](/docs/security/responsible-disclosure). The order matters. There's not much value in investigating a leaked credential while it's still valid. ## Related guides - [Authentication](/docs/get-started/authentication) — keys, scopes, and failures - [Signature verification](/docs/webhooks/signature-verification) — the rotation code path --- ## Security overview When you’re wiring this up, one thing people usually want to understand first is where the compliance boundary sits. Here’s the basic picture. ## The short version When you create a card, the response includes `last_four` and nothing more. Retrieving the full card number or CVV is a separate operation with its own permission and audit trail. This keeps your infrastructure outside PCI DSS cardholder-data scope. If you build a passthrough that decrypts card data on your servers, your infrastructure comes back into scope. It’s worth making that choice deliberately rather than discovering it later. ## Credentials The API uses the authentication controls below. Some of the surrounding controls are available on the API today, while others are part of the wider platform build. We call out that distinction clearly so you know what to say when completing a security questionnaire. **On the API surface now** | Control | Detail | | --- | --- | | OAuth 2.0 client credentials | `private_key_jwt` assertions are exchanged for opaque fifteen-minute tokens. There is no shared secret; we only hold public keys | | Key rotation | Keep up to three keys per client. Add the new key, move traffic across, then remove the old one — no downtime or coordination needed | | IP allowlisting | Organization-wide network policy plus a per-client allowlist, enforced on every API call and token request | | Scoped permissions | `cards:secrets` is not granted by default | | Webhook signatures | HMAC-SHA256, with both signatures sent during a rotation overlap | ## Cardholder PII The API returns cardholder details only to credentials authorized to read that cardholder. The boundary here is access control rather than field-level masking. Keep credentials scoped to the job they need to do. For example, a reporting service generally shouldn’t use credentials that can also read cardholder records. Some sensitive information deliberately has no field in the API, including identity documents, PANs, and PIN digits. Details about storage, including encryption at rest, data residency, and retention, are covered in the security questionnaire we provide under NDA rather than published here. ## Transport We require TLS 1.2 or later, with TLS 1.3 preferred. Plain HTTP requests are refused rather than redirected. By the time a redirect happens, credentials may already have crossed the network in the clear. Webhook endpoints must also present a certificate that validates against a public root. Self-signed certificates are rejected when you configure the endpoint. ## Webhook authenticity Every webhook delivery is signed with HMAC-SHA256 over the timestamp and raw request body. Checking that signature lets you confirm that an event really came from us rather than someone who happens to know your endpoint URL. See [signature verification](/docs/webhooks/signature-verification). ## In this guide * **[PCI DSS scope](/docs/security/pci-dss-scope)** — where the boundary sits and the decision that can move it * **[Encryption and key management](/docs/security/encryption-and-key-management)** — the keys you handle and how each one rotates * **[Audit logs](/docs/security/audit-logs)** — what we record and what you’ll need to record on your side * **[Responsible disclosure](/docs/security/responsible-disclosure)** — how to report a vulnerability --- ## PCI DSS scope "Does integrating with you put us in PCI DSS scope?" is usually one of the first questions teams ask when looking at a card issuing platform. The short answer: **not if card details stay protected until they reach the cardholder, and your servers never hold the key or handle the readable details.** This page explains where that line sits and what can move it. It covers the underlying principles, not specific API calls, so it should stay relevant as the API evolves. ## The boundary ```mermaid flowchart LR subgraph device["Cardholder's device"] D1["Sees card details
Enters the PIN"] end subgraph you["Your systems — outside cardholder-data scope"] Y1["Card and cardholder records
Masked details only"] Y2["Passes along protected values
Cannot read them"] end subgraph us["Our systems — inside the processor's PCI environment"] A1["Full card number · CVV · PIN"] end A1 -->|Protected for the cardholder| Y2 Y2 -->|Passes them along| D1 D1 -->|PIN encrypted so only we can read it| Y2 Y2 -->|Passes it along| A1 Y1 --- Y2 ``` Full card numbers, CVVs, and PINs stay within the processor's PCI DSS environment. When a cardholder needs to view their card details, those details are sent in a protected form that only their device can read. PINs travel the other way. The cardholder's device encrypts the PIN so that only we can read it, and your servers only pass along something they can't read. Your application only gets masked details and protected values it can't read. **The key point is that your servers never see the actual card details in plain text. The cardholder's device does.** ## Why the boundary holds The platform is designed to keep sensitive card details out of your hands in the first place: - **Regular API responses don't include full card numbers, CVVs, or PINs.** You only see masked details in ordinary records. - **Viewing full card details is a separate, permissioned, and audited action.** The values are returned in a protected form for a key you control, rather than as readable text. - **A PIN is protected before it reaches your systems.** The device encrypts it for us, so what your servers hold can't be read by them. - **Identity verification uses the results from your identity provider.** You don't need to receive the underlying identity documents through this flow. The idea is simple: if the platform never gives your application a readable card value, your application can't accidentally expose one. That's a stronger safeguard than just promising not to store sensitive data. ## The one decision that changes the scope It comes down to two things: **where the decryption key lives and where the readable card number is handled.** If your servers only pass around protected values they can't read, those values aren't exposed to your application in plain text. But as soon as a server holds the key and decrypts the card number, that server is handling cardholder data and can bring systems into scope. The same principle applies to PINs. They stay out of scope only while they stay encrypted. As soon as one of your servers handles a PIN as readable text, that server is in scope. It's worth making these decisions during implementation, rather than waiting until an auditor asks how the integration works. ### Which side of the line are you on? | If your product… | Cardholder data in your systems | | --- | --- | | Issues cards and shows masked details | **None from those details alone.** Masked details aren't cardholder data. | | Reads protected card details only on the cardholder's device | **None on your servers.** They only handle values they can't decrypt. | | Decrypts card details inside a hardware security module (HSM) without exposing the number | **Depends on the design and your assessor's evaluation.** The HSM and relevant systems handling the data may be in scope. | | Encrypts the PIN on the cardholder's device and only relays it | **None on your servers.** They only handle a value they can't read. | | Handles the PIN as readable text on your servers | **Yes.** Those servers handle a PIN and are in scope. | | Decrypts protected card details on your servers | **Yes.** Those servers handle cardholder data. | | Displays the readable number in a screen your own code can capture | **The relevant parts may be in scope.** Your code and systems can access the card data. | The first two are the designs we recommend. The others can work, but they need a closer look at the impact on your PCI DSS scope. These are general design guidelines, not a guarantee of scope exclusion. Your final scope depends on the actual implementation, connected systems, and assessment of your environment. ## What you're still responsible for Being outside the cardholder-data scope doesn't mean you can stop thinking about security. There are still a few things to get right. - **Protect cardholder personal information.** Names, addresses, dates of birth, and identity documents still need to be handled securely and in line with your own regulatory obligations. - **Keep readable card details out of logs.** Don't put them in application logs, traces, or support tickets. Crash reporters and analytics SDKs can also capture screen content or view hierarchies, so check what they collect. - **Mask card details in internal tools.** If an admin screen shows full card details, the systems involved may be in scope, regardless of how the data got there. - **Protect your API credentials.** A leaked production key could let someone issue cards or perform other sensitive actions. Treat it like any other production secret. The goal is to keep readable card details away from your systems wherever possible. That makes the boundary easier to maintain and gives you less sensitive data to protect. --- ## Responsible disclosure Found a security vulnerability? We want to hear about it. If you report it in good faith and follow the rules below, we will not take legal action against you for the report. ## Reporting Email **it@aletaplanet.com**. Please include: - What you found and why it matters - Steps to reproduce it, ideally with a proof of concept - Any `Aleta-Request-Id` values from requests that show the issue - Whether you want to be credited ## What we commit to | | | | --- | --- | | Acknowledge your report | Within 2 business days | | Initial assessment | Within 5 business days | | Updates while we work | At least every 10 business days | | Credit in our advisory | If you want it | ## Scope **In scope** - API endpoints - Cockpit Dashboard - Developer Documentation - Published SDKs - Anything that crosses a tenant boundary, exposes cardholder data, or bypasses authentication or authorization **Out of scope** - Denial of service, volumetric testing, or anything that could affect availability for other users - Social engineering of staff, customers, or cardholders - Physical attacks - Automated scanner findings with no demonstrated impact - Missing security headers with no exploitable consequence - Software version reports without a working exploit - Anything that requires a compromised device or someone deliberately acting against their own interests ## Rules 1. **Use your own sandbox account.** Sandbox access is free and self-serve. There is no reason to access someone else's account or data. 2. **Don't access, change, or keep data that isn't yours.** If you happen to see real cardholder data, stop testing and tell us what happened in your report. 3. **Don't disrupt the service.** No load testing or production fuzzing. 4. **Give us time to fix the issue** before making it public. We aim for 90 days, but we'll let you know if something needs more time and why. 5. **One issue per report**, unless multiple issues need to be chained together for the same attack. ## Bug bounty We don't currently run a paid bug bounty program. Valid reports will be credited in our security advisory if you want credit, and you'll have our thanks. If we start a paid program in the future, we'll announce it to registered technical contacts. ## Reporting a compromised credential If you think one of **your** private keys has leaked, don't wait for the disclosure process. Revoke the client immediately in Cockpit. Revocation takes effect instantly. Then email **it@aletaplanet.com** with the client label and whatever you know about the leak. --- ## API versioning and deprecation The full policy is in [base URL and versioning](/api-reference/conventions/base-urls-and-versioning). This page covers the practical stuff: what can change without warning, what needs a new version, and what you need to do in your integration. ## Build for additive change The major version is in the path (`/v1`). Within a major version, we can add things without announcing each change. Your integration should be able to handle: - new fields in a response - new values in an existing enum - new webhook event types - new error `code` values within an existing error `type` - new optional request parameters Two of these cause problems more often than the others. **Unknown enum values.** A client that exhaustively switches over a status and throws on the default branch will break when we add a new value. Treat an unknown value as unknown, rather than as an error. **Unknown event types.** Your webhook handler should return `200` for an event type it doesn't recognise. A `4xx` response causes the delivery to be retried, and enough retries can disable your endpoint. ## What gets a new version Anything that could break an existing integration gets a new major version. That includes removing or renaming a field, changing a field's type or meaning, making an optional parameter required, or removing a webhook event type. These changes won't happen inside `/v1`. ## Deprecation When we deprecate an endpoint, it keeps working for a while and starts telling you about it: ```http Deprecation: true Sunset: Sat, 31 Jul 2027 23:59:59 GMT ``` We give 12 months' notice before removing an endpoint or field, and 24 months' notice before a major version reaches end of life. Security-related removals may happen faster. If that happens, we'll communicate with you directly. If you only alert on one response header from this API, make it `Sunset`. ## Pinning behaviour `Aleta-Version` lets you pin behavioural defaults to a specific dated release within the major version: ```http Aleta-Version: 2026-07-28 ``` Additive changes can still reach you. If you leave the header out, you get the current release. Webhook payloads keep track of their version separately. Each event includes the `api_version` it was serialized with, so replaying an event from last month gives you the same payload shape it had then. ## Related - [Base URL and versioning](/api-reference/conventions/base-urls-and-versioning) — the full policy. - [Card issuing events](/docs/webhooks/events-card-issuing) — every event type and payload. --- ## Contacting support ## Include the request ID. Always. Every API response includes a request ID. You’ll find it in the `Aleta-Request-Id` header and, when a request fails, in `error.request_id`: ```http Aleta-Request-Id: req_01HX8ZQ2K4M7P9RSTV ``` **Please include this ID whenever you contact support.** It lets us find the exact request, along with its body, response, and downstream trace. Without it, we’ll usually need to ask a few questions first to work out which request you’re referring to. That can turn a quick investigation into a longer back-and-forth. We recommend logging `Aleta-Request-Id` for every response, including successful ones. The request you need help with isn’t always the one that returned an error. ## What to include | | | | --- | --- | | `request_id` | **Required** | | Environment | Sandbox or production | | What you expected | One sentence is enough | | What happened | The actual response, verbatim | | When | Timestamp with timezone | | How often | Once, intermittently, or every time | Please don't include a full card number, CVV, PIN, or access token in a support ticket. If you’ve exposed a credential, revoke it in Cockpit first, then let us know. ## Before raising a ticket A couple of quick checks can often help you find the answer without waiting for support. **Request logs in Cockpit** (Developers → Request logs) show the full request and response for every API call. You can filter by endpoint, status, and request ID. If you’re trying to figure out why a call failed, this is a good place to start. **Webhook delivery logs** (Developers → Webhook delivery logs) show every delivery attempt, the response from your endpoint, and let you replay a delivery manually. If an event seems to be missing, check these logs first. You can see whether the event was sent and what happened when it reached your endpoint. ## Where to raise it | Channel | Use for | | --- | --- | | Cockpit → Support | Most support questions. Your organization context is attached automatically | | **[it@aletaplanet.com](mailto:it@aletaplanet.com)** | Vulnerabilities — see [responsible disclosure](/docs/security/responsible-disclosure) | | Your account contact | Commercial matters, program configuration, or limit increases | If you have a production incident affecting live cardholders, use the escalation path agreed during onboarding rather than submitting a standard support ticket. --- ## FAQ ## Getting started **Is there a separate sandbox URL?** No. There's one base URL, `https://api.aletaplanet.com`, for both environments. Your token tells us which environment you're using: `at_test_…` is sandbox and `at_live_…` is production. See [environments](/docs/get-started/environments). **How do I get production access?** Your account contact can enable it after business verification and program setup. Until then, production calls return `403 production_not_enabled`. Make sure you've worked through the [go-live checklist](/docs/get-started/go-live-checklist) first. ## Cardholders and cards **Can I change a cardholder's type later?** No. The type is set when the cardholder is created and can't be changed later. **Why can't I issue a card?** The cardholder needs an approved KYC outcome, an address, and an identity document. The identity document can only be set when the cardholder is created. If it's missing, you'll need to create a new cardholder record. **Can I get the full card number from the API?** Yes. `POST /v1/cards/{card_id}/secrets` takes a public key from you and returns the card number and security code encrypted to it. Only the holder of the matching private key can read them. If the key stays on the cardholder's device, your servers only relay ciphertext and stay out of PCI DSS cardholder-data scope. A server that decrypts the number is in scope. See [displaying PAN and CVV securely](/docs/products/card-issuing/cards/displaying-pan-and-cvv-securely). ## Endpoints and errors **An endpoint in the spec returns 404. Why?** Routes are only available when the processor behind your program supports them. For example, if your processor doesn't report transactions, `GET /v1/transactions` won't be available even though it's in the API spec. See [troubleshooting](./troubleshooting). **Is it safe to retry a failed request?** With an `Idempotency-Key`, yes. Without one, retrying after a timeout could create a second card or a second debit. Send an idempotency key with every write. See [idempotency](/api-reference/conventions/idempotency). **What are the rate limits?** Sandbox allows 100 requests per minute. Production starts at 1,000 requests per minute, with the final limit set during program setup. Limits apply per client and are also tracked per organization. See [rate limits](/api-reference/conventions/rate-limits). ## Webhooks **Should I poll instead?** No. Polling endpoints are rate-limited well below what you'd need for regular polling, and some state is only delivered through events. **Where do I register an endpoint?** In Cockpit, go to Developers → Webhooks. There's currently no API for registering an endpoint. **Can I receive the same event twice?** Yes. You should expect duplicate deliveries and deduplicate them using `event.id`. See [idempotent event handling](/docs/webhooks/idempotent-event-handling). **Do events arrive in order?** No. Events can arrive out of order. Use `created_at` from the envelope and ignore anything older than the state you already have. See [retries, backoff and ordering](/docs/webhooks/retries-backoff-and-ordering). **My endpoint stopped receiving events.** Twenty consecutive failed deliveries will disable the endpoint. Re-enable it in Cockpit, then use the API to reconcile any missing state. Events aren't queued while the endpoint is disabled. ## Security **Can I restrict access by IP?** Yes, per OAuth client, in Cockpit under Developers → OAuth clients. A call from an address that isn't on the list is refused with `403 ip_not_allowed`. **How do I report a vulnerability?** Email [it@aletaplanet.com](mailto:it@aletaplanet.com). See [responsible disclosure](/docs/security/responsible-disclosure). **I leaked a credential.** Revoke it in Cockpit first, then let us know. Don't wait for a reply before revoking it. --- ## Troubleshooting Start in Cockpit under Developers → Request logs. You’ll find the full request and response for every call there, so you can usually confirm what went wrong in one place. Every response includes an `Aleta-Request-Id`. Log it, including for successful requests. It’s the first thing support will ask for. ## Authentication and permissions | You see | Cause | | --- | --- | | `401 missing_credentials` | There’s no `Authorization` header, or it isn’t in the `Bearer ` format | | `401 invalid_access_token` | The token is expired, revoked, or for the wrong environment. Tokens are short-lived, so fetch a new one instead of caching it for hours | | `403 insufficient_scope` | The client doesn’t have the required scope. Check it in Cockpit → Developers → OAuth clients | | `403 environment_mismatch` | An `at_test_` token is being used against production data, or the other way around | | `403 ip_not_allowed` | The caller’s IP address isn’t on the client’s allowlist | | `403 production_not_enabled` | Production access hasn’t been enabled for your organization yet | ## 404 on an endpoint that exists There are two common causes, and the error tells you which one you’re dealing with. `404 not_found` with no resource name means the route isn’t mounted. Routes are only available when the processor behind your program supports them. For example, `GET /v1/transactions` returns `404` for a program whose processor doesn’t report transactions, even though the endpoint is included in the spec. `404 _not_found` means the route is working, but the ID isn’t. Usually, the resource belongs to another organization or environment. ## Something is refused with a 422 | You see | Fix | | --- | --- | | `program_not_provisioned` | The program isn’t set up on this deployment. Contact your account contact | | `operation_unsupported` | The operation isn’t available on this program’s processor | | `kyc_not_approved` | Make sure the cardholder has an approved KYC outcome before issuing | | `identity_document_required` | Set `identity_document` when creating the cardholder. It can’t be added later | | `address_required` | Add an address with `PATCH /v1/cardholders/{id}` | | `kyc_identity_required` | This program’s processor handles its own verification. Send `identity` on the attestation | | `currency_mismatch` | The account currency and the request currency don’t match | | `card_invalid_state` | The card is frozen or closed. Read the card first to check its current state | | `unsupported_country` | The country isn’t enabled for the program | ## Duplicates and conflicts | You see | Cause | | --- | --- | | `409 idempotency_key_reuse` | The same `Idempotency-Key` was used with a different request body. Use a new key for the new request | | `409 idempotency_request_in_progress` | The first request using that key hasn’t finished yet. Wait and retry with the same key | | `409 cardholder_already_exists` | A cardholder with that `reference` already exists | ## Webhooks aren’t arriving Check Developers → Webhook delivery logs first. The logs show whether we tried to deliver the event and what happened. | What the log shows | Cause | | --- | --- | | Nothing for that event | The endpoint isn’t subscribed to that event type, or it was disabled when the event happened | | A transport error | Your host can’t be reached from the internet, or the TLS connection failed | | `signature does not match` from your handler | Something parsed and re-serialized the request body. Verify the raw bytes instead | | Repeated non-`2xx` | Your handler is failing. The response body is captured in the log | | Endpoint marked disabled | There were 20 consecutive failures. Re-enable it, then reconcile the gap through the API. Nothing was queued while the endpoint was disabled | See [retries, backoff and ordering](/docs/webhooks/retries-backoff-and-ordering) for more detail. ## Rate limits and upstream failures `429 rate_limited` includes `RateLimit-Reset`, which tells you how many seconds to wait. Back off for that long instead of retrying immediately. Limits apply per client and are also tracked per organization, so adding more clients won’t increase the limit. `502 processor_unavailable` means the card processor is unavailable, not that there’s a problem with our API. It’s safe to retry with the same `Idempotency-Key`. The key prevents a retry from creating a second card or debit. `500 internal_error` means something went wrong on our side. Send us the `request_id` when you contact support. ## Related - [Error code catalog](/api-reference/errors/error-code-catalog) — every error code. - [Contacting support](./contacting-support) — what to include in a support ticket. --- ## Balances: ledger vs available Tell the three balances apart, and know which one belongs in your interface. ## Before you begin Nothing to set up — but this page is worth reading before you build a balance display rather than after. Getting it wrong is the single most common source of support tickets on a new card program, and the symptom appears only once real authorizations start flowing. ## How it works Every account carries three figures: | Balance | Meaning | | --- | --- | | `ledger_balance` | Money that has **settled**. What the account actually holds | | `held_balance` | Money **reserved** by approved authorizations that have not yet cleared | | `available_balance` | `ledger_balance − held_balance`. What can still be spent | The relationship is arithmetic and always holds: ``` available_balance = ledger_balance − held_balance ``` ### The rule > **Show `available_balance` to end users. Reconcile against `ledger_balance`.** An interface built on `ledger_balance` shows money the cardholder cannot spend, because it ignores every hold placed earlier that day. The cardholder sees S$250, gets declined at S$220, and calls support. Nothing is broken — the display was answering a different question from the one being asked. Conversely, reconciling against `available_balance` is also wrong: holds are provisional and can vanish without any money moving. Reconcile on settled money only. ## A worked example One account, followed through a complete authorization lifecycle. Amounts are minor units, as the API returns them; the currency is SGD throughout. ### Step 1 — Fund the account with S$250.00 ```bash POST /v1/accounts/{id}/credit { "amount": { "amount": 25000, "currency": "SGD" } } ``` | | ledger | held | available | | --- | ---: | ---: | ---: | | After funding | **25000** | 0 | **25000** | Nothing surprising yet. This is the state most balance displays are built and tested against, which is why the bug ships. ### Step 2 — Cardholder buys coffee, S$42.50 The merchant requests authorization. We approve it and place a hold. | | ledger | held | available | | --- | ---: | ---: | ---: | | After authorization | 25000 | **4250** | **20750** | **No money has moved.** `ledger_balance` is untouched. The cardholder can now spend S$207.50, not S$250.00. A display reading `ledger_balance` still says S$250.00 here. That is the bug, and it is invisible until someone tries to spend the difference. ### Step 3 — The merchant clears, at S$46.00 Next day, a tip is added and the merchant submits the transaction for real. The hold is released and an actual debit is posted — **for a different amount than was authorized.** | | ledger | held | available | | --- | ---: | ---: | ---: | | After clearing | **20400** | **0** | **20400** | `25000 − 4600 = 20400`. The S$42.50 hold released in full; the S$46.00 charge posted against the ledger. This is normal. Restaurants, fuel pumps, and hotels all authorize one amount and clear another. Any reconciliation that assumes authorized equals cleared will break on the first tip. ### The whole sequence | Event | ledger | held | available | Money moved? | | --- | ---: | ---: | ---: | --- | | Opening | 0 | 0 | 0 | — | | Credit S$250.00 | 25000 | 0 | 25000 | Yes | | Authorize S$42.50 | 25000 | 4250 | 20750 | **No** | | Clear at S$46.00 | 20400 | 0 | 20400 | Yes | ```mermaid sequenceDiagram autonumber participant M as Merchant participant A as AletaPlanet participant Y as You Note over A: ledger 25000 · held 0 · available 25000 M->>A: Authorization request 4250 A->>A: Place hold A-->>Y: authorization.approved Note over A: ledger 25000 · held 4250 · available 20750 Note over M,A: Next day M->>A: Clearing 4600 A->>A: Release hold, post debit A-->>Y: transaction.settled Note over A: ledger 20400 · held 0 · available 20400 ``` ## Two edge cases worth knowing **Holds that never clear.** An authorization that is neither cleared nor reversed eventually expires, and the hold is released with no money moving. Until then those funds are unavailable — which is the usual explanation when a cardholder insists money is missing after cancelling an online order. `authorization.expired` fires when it resolves. **`available_balance` can go negative.** If a transaction clears for **more** than it was authorized for, and the difference exceeds what is left, the account goes overdrawn. Take the example above with only S$44.00 funded: | Event | ledger | held | available | | --- | ---: | ---: | ---: | | Credit S$44.00 | 4400 | 0 | 4400 | | Authorize S$42.50 | 4400 | 4250 | 150 | | Clear at S$46.00 | **−200** | 0 | **−200** | Handle a negative balance in your interface. Do not assume the field is unsigned, do not clamp it to zero, and do not crash on it. Subsequent authorizations decline with `insufficient_funds` until the account is credited. ## Verifying Read the account at each step and assert on all three fields: ```bash curl "$ALETA_BASE_URL/v1/accounts/acct_01HX8ZQ2K4M7P9RSTVWXYZAB" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` In sandbox you can drive the whole sequence yourself — and the assertions in the middle are the ones that catch real bugs: ```bash POST /v1/simulate/authorization { "card_id": "...", "amount": { "amount": 4250, "currency": "SGD" } } # assert available dropped by 4250, ledger unchanged POST /v1/simulate/clearing { "authorization_id": "...", "amount": { "amount": 4600, "currency": "SGD" } } # assert held is 0, ledger dropped by 4600 ``` An implementation that treats a hold as a debit, or that fails to release a hold on clearing, passes every single-call test and fails this one. See [sandbox and test data](/docs/get-started/sandbox-and-test-data). ## Common errors | Status | `code` | What it usually means | | --- | --- | --- | | `404` | `account_not_found` | Reading a balance with `GET /v1/accounts/{account_id}`: the account doesn't exist, or belongs to another organization | | `402` | `insufficient_funds` | Checked against `available_balance`, not `ledger_balance`. Held funds are not spendable | | `422` | `currency_mismatch` | Comparing or combining money across currencies. Balances are only comparable within one currency | ## Related - [Funding and defunding](./funding-and-defunding) - [Transactions](/docs/products/card-issuing/transactions/) — the authorization lifecycle in full - [Timestamps and money](/api-reference/conventions/timestamps-and-money) — why amounts are integers --- ## Creating accounts Open the account a card will spend from. ## Before you begin - A cardholder — see [cardholders and KYC](/docs/products/card-issuing/cardholders-and-kyc/). - The `program_id` of the product you are opening the account under. `GET /v1/programs` returns the ids you may use; **Settings → Programs** in Cockpit shows the same list for reading by hand. Most organizations hold one. - A currency the program supports. Nothing here rejects an unsupported one on our side — it is refused when the account is opened at the processor — so check with your account contact if you are unsure which are available. ## How it works An account holds value in exactly **one** currency, belongs to exactly **one** cardholder and exactly **one** program, and is what its card spends from. `program_id` is where a program is named. Everything drawn on the account inherits it — the cards, and the authorizations and transactions those cards produce — so no call on the account's own path asks you for it again. It is fixed at creation: there is no call that moves an account to another program. Send it even if you hold a single program. We could default to it, and that would be right until the day a second product went live, after which accounts would quietly open under the wrong one. It opens `active`, with all three balances at zero: ``` ledger_balance 0 money that has settled held_balance 0 reserved by authorizations that have not cleared available_balance 0 ledger − held, the spendable figure ``` A cardholder who needs two currencies needs two accounts, and the API never converts between them. ## Steps ### 1. Create the account Call [Create an account](/api-reference/resources/create-account). The request names the `program_id`, the `cardholder_id`, the `currency` and, if you have one, your own `reference`. Send an `Idempotency-Key` so a retry doesn't open a second account. The response is the account: `active`, in the currency you sent, with all three balances at zero. ### 2. Set `reference` while you are here `reference` is your own identifier. It is filterable, and it appears in every webhook payload for this account — which is what turns an incoming `account.funded` event into a row you can find in your own database without keeping a lookup table. [List accounts](/api-reference/resources/list-accounts) takes it as a filter. Retrofitting it later means backfilling every account you have already created. Set it now. ## Verifying Read the account back with [Get an account](/api-reference/resources/get-account), or list the cardholder's accounts with [List accounts](/api-reference/resources/list-accounts). You should see `status: "active"`, the `program_id` you sent, and three zero balances. `account.created` fires on your webhook endpoint. The list takes a `program_id` filter too, which is the shape to reach for if you run more than one product. Omit it and the page spans every program your organization holds — one credential covers all of them. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `missing_parameter` | `program_id` was not sent. It is required even when you hold one program | | `400` | `invalid_parameter` | `program_id` is malformed, or is not a program your organization holds. The two read the same on purpose — the API will not confirm that an id it refused exists somewhere else | | `404` | `cardholder_not_found` | `cardholder_id` is wrong, or belongs to another organization. The two are deliberately indistinguishable | | `409` | `idempotency_key_reuse` | Same `Idempotency-Key`, different body — usually a bug in retry logic | | `422` | `program_not_provisioned` | The program is real and yours, but cannot serve this request. A configuration matter on our side — raise it rather than retrying | Note what is **not** in that list: there is no `kyc_not_approved`. That error belongs to issuing and activating a card, not account creation. ## Related - [Funding and defunding](./funding-and-defunding) - [Balances: ledger vs available](./balances-ledger-vs-available) --- ## Funding and defunding Add value to an account, and take it back out. ## Before you begin - An account — see [creating accounts](./creating-accounts). - An `active` card on the account. Value can only be added or removed while the card is `active`; in any other status the call is refused with `card_invalid_state`. - An understanding of the three balances. If you have not read [balances: ledger vs available](./balances-ledger-vs-available), read it first; the difference determines whether a debit succeeds. :::danger Idempotency is not optional here These two endpoints move money. A retry without an `Idempotency-Key` — after a timeout, a dropped connection, a pod restart mid-request — credits the account twice. Generate the key **once, before the first attempt**, and reuse it for every retry of that same logical operation. A key derived from your own primary key, such as `topup-8841`, survives a process restart in a way a fresh UUID does not. See [idempotency](/api-reference/conventions/idempotency). The key is forgotten after 24 hours. For the movement itself, also send `client_transaction_id` — your own id for this credit or debit, unique per account, never expiring. A repeat with the same id and amount returns the account unchanged with `Idempotent-Replay: true`; a different amount under the same id is refused. `reference` is a label, not an identifier: it carries no uniqueness rule. ::: ## How it works Two operations, mirroring each other: | | Effect on `ledger_balance` | Effect on `available_balance` | | --- | --- | --- | | `POST /credit` | increases | increases | | `POST /debit` | decreases | decreases | Neither touches `held_balance` — holds belong to authorizations, not to you. **A debit is checked against `available_balance`, not `ledger_balance`.** Money reserved by an outstanding authorization cannot be swept out from under it. An account showing S$250 settled but S$60 held will refuse a S$200 debit with `insufficient_funds`, and that is correct: S$60 of that S$250 is already spoken for. ### What this does and does not cover These endpoints move value **on the ledger**. They are the record of what a cardholder can spend. How real money reaches the pooled account backing that ledger — a bank transfer, a payout batch, a collection from your treasury — is arranged per program and is not an API call. Talk to your account contact about the funding rail for your program before you go live; the API side is the easy half. ## Steps ### 1. Credit an account Call [Credit an account](/api-reference/resources/credit-account) for the account. The request carries the amount, an optional description and reference, and your own `client_transaction_id`. Send an `Idempotency-Key` as well, as described above. Amounts are **integer minor units** in the account's currency. S$250.00 is `25000`, and the currency must match the account's. The response is the account in its new state, so you do not need to read it back. ### 2. Debit an account Call [Debit an account](/api-reference/resources/debit-account). It takes the same request, in the opposite direction. Use it to sweep an unspent balance back, correct an over-credit, or empty an account before retiring it. A debit is checked against `available_balance`, not `ledger_balance` — funds held by pending authorizations are not yours to take back. Too large a debit fails with `insufficient_funds` and a `402`. The check and the write are one operation, so an authorization landing between them cannot overdraw the account. ### 3. Read the funding history Credits and debits appear as transactions of type `adjustment`. Call [List transactions](/api-reference/resources/list-transactions) filtered by the account. Every credit and debit is one entry, and the balance you read back is the sum of them. A duplicate request carrying the same `Idempotency-Key` posts once, and so does one carrying the same `client_transaction_id` — however long ago the first was. ## Verifying `account.funded` fires on credit and `account.updated` on debit. There is no low-balance event: poll `available_balance` from your own scheduler if you auto-fund, at whatever cadence your float can tolerate. Read the account back and check that `available_balance` moved by exactly the amount you sent. If it moved by a different amount, something else touched the account concurrently — an authorization clearing, most likely. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `402` | `insufficient_funds` | `available_balance` is too low. Note this is not `422` | | `422` | `currency_mismatch` | The `amount.currency` differs from the account's currency. There is no conversion | | `422` | `account_blocked` | Unblock the account first | | `400` | `invalid_parameter` | Usually a float or a major-unit amount. `25000`, not `250.00` | | `409` | `idempotency_key_reuse` | Same key, different body. Almost always a retry-logic bug worth fixing rather than working around | | `409` | `adjustment_already_applied` | Same `client_transaction_id`, different amount or direction. The first movement stands; the message says what it was | :::warning `insufficient_funds` is never retryable Retrying it will fail identically until the balance changes. Credit the account, then retry — with a **new** idempotency key, since it is a new operation. ::: ## Related - [Balances: ledger vs available](./balances-ledger-vs-available) - [Idempotency](/api-reference/conventions/idempotency) - [Timestamps and money](/api-reference/conventions/timestamps-and-money) --- ## Accounts & funding An account holds value. It has exactly one currency, belongs to one cardholder, and is what a card spends from. ## The three balances This is the part that most often gets built wrong, so it is worth stating plainly: | Balance | Meaning | | --- | --- | | `ledger_balance` | Money that has settled | | `held_balance` | Money reserved by approved authorizations that have not cleared | | `available_balance` | `ledger_balance − held_balance` — **the spendable figure** | **Show `available_balance` to end users. Reconcile against `ledger_balance`.** An interface built on `ledger_balance` displays money the cardholder cannot actually spend, because it ignores holds placed by authorizations earlier the same day. The result is a support ticket every time someone is declined while the app says they have funds. Holds are not debits. An approved authorization moves nothing; it reserves. The debit happens at clearing, typically the next day, and can be for a **different amount** — a restaurant tip is the everyday case. ## Account status An account is `active` or `blocked`. Blocking is reversible, does not touch the balances, and makes every card funded by the account decline with `account_blocked` until it is lifted. ## In this section - **[Creating accounts](/docs/products/card-issuing/accounts-and-funding/creating-accounts)** — opening an account against a cardholder, and why you do not need approved KYC to do it - **[Funding and defunding](/docs/products/card-issuing/accounts-and-funding/funding-and-defunding)** — moving value in and out, idempotently - **[Balances: ledger vs available](/docs/products/card-issuing/accounts-and-funding/balances-ledger-vs-available)** — the full model, with a worked example through a complete authorization lifecycle --- ## Creating a cardholder Create the record everything else references. ## Before you begin - A sandbox or production access token — see [authentication](/docs/get-started/authentication). - Nothing else. A cardholder is the first object you create, and it needs no prior verification — see below. :::tip Create first, verify after Creating a cardholder does **not** require verification. The record exists immediately with `kyc_status: not_started`, and you can open accounts and even issue cards against it — provided it has an `address` and an `identity_document`, which issuance needs but creation does not. What verification gates is **card activation**. Creating early means the record, the account, and the card are all in place the moment you report a passing outcome, rather than serializing four steps. ::: ## How it works A cardholder is the spine of the model: accounts reference it, cards reference it, and nothing can be issued without one. See [core concepts](/docs/products/card-issuing/core-concepts). `type` is `individual`. Business or company card will be supported in the future. `type`, `first_name` and `last_name` are required — a card has to be issued to somebody. Which of the rest you must supply depends on your program's configuration and on what your own verification process needs. Two fields are required not for creation but for the next step: `identity_document` and `address`. A cardholder can be created without either, but a card cannot be issued to one that lacks them. **`identity_document`** — missing it fails issuance with `identity_document_required`. Only the number and its type are held — no image, no verification result — and the number is never returned; the response reports `has_identity_document` instead. This one **cannot be added later**: it is not patchable, so a cardholder created without it must be recreated. **`address`** — missing it fails issuance with `address_required`. The card is registered to this address, so it is the cardholder's own. Unlike the document, it **can** be added later with `PATCH /v1/cardholders/{cardholder_id}`. Set both at creation unless you are recording a cardholder you will not yet issue a card to. ## Steps ### 1. Create the cardholder ```bash curl -X POST "$ALETA_BASE_URL/v1/cardholders" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "type": "individual", "first_name": "Mei", "last_name": "Tan", "email": "mei.tan@example.com", "phone": "+6591234567", "date_of_birth": "1990-04-12", "address": { "line1": "350 Orchard Road", "line2": "#12-01", "city": "Singapore", "postal_code": "238868", "country": "SGP" }, "identity_document": { "type": "national_id", "number": "S1234567D" }, "reference": "user-4471" }' ``` ```json title="Response" { "id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "cardholder", "type": "individual", "kyc_status": "not_started", "first_name": "Mei", "last_name": "Tan", "email": "mei.tan@example.com", "reference": "user-4471", "has_identity_document": true, "created_at": "2026-07-29T14:32:05Z" } ``` `kyc_status` starts at `not_started`. It moves only when you [report an outcome](./kyc-lifecycle-and-statuses). ### 2. Set `reference` `reference` is your own identifier. It is filterable and appears in every webhook payload for this cardholder: ```bash curl "$ALETA_BASE_URL/v1/cardholders?reference=user-4471" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` Without it, every incoming `cardholder.kyc_approved` needs a lookup table to map back to your user. With it, the event carries your own key. Retrofitting means backfilling every cardholder you have already created. ### 3. Field rules worth knowing | Field | Rule | | --- | --- | | `phone` | E.164, with the `+`. `+6591234567`, not `91234567` | | `date_of_birth` | `YYYY-MM-DD`. A date, not a timestamp — no `_at` suffix | | `address.country` | ISO 3166-1 **alpha-3**. `SGP`, not `SG`. Checked against the real list, so `ZZZ` is refused too | | `email` | Used for program communications where configured | Unknown fields are **rejected** with `unknown_parameter` rather than ignored, so a typo surfaces immediately instead of silently dropping data. ### 4. Updating later ```bash curl -X PATCH "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"email": "mei.tan@newdomain.example"}' ``` `PATCH` accepts contact details and `reference` only. **It cannot change `kyc_status`** — that moves solely through [reporting an outcome](./kyc-lifecycle-and-statuses), so the verification record stays a deliberate, attributable act rather than a field update. ## Verifying ```bash curl "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` Expect `kyc_status: "not_started"` and your `reference` echoed back. `cardholder.created` fires on your webhook endpoint. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `missing_parameter` | `type`, `first_name` and `last_name` are required | | `400` | `invalid_parameter` | `type` must be `individual`; or `phone` isn't in E.164 format, `email` isn't an address, or a country isn't an ISO 3166-1 alpha-3 code. `param` names the field | | `400` | `unknown_parameter` | A field name typo, or `business_name`, which is no longer accepted. Check `param` in the error | | `409` | `cardholder_already_exists` | Another cardholder in your organization already has this `email` or `phone` | | `409` | `idempotency_key_reuse` | Same key, different body | ### Errors when updating a cardholder `PATCH /v1/cardholders/{cardholder_id}` returns these as well. The fields you sent are validated first, then written, then sent to the processor if it holds them. | Status | `code` | Fix | | --- | --- | --- | | `400` | `invalid_parameter` | `email`, `phone` or `address` is malformed, or `reference` is over 128 characters. `param` names the field | | `400` | `unknown_parameter` | The field can't be changed. Name, `type` and the identity document are fixed; re-verify instead | | `404` | `cardholder_not_found` | The cardholder doesn't exist, or belongs to another organization | | `409` | `cardholder_already_exists` | Another cardholder in your organization already has this `email` or `phone` | | `422` | `address_change_requires_kyc` | This program's processor can't take an address change. Report a KYC attestation that carries the new address | | `422` | `operation_unsupported` | The processor can't take this change | | `502` | `processor_unavailable` | Your change is saved here but the processor didn't take it. Send the same change again | | `502` | `processor_permission_denied` | The program isn't approved to change this at the processor. Send us the `request_id` | ## Related - [KYC lifecycle and statuses](./kyc-lifecycle-and-statuses) - [Creating accounts](/docs/products/card-issuing/accounts-and-funding/creating-accounts) - [Core concepts](/docs/products/card-issuing/core-concepts) --- ## Identity documents Identity documents do not pass through this API. This page explains what that means for you. ## Before you begin Read [KYC lifecycle and statuses](./kyc-lifecycle-and-statuses) first. This page covers the half of verification that happens entirely on your side. ## How it works **There is no document upload endpoint, and that is a design decision rather than a gap.** Verification is yours to run. The cardholder submits their passport, license, or selfie to **your** identity provider, through that provider's own hosted flow or SDK. Those images and extracted fields stay with you and your provider. What reaches us is the [attestation](./kyc-lifecycle-and-statuses) — an assertion that verification happened, by whom, when, and with what result. ```mermaid flowchart LR CH["Cardholder"] -->|documents| P["Your identity provider"] P -->|result| Y["Your system"] Y -->|attestation only| A["AletaPlanet /v1"] A -.->|kyc_status + webhook| Y %% Emphasis is a class, not a baked colour: a hardcoded dark fill with %% white text only works in one mode. src/css/docs.css themes `.ec-emphasis`. classDef ec-emphasis stroke-width:2px class A ec-emphasis ``` Nothing crosses the boundary into `/v1` except the attestation. ### Why it is built this way Identity documents are among the most sensitive data a fintech handles — a passport image is a lifetime credential, not a resettable one. Every system that stores one inherits obligations around retention, residency, access control, and deletion. Keeping them out of our pipeline means: - **Your compliance footprint is yours**, and does not have to accommodate ours - **Data residency stays where you put it.** No copy crosses a border because our infrastructure sits somewhere else - **A breach here does not expose your cardholders' identity documents**, because we do not hold them - **Deletion requests are single-party.** You erase your copy; there is no second system to chase ### What we hold instead | We hold | We do not hold | | --- | --- | | The attestation: status, timestamp, provider, reference, method | Document images or scans | | `custIdType` / document **type**, where your program supplies it | Document numbers | | Contact details and address on the cardholder | Extracted biometric or MRZ data | | The full attestation chain over time | Anything your provider returned beyond the outcome | ## Steps ### 1. Collect documents through your provider Use their hosted flow or mobile SDK. Do not build your own capture pipeline unless you have a reason to — the providers handle liveness, tamper detection, and document parsing, which are not casual problems. ### 2. Report the outcome, not the evidence ```bash curl -X POST "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Acme Identity", "provider_reference": "chk_9f31c7", "method": "document_and_selfie" }' ``` `provider_reference` is the hinge. It is what lets someone holding our record ask you for the corresponding check, and lets you find it. ### 3. Do not send documents anyway The API has no field that accepts them. Requests carrying unrecognized fields are **rejected** with `unknown_parameter`, not silently ignored — so an attempt to attach a base64 image fails loudly rather than quietly landing somewhere it should not. :::danger `notes` is not a smuggling route `notes` exists for operational context: *"manual review by compliance, ticket 4471"*. Putting a document number, an MRZ string, a date of birth extracted from a passport, or a base64 image in there defeats the entire boundary this page describes. It is a 500-character free-text field, and it is audited like every other field. ::: ### 4. Retain your side properly We cannot tell you your retention period — it depends on your regulator, your markets, and your own policy. What we can say is what the attestation assumes: - **Retain the underlying evidence at least as long as the attestation is in force.** An attestation pointing at a check you have deleted is not evidence. - **Keep `provider_reference` resolvable.** If you change providers, keep the old records addressable rather than orphaning every historical attestation. - **Log who submitted each attestation** on your side. We record that your organization asserted it; only you know which of your staff or systems did. ## Verifying - A cardholder with a reported outcome shows the expected `kyc_status` - `GET .../kyc` returns your attestation with the provider reference intact - An attempt to send a document field returns `unknown_parameter` - Given a `provider_reference` from our record, you can locate the original check in your provider's system That last one is the real test. Do it once, deliberately, before go-live — it is exactly what an audit will ask you to demonstrate. ## What an auditor asks each side | Asked of you | Asked of us | | --- | --- | | The documents and the provider's check | The attestation chain per cardholder | | Who ran verification and under what policy | When each was recorded and by which organization | | Evidence the person matches the documents | That no card was activated without an `approved` attestation | The two halves join on `provider_reference`. Neither side is complete alone, which is why that field is worth treating as mandatory even though the API marks it optional. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `unknown_parameter` | You sent a field the API does not accept — including any attempt at a document payload | | `422` | `kyc_provider_required` | The attestation must name who verified | | `422` | `kyc_attestation_incomplete` | Missing `status` or `verified_at` | ## Related - [KYC lifecycle and statuses](./kyc-lifecycle-and-statuses) - [Re-verification and remediation](./re-verification-and-remediation) - [Security overview](/docs/security/overview) --- ## Cardholders & KYC A cardholder is the person a card belongs to, and it is the first object you create. Nothing — no account, no card — exists without one. ## What matters here Identity verification gates issuance. A cardholder whose `kyc_status` is not `approved` cannot have a card activated, and no amount of retrying the card call will change that. Design your onboarding flow around the verification state machine rather than treating it as a step that usually succeeds. **You run verification; we record what you assert.** The cardholder submits documents to your own identity provider, and you report the outcome as an attestation. Identity documents never pass through this API — see [identity documents](/docs/products/card-issuing/cardholders-and-kyc/document-upload). You read `kyc_status` and react to `cardholder.kyc_*` webhooks. The states and their meanings are in [core concepts](/docs/products/card-issuing/core-concepts). The practical rule is that `approved` is the only state that unblocks issuance, and `review_required` needs a human somewhere. **Verification is not the only gate.** Issuance also needs the cardholder to have an `address` and an `identity_document` on file — `address_required` and `identity_document_required` respectively. Unlike `kyc_status`, both are your data rather than a state machine, so the fix is a field, not a wait. ## Two things worth getting right early **Do not poll for verification.** It can take seconds or days depending on the program and the individual. Subscribe to `cardholder.kyc_approved` and `cardholder.kyc_rejected`. **Store your own identifier in `reference`.** It is filterable and appears in every webhook payload, which makes correlating an event back to your user trivial. Retrofitting it later is painful. ## In this section - **[Creating a cardholder](/docs/products/card-issuing/cardholders-and-kyc/creating-a-cardholder)** — the required fields, and why you can create before verifying - **[KYC lifecycle and statuses](/docs/products/card-issuing/cardholders-and-kyc/kyc-lifecycle-and-statuses)** — every state, what each gates, and how to report an outcome - **[Identity documents](/docs/products/card-issuing/cardholders-and-kyc/document-upload)** — why documents never pass through this API, and what you retain - **[Re-verification and remediation](/docs/products/card-issuing/cardholders-and-kyc/re-verification-and-remediation)** — expiry, `review_required`, and what happens to live cards --- ## KYC lifecycle and statuses Report the result of verification you performed, and understand what each state unblocks. ## Before you begin - A cardholder — see [creating a cardholder](./creating-a-cardholder). - A completed identity check from your own provider. **You run verification; we record what you assert.** See [documents](./document-upload) for why nothing is uploaded here. ## How it works `kyc_status` is read-only through `PATCH`. It moves only when you report an outcome, which creates an **attestation** — a dated, attributed record of who verified this person and how. ```mermaid stateDiagram-v2 [*] --> not_started: cardholder created not_started --> pending: verification begun not_started --> approved: reported approved not_started --> rejected: reported rejected pending --> approved: reported approved pending --> rejected: reported rejected pending --> review_required: needs more evidence review_required --> approved: resolved review_required --> rejected: resolved approved --> review_required: re-verification flags something approved --> rejected: revoked ``` | State | Meaning | Card activation | | --- | --- | --- | | `not_started` | No attestation exists | Blocked | | `pending` | You have started verification, no outcome yet | Blocked | | `approved` | Verified | **Permitted** | | `rejected` | Failed definitively | Blocked | | `review_required` | Needs further evidence | Blocked | **Only `approved` permits issuing or activating a card.** Everything else blocks both with `kyc_not_approved`. `not_started` cannot be reported — it is the absence of an attestation, not an outcome. ## Steps ### 1. Report an outcome ```bash curl -X POST "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Acme Identity", "provider_reference": "chk_9f31c7", "method": "document_and_selfie", "expires_at": "2028-07-29T00:00:00Z" }' ``` ```json title="Response" { "id": "kyc_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "kyc_attestation", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Acme Identity", "provider_reference": "chk_9f31c7", "method": "document_and_selfie", "expires_at": "2028-07-29T00:00:00Z", "is_current": true, "created_at": "2026-07-29T14:32:05Z" } ``` ### 2. Supply the fields that make it a record `provider` and `verified_at` are **required**, and that is deliberate. An attestation that cannot say who verified and when is a status flag, not evidence. Supply `provider_reference` too — it is what lets an auditor trace our record back to yours. | Field | Why | | --- | --- | | `provider` | The party that performed the check. Your own name if in-house | | `verified_at` | When you checked, not when you told us. May be in the past, never in the future | | `provider_reference` | Their identifier for the check. The audit trail hinge | | `method` | `document_and_selfie`, `document_only`, `database_check`, `in_person`, `other` | | `expires_at` | When re-verification falls due. See [re-verification](./re-verification-and-remediation) | :::danger Never put document contents in `notes` `notes` is for context — "manual review by compliance, ticket 4471". It is not for document numbers, images, or extracted identity data. See [documents](./document-upload). ::: ### 3. Read the attestation chain ```bash curl "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` Newest first; the first item has `is_current: true`. Reporting a new outcome supersedes the previous one without deleting it — which is what makes the chain auditable. ### 4. Expect an echo Reporting an outcome fires the matching webhook back to you: | Reported | Event | | --- | --- | | `approved` | `cardholder.kyc_approved` | | `rejected` | `cardholder.kyc_rejected` | | `review_required` | `cardholder.kyc_review_required` | :::note Yes, you receive an event for something you told us This looks circular and is intentional. Most clients have more than one system — the service that runs verification is rarely the service that issues cards — and cockpit needs to stay in sync too. Treat the event as the fan-out mechanism. Your reporting service will receive an echo of its own attestation; deduplicate on the event `id` and let your other systems act on it. ::: Gate issuance on the **event**, not on the response to your own POST. That way one code path handles both your own attestations and any status change originating elsewhere. ## Verifying - The cardholder's `kyc_status` matches what you reported - `GET .../kyc` returns the attestation with `is_current: true` - The matching `cardholder.kyc_*` webhook fired - Card activation succeeds after `approved`, and returns `kyc_not_approved` before it That last pair is the assertion worth automating. It is the actual contract. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `422` | `kyc_provider_required` | `provider` is mandatory | | `422` | `kyc_attestation_incomplete` | Missing `status` or `verified_at`, or `verified_at` is in the future | | `400` | `invalid_parameter` | `not_started` cannot be reported | | `422` | `cardholder_inactive` | The cardholder is suspended | | `404` | `resource_not_found` | Wrong cardholder id, or another organization's | ## Related - [Documents — you hold them](./document-upload) - [Re-verification and remediation](./re-verification-and-remediation) - [Card issuing events](/docs/webhooks/events-card-issuing) --- ## Re-verification and remediation Verification is not permanent. This is what happens when it lapses, needs more evidence, or has to be withdrawn. ## Before you begin - A cardholder with a current attestation — see [KYC lifecycle and statuses](./kyc-lifecycle-and-statuses). - A view on your own re-verification policy. We record what you assert; the cadence is yours. ## How it works Three situations bring a verified cardholder back into scope, and they differ in urgency: | Situation | Trigger | Urgency | | --- | --- | --- | | **Periodic re-verification** | `expires_at` approaching | Planned | | **Review required** | Something needs more evidence | Prompt | | **Revocation** | Verification must be withdrawn | Immediate | All three are handled the same way — by reporting a new attestation, which supersedes the previous one. Nothing is deleted; the chain is what an audit reads. ### The chain ```mermaid flowchart LR A1["approved
verified 2026-07
expires 2028-07"] --> A2["review_required
2028-06"] --> A3["approved
verified 2028-06
expires 2030-06"] style A3 stroke-width:3px ``` Only the newest has `is_current: true`. The earlier ones stay readable. ## Steps ### 1. Track expiry yourself Set `expires_at` when you report an outcome: ```json { "status": "approved", "verified_at": "2026-07-29T11:04:00Z", "provider": "Acme Identity", "expires_at": "2028-07-29T00:00:00Z" } ``` Then find what is falling due: ```bash curl "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` :::note We do not currently notify you before expiry There is no `cardholder.kyc_expiring_soon` event. Drive re-verification from your own scheduler against the `expires_at` values you set, the same way you would any other renewal. Omitting `expires_at` means no expiry is tracked at all — which is a valid choice if your policy has no fixed cadence, but make it deliberately rather than by forgetting the field. ::: ### 2. Re-verify and report again ```bash curl -X POST "$ALETA_BASE_URL/v1/cardholders/chd_01HX8ZQ2K4M7P9RSTVWXYZAB/kyc" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "status": "approved", "verified_at": "2028-06-14T09:22:00Z", "provider": "Acme Identity", "provider_reference": "chk_c81f04", "method": "document_and_selfie", "expires_at": "2030-06-14T00:00:00Z", "notes": "Scheduled biennial re-verification" }' ``` A **new** `provider_reference` — this is a new check, not a re-statement of the old one. Reusing the previous reference makes the chain unauditable. ### 3. Handle `review_required` Report it when your process needs more from the cardholder but has not concluded: ```bash -d '{ "status": "review_required", "verified_at": "2028-06-01T10:00:00Z", "provider": "Acme Identity", "notes": "Address proof older than 3 months, awaiting replacement" }' ``` This **blocks card activation immediately**, and fires `cardholder.kyc_review_required`. Use `notes` for operational context. Do not put the evidence itself there — see [identity documents](./document-upload). ### 4. Understand what happens to live cards This is the part worth getting right before you need it. | Reported | Effect on existing active cards | | --- | --- | | `review_required` | **No automatic change.** Cards keep working | | `rejected` | **No automatic change.** Cards keep working | | Expiry passing | **No automatic change.** Cards keep working | **Changing `kyc_status` does not freeze anything.** A cardholder whose verification you have just revoked still holds a spendable card until you act on it. That is deliberate — an automatic freeze on every `review_required` would cut cardholders off mid-transaction for what is often a paperwork issue. But it means remediation is a **two-step** process, and skipping the second step is the failure mode: ```bash # 1. Record the outcome POST /v1/cardholders/{id}/kyc { "status": "rejected", ... } # 2. Act on the cards — this does not happen for you GET /v1/cardholders/{id}/cards POST /v1/cards/{card_id}/freeze # reversible, for review POST /v1/cards/{card_id}/close # terminal, for revocation ``` Freeze for `review_required`; it is reversible and the usual outcome is that the cardholder supplies what was missing. Close for a genuine `rejected` — see [freeze, replace, close](/docs/products/card-issuing/cards/freeze-replace-close). ### 5. Restoring after remediation Report `approved` again and unfreeze: ```bash POST /v1/cardholders/{id}/kyc { "status": "approved", ... } POST /v1/cards/{card_id}/unfreeze ``` A **closed** card cannot come back. If you closed rather than froze, issue a replacement. ## Verifying - A new attestation appears with `is_current: true`, and the previous one is still readable with `is_current: false` - `kyc_status` reflects the newest outcome - The matching webhook fired - **Cards were not touched automatically** — confirm this in sandbox, because assuming otherwise is the mistake this page exists to prevent - After freezing, a simulated authorization declines with `card_not_active` ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `422` | `kyc_attestation_expired` | The attestation in force is past `expires_at`. Re-verify and report | | `422` | `kyc_provider_required` | Every attestation names who verified, including re-verifications | | `422` | `kyc_attestation_incomplete` | `verified_at` missing or in the future | | `422` | `card_closed` | Closure is terminal. Issue a replacement instead of unfreezing | ## Related - [KYC lifecycle and statuses](./kyc-lifecycle-and-statuses) - [Identity documents](./document-upload) - [Freeze, replace, close](/docs/products/card-issuing/cards/freeze-replace-close) --- ## Card lifecycle A card moves through a small number of states during its lifetime. Some states can change, while others are permanent. ## Before you begin There's nothing to set up. Read this before building anything that displays card status or lets someone take action on a card. The important distinction is between a card that's temporarily blocked and one that's permanently finished. `frozen` can be reversed; `expired` and `closed` cannot. ## How it works ```mermaid stateDiagram-v2 [*] --> pending: create [*] --> inactive: create (issued at once) pending --> inactive: card.issued inactive --> active: activate active --> frozen: freeze frozen --> active: unfreeze inactive --> expired: expiry date passes active --> expired: expiry date passes frozen --> expired: expiry date passes pending --> closed: close inactive --> closed: close active --> closed: close frozen --> closed: close expired --> closed: close expired --> [*] closed --> [*] ``` | State | Meaning | Can it authorize? | Reversible? | | --- | --- | --- | --- | | `pending` | The request was accepted and the processor has not issued the card yet. The card leaves this state by itself, usually within minutes | No | | | `inactive` | The card has been issued and is waiting for you to activate it with `POST /v1/cards/{id}/activate` | No | | | `active` | Card can be used | Yes | | | `frozen` | Card is temporarily blocked | No | Yes, by unfreezing | | `expired` | Card has passed its expiry date | No | No | | `closed` | Card has been permanently terminated | No | No | ### Every card needs to be activated Virtual and physical cards follow the same flow: `pending`, then `inactive` when `card.issued` comes in, and finally `active` once you call `POST /v1/cards/{id}/activate`. You make this call from your server, and it returns the card. There’s no hosted page — the card won't be usable until you activate it. ### The two terminal states `expired` and `closed` are final. There is no API call that can bring either state back. An `expired` card can still be closed, which is the only change left to it. Closing a card also invalidates every network token created for it, including tokens used by mobile wallets. Once a card is closed, the only way to continue using a card is to issue a replacement. ### Freeze is reversible If a cardholder has lost or misplaced their card, freeze it first. New authorizations are blocked, but the card itself remains intact and can be unfrozen later. Unfreezing takes effect immediately. Use `close` when the card is permanently finished, such as when it has been compromised, is no longer needed, or belongs to a cardholder who has left. Closing a card that only needed to be frozen means the cardholder will need a replacement and another delivery cycle. ## Common errors | Status | `code` | Meaning | | --- | --- | --- | | `422` | `card_invalid_state` | The card's status doesn't allow the operation. This covers freezing a card that isn't `active`, freezing one that is already `frozen`, unfreezing one that isn't `frozen`, activating one that is `pending`, `frozen` or `expired`, and any operation on an `expired` card except closing it. The message names the state | | `422` | `card_already_activated` | Activation was called on a card that is already `active` | | `422` | `card_closed` | The card is permanently closed, including closing it a second time. Issue a replacement | | `422` | `card_not_provisioned` | The processor hasn't issued the card yet, so the operation can't reach it. Wait for `card.issued` | These errors aren't retryable. They describe the card's current state, and retrying the same request won't change that state. ## Related - [Operations by card status](./operations-by-status) — which operations are available in each state and the errors returned when they're not - [Freeze, replace, close](./freeze-replace-close) - [Core concepts](/docs/products/card-issuing/core-concepts) - [Card issuing events](/docs/webhooks/events-card-issuing) --- ## Displaying PAN and CVV securely Let the cardholder view their full card number without your servers ever holding it in the clear. ## Before you begin - An `active` card. A card in any other status has no details to show: it is refused with `card_invalid_state`, or `card_closed` once closed. - The `cards:secrets` scope on your credential. It is not granted by default. - A place to keep a private key where card data is allowed, such as the cardholder's device (in the platform's secure key storage) or a hardware security module. The decision about where is covered below, because it is the one that decides your PCI DSS scope. ## How it works When the cardholder wants to see their card details, your app creates an RSA key pair on the device. The private key stays there. Your backend sends the public key to us in a [Reveal card secrets](/api-reference/resources/reveal-card-secrets) call, and we return the card number and security code encrypted to that key. Your backend passes the ciphertext on to the device, which decrypts it with the private key and shows the details. Your servers only ever hold ciphertext. ```mermaid sequenceDiagram autonumber participant D as Cardholder's device participant Y as Your backend participant A as AletaPlanet D->>D: create a key pair, keep the private key D->>Y: cardholder taps "show card number", sends the public key Y->>A: POST /v1/cards/{id}/secrets { public_key } A-->>Y: encrypted_pan, encrypted_cvv (ciphertext only) Y-->>D: relay the ciphertext Note over D: decrypt with the private key, show, discard ``` ## Steps ### 1. Create a key pair on the device Generate an RSA key pair of at least 2048 bits each time the cardholder asks to see their details, or once per install if you prefer. Keep the private key in the platform's secure key storage and never send it anywhere. Only the public key leaves the device, in PEM form. ### 2. Ask for the secrets when the cardholder taps Call [Reveal card secrets](/api-reference/resources/reveal-card-secrets) with the public key. Don't request it when a screen loads: a number that nobody looked at was still read. There's nothing to retry safely and nothing to cache. Each call returns a new ciphertext, the response is never cached, and the call doesn't use `Idempotency-Key`. ### 3. Decrypt on the device The response carries `encrypted_pan` and `encrypted_cvv`, base64 encoded. Both are encrypted with RSA-OAEP using SHA-256 and no label, which every common cryptography library supports. `last_four` and `expires_at` come back in the clear. ### 4. Show the details, then discard them Display the number and security code for as long as the cardholder is looking. Don't write them to storage, logs, analytics events, crash reports or support tickets, and clear them from memory when the screen closes. ### 5. Gate the reveal Showing a full card number is a sensitive action, so treat it that way: - Step up authentication immediately before requesting it. Use a biometric or passcode rather than relying only on the existing session. - Never reveal cards in bulk. Reveal one card at a time after a deliberate user action. - Every read is audited on our side and tied to the request. Log the event on your side too, using `card_id` — never a decrypted value. ## Where you decrypt decides your scope Ciphertext on its own tells your systems nothing. The decision that matters is where the private key lives and where the decrypted number is handled. | If you… | Card data on your servers | | --- | --- | | Decrypt on the cardholder's device, with the private key kept there | **None.** Your servers relay ciphertext they cannot read | | Decrypt in a hardware security module and never expose the number | **Depends on your assessor.** The HSM and everything that touches the number is in scope | | Decrypt on your servers | **Yes.** Your infrastructure handles card data and is in PCI DSS scope | See [PCI DSS scope](/docs/security/pci-dss-scope) for the reasoning. Make this choice while you design the feature, not when an auditor asks about it. ## Verifying - The response contains `encrypted_pan`, `encrypted_cvv`, `algorithm` and `last_four`, and no field holds a readable number. - Decrypting both values with your private key gives digits. A different key can't decrypt them. - Two calls for the same card return different ciphertext, because each is encrypted afresh. - A card that isn't `active` returns `card_invalid_state`, and a closed card `card_closed`. - A credential without `cards:secrets` returns `insufficient_scope`. - Nothing sensitive shows up in your logs. Search them for a test card number and its security code and make sure both return zero results. Ideally, automate this check. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `missing_parameter` | `public_key` wasn't sent | | `400` | `invalid_public_key` | `public_key` isn't a PEM RSA key or certificate, or is shorter than 2048 bits | | `403` | `insufficient_scope` | The credential doesn't have `cards:secrets` | | `404` | `not_found` | This program can't return card details, so the route isn't available | | `404` | `card_not_found` | The card doesn't exist, or belongs to another organization | | `422` | `card_closed` | The card is closed. A closed card has no number to show | | `422` | `card_invalid_state` | The card isn't `active`: it is `pending`, `inactive`, `frozen` or `expired` | | `422` | `program_not_configured` | The program isn't set up to return card details. Raise it with your account contact | | `502` | `processor_permission_denied` | The program isn't approved to read card details. Send us the `request_id` | | `502` | `processor_unavailable` | The processor couldn't return the details, or its reply couldn't be verified. Try again | ## Related - [Security overview](/docs/security/overview) - [PCI DSS scope](/docs/security/pci-dss-scope) - [Card lifecycle](./card-lifecycle) — which card states allow secret retrieval --- ## Freeze, replace, close Most card support actions come down to four operations: freeze, unfreeze, replace, and close. Freeze and unfreeze can be reversed. Replace and close are permanent. ## Before you begin You'll need an issued card. ## How it works | Action | Endpoint | Reversible | Use when | | --- | --- | --- | --- | | Freeze | `POST /v1/cards/{id}/freeze` | Yes | Card is misplaced, a charge is being disputed, or the card needs a temporary suspension | | Unfreeze | `POST /v1/cards/{id}/unfreeze` | Yes | The reason for freezing the card has passed | | Replace | `POST /v1/cards/{id}/replace` | No | Card is damaged, lost, stolen, compromised, or expiring | | Close | `POST /v1/cards/{id}/close` | No | Cardholder has left or the card is no longer needed | All four endpoints accept an `Idempotency-Key`. Always send one. For example, it prevents a double-click on a "Freeze" button from creating duplicate requests. ## Steps ### Freeze and unfreeze ```bash curl -X POST "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZAB/freeze" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` Once the card is frozen, new authorizations are declined. Unfreezing takes effect just as quickly. Freezing doesn't change the card balance or the funding account, and it doesn't release existing authorization holds. A transaction that was already held before the freeze can still settle normally when the merchant clears it. ### Replace Replacing a card issues a new card for the same cardholder and account, then closes the current one. The new card is returned in the response. ```bash curl -X POST "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZAB/replace" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"reason": "compromised"}' ``` ```json title="Response, with a new id" id="a6x1qs" { "id": "card_01HX8ZQ2K4M7P9RSTVWXYZCD", "object": "card", "form_factor": "virtual", "status": "pending", "reference": "user-4471-card-1", "created_at": "2026-08-02T10:14:00Z" } ``` `reason` must be one of `lost`, `stolen`, `damaged`, `expired`, `compromised`, or `other`. Not every program can replace a card. Where the processor has no replacement, the call is refused with `operation_unsupported`. To replace a lost or stolen card there, close it and issue a new one. Keep these things in mind when replacing a card: - The replacement has a new `id`, card number, and expiry date, and keeps the original's `reference`. Update anything in your system that references the old card. - The replacement is issued like any other card. It is `pending` in the response, then `inactive` on `card.issued`. Activate it with `POST /v1/cards/{id}/activate` before it can be used. - The original is closed and emits `card.closed`. If the cardholder uses the card in a phone wallet, they'll need to add the replacement card again. - If the call fails with `processor_unavailable`, read the original card before you try again, so a replacement isn't issued twice. ### Close ```bash curl -X POST "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZAB/close" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` Closing a card is permanent. The card enters its terminal state and emits `card.closed`. Closing doesn't affect the funding account. Its balance stays where it is, and other cards linked to the account continue to work. To move money out of the account, see [funding and defunding](/docs/products/card-issuing/accounts-and-funding/funding-and-defunding). ## Choosing between freeze, replace, and close | Situation | Action | | --- | --- | | "I've misplaced my card. It's probably somewhere in the house." | Freeze | | "I found it." | Unfreeze | | "It's definitely gone." / "It's damaged." | Replace | | "There are charges I didn't make." | Freeze, then replace once confirmed | | Employee leaves | Close | | Card expires in 30 days | Replace, based on the card's `expires_at` | When you're unsure, freeze first. It's reversible. Replace when the card needs to be retired and issued again, and use close when the card is permanently finished. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `missing_parameter` | Replace: `reason` wasn't sent, or `delivery_address` is missing on a physical replacement | | `400` | `invalid_parameter` | Replace: `reason` isn't one of the values above | | `404` | `card_not_found` | The card doesn't exist, or belongs to another organization | | `422` | `card_invalid_state` | Freezing a card that isn't `active` or is already `frozen`; unfreezing one that isn't `frozen`; or freezing or unfreezing an `expired` card. The message names the state | | `422` | `card_closed` | The card is permanently closed, including closing or replacing it again | | `422` | `card_not_provisioned` | Close or replace on a card the processor hasn't issued yet (`pending`). Wait for `card.issued` | | `422` | `unsupported_form_factor` | Replace: the `form_factor` you named isn't one the program issues | | `422` | `operation_unsupported` | Replace: this program's processor can't replace a card. Close it and issue a new one | | `502` | `processor_unavailable` | The processor couldn't complete the call and the card is unchanged. Retry with the same `Idempotency-Key` | | `502` | `processor_permission_denied` | The program isn't approved for this operation at the processor. Send us the `request_id` | ## Related - [Operations by card status](./operations-by-status) — which actions are available in each state - [Card lifecycle](./card-lifecycle) --- ## Fulfillment and shipping tracking Track a physical card from manufacturing through delivery, and give the cardholder a clear view of where it is. :::caution Planned Fulfillment tracking isn't available yet. A physical card can be requested and activated, but the API doesn't report where the card is between production and the cardholder, and there are no shipping events. The exact fulfillment states, tracking fields, and events aren't published yet. Processors differ quite a bit in what they report while a card is in transit. One might provide several fulfillment states, a carrier, and a tracking number. Another might provide no tracking information at all and only tell you when the card has been delivered. ::: ## Before you begin You'll need: - A physical card. See [issuing a physical card](./issuing-a-physical-card). - A webhook endpoint. Fulfillment can take several days, so polling isn't a good fit. It uses your rate limit and can still leave the cardholder waiting for an update. ## How it works There are two status fields to keep separate: | Field | Answers | | --- | --- | | `status` | Can the card be used? | | `fulfillment.status` | Where is the physical card? | These statuses describe different things. A card can be unusable because it's still being delivered, and that's expected. For example, if `status` says the card isn't active yet but `fulfillment.status` says it's in transit, show the cardholder that the card is on its way rather than showing an error. ## What to plan for ### Manufacturing and delivery Printing, dispatch, and delivery are handled by the card processor and carrier. There isn't an API call that moves a card through these steps. Delivery can take several days, and the actual time varies by destination. Be careful about turning a dispatch date or tracking status into a guaranteed delivery date. A card that has shipped today may still arrive several days later than expected. ### Returned cards Some cards will be returned instead of delivered. It won't happen often, so it's easy to miss when designing the first version. When a card is returned, the cardholder still needs a usable card. You'll usually need to confirm the delivery address and arrange a replacement. Make sure returned cards generate an alert or another signal that your support team will actually see. A status sitting in a dashboard isn't much help if nobody checks it. ### Activation A physical card is activated with `POST /v1/cards/{card_id}/activate`. The call doesn't check delivery, so keep your own activation action behind the delivery state, and don't offer it for a card that is still in transit. ### Webhook ordering Webhook events can arrive out of order. The platform doesn't guarantee that fulfillment events will be delivered in the same order they were created. If your handler applies every event as soon as it arrives, an older event can overwrite a newer state and make the card appear to move backwards. Use the event's `created_at` when processing updates. If an event describes a state older than the one you've already stored, ignore it. ## Related - [Issuing a physical card](./issuing-a-physical-card) - [Card lifecycle](./card-lifecycle) — the states the card itself moves through - [Webhooks](/docs/webhooks/overview-and-delivery-guarantees) — delivery and ordering guarantees --- ## Cards A card is used to make payments from an associated account. Each card follows a defined lifecycle from creation to closure or expiry. ## Card data and PCI scope The full card number and CVV are never included in normal API responses, and issuing a card does not send either value to your servers. `last_four` appears once the card has been issued, when the processor reports it. Showing the full card number is a server-to-server call. You send a public key, and we return the card number and security code encrypted to it. Only the holder of the matching private key can read them, so your backend relays ciphertext it cannot read. This keeps your infrastructure outside PCI DSS cardholder-data scope, as long as the private key and the decryption stay on the cardholder's device or somewhere else that card data is allowed. A server that decrypts the number is in scope. See [displaying PAN and CVV securely](/docs/products/card-issuing/cards/displaying-pan-and-cvv-securely) and [PCI DSS scope](/docs/security/pci-dss-scope). ## In this section - **[Issuing a virtual card](/docs/products/card-issuing/cards/issuing-a-virtual-card)** — the quickest way to issue a card - **[Issuing a physical card](/docs/products/card-issuing/cards/issuing-a-physical-card)** — request a printed card for a virtual one, or issue one directly - **[PIN management](/docs/products/card-issuing/cards/pin-management)** — set the PIN of a physical card from a PIN encrypted on the device - **[Card lifecycle](/docs/products/card-issuing/cards/card-lifecycle)** — all card states and transitions - **[Freeze, replace, close](/docs/products/card-issuing/cards/freeze-replace-close)** — the actions support teams use most, including which ones are permanent - **[Displaying PAN and CVV securely](/docs/products/card-issuing/cards/displaying-pan-and-cvv-securely)** — show sensitive card details, encrypted until they reach the device --- ## Issuing a physical card Get an embossed card delivered to the cardholder. There are two ways to ask for one, and the card behaves the same afterwards. ## Before you begin You'll need: - Everything required to [issue a virtual card](./issuing-a-virtual-card), including an `address` on the cardholder and `kyc_status: approved`. - Physical card issuance enabled for your program. A program that doesn't issue physical cards refuses the request with `unsupported_form_factor`. - To request plastic for a card you already hold, a program whose processor supports it. Where it doesn't, `POST /v1/cards/{card_id}/request-physical` isn't mounted and the path returns `404` — the same answer a misspelled URL gets. Probe for it when you integrate rather than at run time. ## How it works A physical card follows the same statuses as a virtual card, and is activated with the same call. ```mermaid stateDiagram-v2 direction LR [*] --> pending: request or issue pending --> inactive: card.issued inactive --> active: POST /v1/cards/{id}/activate active --> [*] ``` 1. **Ask for the card.** Either of the two ways below. Issuing a physical card directly returns a new card, `inactive`, or `pending` while the processor accepts the application, and you get `card.issued` when it moves on. Requesting plastic for a virtual card keeps the same card and `id`. 2. **Wait for it to arrive.** Manufacturing and delivery take days, and they happen outside the API. 3. **Activate it.** Call `POST /v1/cards/{card_id}/activate`. On a card issued directly, the first call turns the card on and orders the plastic, and fires `card.activated`. Once the cardholder has the plastic, call it again to enable the card for use in person. Until then every authorization on the card is declined. ## Two ways to ask for a card ### Request one for a virtual card If the cardholder already has an `active` virtual card and now wants it printed, use `POST /v1/cards/{card_id}/request-physical`. There is no request body: the card keeps the name and the delivery address you gave when you issued it. ```bash curl -X POST "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZAB/request-physical" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` ```json title="Response" { "id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "card", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "account_id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "form_factor": "physical", "status": "active", "currency": "SGD", "embossed_name": "A TAN", "fulfillment": { "status": "pending" }, "created_at": "2026-08-02T10:14:00Z" } ``` `form_factor` is now `physical` and `fulfillment.status` is `pending`: the plastic has been ordered. Everything else is the same card — same `id`, same number, same balance. `status` is untouched too, so the card goes on spending online while the plastic is made. Requesting plastic twice is refused with `card_invalid_state`, because the card is already `physical` by then. When the cardholder has the plastic, call `POST /v1/cards/{card_id}/activate` to enable it for use in person. ### Issue one directly If the cardholder doesn't need a virtual card first, create the physical card with `POST /v1/cards`: ```bash curl -X POST "$ALETA_BASE_URL/v1/cards" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "account_id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "form_factor": "physical", "embossed_name": "A TAN", "delivery_address": { "line1": "350 Orchard Road", "city": "Singapore", "postal_code": "238868", "country": "SGP" } }' ``` `embossed_name` and `delivery_address` are required here. ## What physical issuance adds ### The name on the card A physical card has a name printed or embossed on it. Once the card has been produced, that name can't be changed. If it's wrong, you'll need a new card and another delivery. The processor sets the character limit (at most 26 characters), and it's usually shorter than the limits on standard name fields. Long names may not fit, so ask the cardholder what they want printed on the card rather than automatically using their legal name. Your integration should also be prepared to shorten the value. ### Addresses Physical cards involve two addresses, and they don't have to be the same. The card is registered to the `address` on the cardholder record. The plastic is sent to the `delivery_address` you gave when you issued the card. Both are fixed at issuance. `POST /v1/cards/{card_id}/request-physical` takes no body, so plastic goes to the address the card already carries and there is no way to redirect it on the way. Issuance is the only moment a delivery address can be set, which is why it's required on every card rather than only on a physical one. If the cardholder has moved since, issue a new card. The exact address fields depend on the processor. Some require more than a street, city, postcode, and country, so leave enough room in your address model to support additional fields. The API checks that the address is complete, but it doesn't verify that the address actually exists. For example, a well-formed address for a nonexistent house can still pass validation and only fail after the card is shipped. For that reason, validate delivery addresses with an address service on your side before asking for the card. ### Manufacturing and delivery Physical cards take days rather than seconds. Manufacturing and delivery are handled by the card processor and carrier, so there isn't an API call you can make to move the card from one stage to the next. Tracking a card through production and shipping isn't available yet. See [fulfillment and shipping tracking](./fulfillment-and-shipping-tracking) for what is planned. Until then, your application knows when the cardholder has the card, and that is when to activate it. ### Activation Activation is a server-to-server call, the same one a virtual card uses. There is no hosted page and no URL to open: ```bash curl -X POST "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZEF/activate" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` A physical card is activated in two steps. The first call turns the card on and orders the plastic. The second, made once the cardholder has the plastic, enables the card for use in person. Both return the card. Don't make the second call before the cardholder has the card. The API can't tell whether a card has arrived, so a card enabled early can be used in person before it is in the cardholder's hands. ## Common errors For the errors on issuing the card, see [issuing a virtual card](./issuing-a-virtual-card#common-errors). A physical card adds these. | Status | `code` | Fix | | --- | --- | --- | | `422` | `card_invalid_state` | Activation: the card is `pending`, `frozen` or `expired`. Requesting plastic: the card isn't an `active` virtual card, or is already physical | | `422` | `card_closed` | The card is closed. Issue a new one | | `422` | `card_already_activated` | Activation: the card is already `active` and its plastic has been confirmed as received | | `422` | `unsupported_form_factor` | The program doesn't issue physical cards | | `404` | `card_not_found` | The card doesn't exist, or belongs to another organization | | `502` | `processor_unavailable` | The processor couldn't turn the card on, order the plastic or enable it for use in person. Retry with the same `Idempotency-Key` | ## Related - [Issuing a virtual card](./issuing-a-virtual-card) - [PIN management](./pin-management) — setting the PIN on the card - [Card lifecycle](./card-lifecycle) - [Operations by card status](./operations-by-status) - [Fulfillment and shipping tracking](./fulfillment-and-shipping-tracking) --- ## Issuing a virtual card ## Before you begin Before issuing a virtual card, make sure you have: - A cardholder with an `identity_document` on file. Issuance is rejected with `identity_document_required` if there isn't one. Set it when [creating the cardholder](/docs/products/card-issuing/cardholders-and-kyc/creating-a-cardholder), since it can't be added afterwards. - A cardholder with an `address` on file. The card is registered to this address, and issuance is rejected with `address_required` if there isn't one. Set it when creating the cardholder. Some programs don't allow an address change once the cardholder exists, and refuse `PATCH /v1/cardholders/{cardholder_id}` with `address_change_requires_kyc`; there, report a KYC attestation carrying the address instead. - A cardholder with `kyc_status: approved`. Issuance is rejected with `kyc_not_approved` until the cardholder has been approved. - An account in the currency the card will spend from. See [creating accounts](/docs/products/card-issuing/accounts-and-funding/creating-accounts). - A funded account if you want to test spending. A card linked to an empty account will have every authorization declined with `insufficient_funds`. ## How it works A virtual card doesn't need to be manufactured or shipped, but it isn't usable the moment it's created. It goes through the same statuses as every other card, and you decide when it starts working: 1. **Create it.** `POST /v1/cards` returns `status: "pending"` while the processor issues the card. On a program that issues at once it returns `inactive` instead. 2. **Wait for `card.issued`.** The processor has the card, and its status is now `inactive`. Issuing can take a few minutes, and sometimes close to an hour. We check for you, so don't poll. 3. **Activate it.** `POST /v1/cards/{card_id}/activate` is a server-to-server call with no body. The card becomes `active` and `card.activated` fires. Until it's `active`, every authorization on the card is declined. ```mermaid stateDiagram-v2 direction LR [*] --> pending: POST /v1/cards pending --> inactive: card.issued inactive --> active: POST /v1/cards/{id}/activate ``` Activation is a separate call so that virtual and physical cards behave the same way, and so the card only starts working when you are ready for it to. The call needs the cardholder to still be verified. ## Steps ### 1. Create the card ```bash curl -X POST "$ALETA_BASE_URL/v1/cards" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "account_id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "form_factor": "virtual", "embossed_name": "A TAN", "delivery_address": { "line1": "350 Orchard Road", "city": "Singapore", "postal_code": "238868", "country": "SGP" }, "reference": "user-4471-card-1" }' ``` ```json title="Response" { "id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "card", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZAB", "account_id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "program_id": "prog_01HX8ZQ2K4M7P9RSTVWXYZAB", "form_factor": "virtual", "card_type": "debit", "status": "pending", "currency": "SGD", "is_default": true, "reference": "user-4471-card-1", "created_at": "2026-07-29T14:32:08Z" } ``` The request needs five fields: `cardholder_id`, `account_id`, `form_factor`, `embossed_name` and `delivery_address`. The last two are required for a virtual card too, because they are fixed when the card is issued and can't be changed if you later order a physical card for it. The card gets its currency from the account, so you don't need to send `currency`. Everything else in the request is optional. The `address` and `identity_document` belong to the cardholder, not the card creation request. The card is registered to the cardholder's address, so issuance is rejected with `address_required` if no address is on file. `last_four` and `expires_at` appear once the card has been issued, and only when the processor reports them, so don't depend on them being there. The full card number and CVV are never in this response and aren't sent to your servers. Retrieving them is a separate, separately scoped and audited operation. See [displaying PAN and CVV securely](./displaying-pan-and-cvv-securely). ### 2. Wait for `card.issued` The event's payload carries `status: "inactive"`. That is your signal that the card can be activated. See [card issuing events](/docs/webhooks/events-card-issuing#card.issued). ### 3. Activate the card ```bash curl -X POST "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZAB/activate" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` The response is the card, now `active`. Calling it again on an active card returns `card_already_activated`, and calling it before `card.issued` returns `card_invalid_state`. If the call fails, the card stays `inactive` and you can call it again. ## Verifying Retrieve the card and check its status at each step: ```bash curl "$ALETA_BASE_URL/v1/cards/card_01HX8ZQ2K4M7P9RSTVWXYZAB" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` Expect `pending`, then `inactive` after `card.issued`, then `active` after the activation call. Then prove it spends: in sandbox, simulate an authorization and confirm the funding account's `held_balance` moves. A card that is not `active` is declined with `card_not_active`. ```bash curl -X POST "$ALETA_BASE_URL/v1/simulate/authorization" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "card_id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB", "amount": { "amount": 4250, "currency": "SGD" }, "merchant": { "name": "Kopi Roastery", "mcc": "5814", "country": "SGP" } }' ``` ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `missing_parameter` | `cardholder_id`, `account_id` or `form_factor` wasn't sent | | `400` | `invalid_parameter` | An id is the wrong kind, `form_factor` isn't `virtual` or `physical`, `embossed_name` is over 26 characters, or the address is incomplete. Or the processor rejected the application: quote the `request_id` and we find the reason | | `400` | `unknown_parameter` | A field name typo. `param` names it | | `404` | `cardholder_not_found` | The cardholder doesn't exist, or belongs to another organization | | `404` | `account_not_found` | The account doesn't exist, or belongs to another organization | | `409` | `idempotency_key_reuse` | The same `Idempotency-Key` was used with a different body | | `422` | `embossed_name_required` | `embossed_name` wasn't sent. It is required for every card | | `422` | `delivery_address_required` | `delivery_address` wasn't sent. It is required for every card | | `422` | `identity_document_required` | The cardholder has no `identity_document`. Set it when creating the cardholder | | `422` | `address_required` | The cardholder has no `address`. Set it with `PATCH /v1/cardholders/{cardholder_id}`, or report it in a KYC attestation on programs that don't allow the change | | `422` | `kyc_not_approved` | The cardholder hasn't been verified. Wait for `cardholder.kyc_approved` instead of polling | | `422` | `account_blocked` | Unblock the funding account first | | `422` | `account_cardholder_mismatch` | The account belongs to a different cardholder. Use one of this cardholder's accounts | | `422` | `unsupported_form_factor` | The program doesn't issue this form factor. The error lists the ones it does issue | | `422` | `card_limit_reached` | The cardholder already has a card with the processor behind this program, which holds one card per cardholder | | `422` | `card_type_not_configured`, `program_not_configured` | The program isn't set up for this card. Nothing was sent to the processor; raise it with your account contact | | `422` | `program_not_provisioned` | The program isn't usable on this deployment. Raise it with your account contact | | `422` | `card_invalid_state` | On activation: the card is still `pending`, `frozen`, or `expired`. Wait for `card.issued`, unfreeze it, or issue a new card | | `422` | `card_already_activated` | On activation: the card is already `active` | | `422` | `card_closed` | On activation: the card is closed. Issue a new card | | `502` | `processor_unavailable` | The processor couldn't take the application. Retry with the same `Idempotency-Key`. Check the card list first if you can't be sure it didn't go through | | `502` | `processor_permission_denied` | The program isn't approved for this operation at the processor. Send us the `request_id` | ## Related - [Issuing a physical card](./issuing-a-physical-card) — to have a card printed - [Card lifecycle](./card-lifecycle) - [Operations by card status](./operations-by-status) - [Freeze, replace, close](./freeze-replace-close) - [Displaying PAN and CVV securely](./displaying-pan-and-cvv-securely) --- ## Operations by card status Here's what you can do with a card in each `status`, and what happens when the card is used. Read across a row to see what a card in that state can do. Read down a column to see when an operation is refused and which `code` comes back. ## Key | Mark | Meaning | | --- | --- | | ✓ | Succeeds. Any condition in the footnote still applies | | ✗ `code` | Refused with `422` and that `code`. Retrying won't change the result | | ↓ `reason` | The card is presented and declined. The authorization contains the `decline_reason`; no API error is returned | ## The matrix | Status | Activate | Request physical | Lock | Unlock | Close | Replace | Set PIN | Show card details | Credit / debit wallet | Online payment | Apple Pay / Google Pay | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `pending` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✓ | ✗ `card_not_provisioned` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` ² | ↓ `card_not_active` | ↓ `card_not_active` | | `inactive` | ✓ → `active` ³ | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✓ | ✓ | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` ² | ↓ `card_not_active` | ↓ `card_not_active` | | `active` | ✗ `card_already_activated` | ✓ ⁵ | ✓ → `frozen` | ✗ `card_invalid_state` | ✓ | ✓ | ✓ ⁴ | ✓ | ✓ ² | ✓ ¹ | ✓ ¹ | | `frozen` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✓ → `active` | ✓ | ✓ | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` ² | ↓ `card_not_active` | ↓ `card_not_active` | | `expired` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✓ | ✓ | ✗ `card_invalid_state` | ✗ `card_invalid_state` | ✗ `card_invalid_state` ² | ↓ `card_expired` | ↓ `card_expired` | | `closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_closed` | ✗ `card_invalid_state` ² | ↓ `restricted_card` | ↓ `restricted_card` | ¹ For a payment to go through, the funding account must be active, otherwise the payment is declined as blocked, and the available balance must cover the amount, otherwise it is declined for insufficient funds. Each decline carries the same reason on the authorization at the processor. ² Value can only be added to or removed from the account while its card is active. In any other status the call is refused with an invalid state error. The account itself must also be active, otherwise the call is refused as blocked, and a debit also fails when the available balance is too low. ³ The cardholder must still be approved, otherwise activation is refused. If the call fails, the card stays inactive and can be activated again. ⁴ A PIN is set on a physical card, and the card must be active. A virtual card is refused with an invalid state error in every status. ⁵ Only a card that is still `virtual`. A card that is already `physical` is refused with an invalid state error in every status, which is also what a second request gets. The program must issue physical cards too, otherwise the refusal is `unsupported_form_factor`; and where the program's processor cannot have plastic made for a card already issued, the endpoint isn't mounted and the path returns `404`. ## What each column is | Operation | API call | Notes | | --- | --- | --- | | Activate | `POST /v1/cards/{card_id}/activate` | Moves `inactive` → `active`. A server-to-server call with no body and no hosted page, for virtual and physical cards alike. Call it after `card.issued` | | Request physical | `POST /v1/cards/{card_id}/request-physical` | Orders plastic for a card issued without it. No body, and it returns the same card — `form_factor` becomes `physical` and `fulfillment.status` `pending`. `status` is unchanged, so the card keeps spending online. See [issuing a physical card](./issuing-a-physical-card) | | Lock | `POST /v1/cards/{card_id}/freeze` | Moves `active` → `frozen`. Reversible; everything else about the card stays the same | | Unlock | `POST /v1/cards/{card_id}/unfreeze` | Moves `frozen` → `active`. The next presentation can be approved | | Close | `POST /v1/cards/{card_id}/close` | Permanent. Invalidates the card's network tokens. If you only need a temporary block, lock it instead | | Replace | `POST /v1/cards/{card_id}/replace` | Issues a new card and closes this one. Refused for a card the processor hasn't issued yet. See [freeze, replace, close](./freeze-replace-close) | | Set PIN | `POST /v1/cards/{card_id}/pin` | Sets the PIN from a PIN encrypted on the device. See [PIN management](./pin-management) | | Show card details | `POST /v1/cards/{card_id}/secrets` | Returns the card number and security code encrypted to a public key you send. See [displaying PAN and CVV securely](./displaying-pan-and-cvv-securely) | | Credit / debit wallet | `POST /v1/accounts/{account_id}/credit` and `/debit` | Adds or removes value on the account the card spends from. Held funds aren't available to debit | | Online payment | An e-commerce authorization | Decided when the card is presented. A completed purchase reaches you later as a settled transaction | Some operations aren't in the matrix because they work in every status: `GET /v1/cards/{card_id}` and the card list endpoints. A new card is `pending`, then `inactive` once it has been issued (`card.issued`), and only becomes `active` when you activate it. See [Issuing a virtual card](./issuing-a-virtual-card). ## Related - [Card lifecycle](./card-lifecycle) — the card states and how they change - [Freeze, replace, close](./freeze-replace-close) — the operations step by step - [Accounts and funding](/docs/products/card-issuing/accounts-and-funding) --- ## PIN management Let cardholders set or reset the PIN of their physical card, without your servers ever handling the digits in the clear. A PIN is used where a card is presented at a terminal or a machine, so it belongs to a physical card. Virtual cards don't have one. ## Before you begin You'll need: - An `active` physical card. See [issuing a physical card](./issuing-a-physical-card). Activate the card first: a card that isn't `active` is refused. - An app that can encrypt on the cardholder's device with RSA-OAEP using SHA-256. - The `cards:secrets` scope on your credential. This scope isn't enabled by default. ## How it works The cardholder types their PIN into your app. The app encrypts it on the device to a short-lived key we issue, and your backend passes the ciphertext to us. Your servers only ever hold something they can't read. ```mermaid sequenceDiagram autonumber participant D as Cardholder's device participant Y as Your backend participant A as AletaPlanet D->>Y: Cardholder taps "Set a PIN" Y->>A: Get a PIN encryption key A-->>Y: key_id, public key, expires_at Y-->>D: Relay the key Note over D: Cardholder types the PIN, the app encrypts it to the key D->>Y: Encrypted PIN Y->>A: Set the PIN with key_id and the encrypted PIN A-->>Y: Done ``` The same two calls set a PIN for the first time and reset an existing one. ## Steps ### 1. Get an encryption key Call [Get a PIN encryption key](/api-reference/resources/create-card-pin-key) when the cardholder is about to enter their PIN, not when the screen loads. The key is for one card, expires after a few minutes, and opens one PIN: once it has been sent with a PIN, whatever the outcome, it can't be used again. ### 2. Encrypt the PIN on the device Encrypt the PIN, as its digits, to the key's public key with RSA-OAEP using SHA-256, and encode the result as base64. Do it on the cardholder's device and send only the ciphertext to your backend. A PIN that your servers handle as readable text puts those servers in PCI DSS scope. Validate the PIN on the device before you encrypt it. We only require digits, in the length the key response gives as `pin_length`. Every other rule is yours, such as refusing repeated digits or sequences, so the cardholder is told at once and nothing weak is sent. See [PCI DSS scope](/docs/security/pci-dss-scope). ### 3. Set the PIN Call [Set a PIN](/api-reference/resources/set-card-pin) with the `key_id` and the encrypted PIN. A successful call returns nothing. Never write the PIN, encrypted or not, to a log, an analytics event, web storage or a support ticket. ## Verifying - Setting a PIN returns no content, and no response carries a PIN. - A key that is unknown, has expired or was used already is refused. Get a new one. - A PIN that isn't digits of the right length, or that can't be decrypted with the key you were given, is refused with `pin_invalid`. We apply no other rule. - A virtual card, or a card that isn't `active`, is refused with `card_invalid_state`. - A credential without `cards:secrets` returns `insufficient_scope`. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `400` | `missing_parameter` | `key_id` or `encrypted_pin` wasn't sent | | `400` | `invalid_parameter` | The key is unknown, has expired or was already used. Get a new key | | `403` | `insufficient_scope` | The credential doesn't have `cards:secrets` | | `404` | `card_not_found` | The card doesn't exist, or belongs to another organization | | `404` | `not_found` | This program can't take a PIN by a call, so the route isn't available | | `422` | `pin_invalid` | The PIN isn't the right length, isn't digits, or wasn't encrypted to the key you were given | | `422` | `card_invalid_state` | The card is virtual, or isn't `active`. A PIN is set on an active physical card | | `422` | `card_closed` | The card is closed. Issue a new card | | `422` | `program_not_configured` | The program isn't set up to take a PIN. Raise it with your account contact | | `502` | `processor_permission_denied` | The program isn't approved to set PINs at the processor. Send us the `request_id` | | `502` | `processor_unavailable` | The outcome is unknown: the PIN may or may not be set. Don't resend on your own; ask the cardholder to try again, with a new key | ## Related - [Displaying PAN and CVV securely](./displaying-pan-and-cvv-securely) - [Security overview](/docs/security/overview) - [Issuing a physical card](./issuing-a-physical-card) --- ## Core concepts This is the card issuing model, used throughout these guides and the API reference. Other products have their own objects and their own sections. ## The object model ```mermaid erDiagram PROGRAM ||--o{ ACCOUNT : "scopes" CARDHOLDER ||--o{ ACCOUNT : "owns" CARDHOLDER ||--o{ CARD : "holds" ACCOUNT ||--o{ CARD : "funds" CARD ||--o{ AUTHORIZATION : "produces" AUTHORIZATION ||--o| TRANSACTION : "clears into" TRANSACTION ||--o{ DISPUTE : "may be disputed" CARD ||--o{ TOKEN : "provisioned as" PROGRAM { string id "prog_" } CARDHOLDER { string id "chd_" string kyc_status } ACCOUNT { string id "acct_" string currency int available_balance } CARD { string id "card_" string form_factor string status } AUTHORIZATION { string id "auth_" string status } TRANSACTION { string id "txn_" } ``` ## Program A **program** is your card product as configured with the network and issuing bank. It defines things such as the BIN range cards are issued from, supported currencies, card designs, and fee schedule. Programs are configured during onboarding and attached to your organization. You do not create or configure them through the API. An account belongs to exactly one program, and cards issued against that account inherit the same program. Cardholders are separate from programs. A cardholder represents a legal person, so the same person can have an account under your consumer program and another under your corporate program while remaining a single cardholder record. When you create an account, specify the program with `program_id` on `POST /v1/accounts`. You don't need to provide the program again when working with the account's cards, spend controls, or reporting. Your client ID determines your organization and environment, but not the program. See [authentication](/docs/get-started/authentication). The two list endpoints that can return objects across programs, `GET /v1/accounts` and `GET /v1/cards`, accept an optional `?program_id=` filter. If you leave the filter out, they return objects from every program available to you. `GET /v1/programs` returns the programs available to the current client, including their IDs and names. It only requires a valid token and no additional scope. You can also see the same programs in Cockpit under **Settings → Programs**. Each row represents a program attached to your organization in the current environment. ## Cardholder A **cardholder** (`chd_`) is the person a card belongs to. It is the base identity record in the model: accounts and cards both reference the cardholder, and a card cannot be issued without one. A cardholder represents a legal person and is not tied to a particular program. Their identity and verification status are therefore established once and can be used across your programs. For example, the same cardholder can have accounts under two different programs at the same time. The most important field is `kyc_status`. KYC must be approved before a card can be activated. | `kyc_status` | Meaning | Can a card be issued? | | --- | --- | --- | | `not_started` | Record created; verification has not started | No | | `pending` | Verification submitted and under review | No | | `approved` | Verification completed successfully | Yes | | `rejected` | Verification failed | No | | `review_required` | Additional evidence is needed | No | See [KYC lifecycle and statuses](/docs/products/card-issuing/cardholders-and-kyc/kyc-lifecycle-and-statuses) for the full set of transitions. Two other fields are required for issuance regardless of `kyc_status`: - `address` — the address the card is registered to - `identity_document` — the identity document associated with the cardholder If either is missing, issuance fails with `address_required` or `identity_document_required`. You handle the actual identity verification and report the result to the API. Cardholders submit their documents to your identity provider, and you send us an attestation containing who performed the verification, when it happened, and the result. Identity documents do not pass through this API. See [identity documents](/docs/products/card-issuing/cardholders-and-kyc/document-upload). ## Account An **account** (`acct_`) holds value. Each account has one currency, belongs to one cardholder, and has three balances: | Balance | Meaning | | --- | --- | | `ledger_balance` | Money that has settled | | `held_balance` | Money reserved by approved authorizations that have not cleared | | `available_balance` | `ledger_balance − held_balance`. **This is the spendable balance** | Use `available_balance` when showing customers how much they can spend. Use `ledger_balance` for reconciliation. If you display `ledger_balance` as the spendable amount, you'll include funds that may already be held by authorizations. A cardholder with multiple currencies will have a separate account for each currency. See [balances: ledger vs available](/docs/products/card-issuing/accounts-and-funding/balances-ledger-vs-available) for more detail. ## Card A **card** (`card_`) is the payment instrument. Its `form_factor` is either `virtual` or `physical`, and each card spends from exactly one account. The relationship between the card and account is explicit: a card without an account cannot authorize a transaction. The `status` field describes the card's lifecycle: | `status` | Meaning | | --- | --- | | `pending` | Accepted; the processor has not issued the card yet | | `inactive` | Issued and waiting for you to activate it | | `active` | Activated and ready to use | | `frozen` | Temporarily blocked; can be unfrozen | | `expired` | Past its expiry date | | `closed` | Permanently terminated and cannot be reopened | See the [card lifecycle guide](/docs/products/card-issuing/cards/card-lifecycle) for the complete set of transitions. ## Authorization An **authorization** (`auth_`) is a merchant asking whether a card has enough available funds for a purchase. It is created by card activity, not directly by you. When an authorization is approved, the API places a **hold** on the funds rather than moving the money immediately. The hold reduces `available_balance` and remains until the transaction clears, the authorization is reversed, or the hold expires. | `status` | Meaning | | --- | --- | | `pending` | Approved and holding funds; not yet cleared | | `approved` | Authorization has cleared | | `declined` | Authorization was refused by us or by you | | `reversed` | Merchant released the authorization before it cleared | | `expired` | The hold expired without clearing | ## Transaction A **transaction** (`txn_`) represents settled money movement. It is what appears on a statement and what reconciliation files are based on. The final transaction amount can differ from the original authorization amount. Some transactions, such as force posts and merchant-originated refunds, do not have an authorization at all. Because of this, don't assume every transaction maps one-to-one to an authorization. ## Naming rules - IDs are opaque. Don't parse them, sort by them, or rely on anything beyond the documented format. - The prefix identifies the object type: `prog_`, `chd_`, `acct_`, `card_`, `auth_`, `txn_`, `disp_`, `tok_`, `whk_`, `evt_`. - Every object includes an `object` field identifying its type. Use that field when you need to determine how to handle a response rather than inspecting the ID. See [ID prefixes](/api-reference/conventions/id-prefixes) for the complete list. --- ## How it works Card issuing involves several different parties. Understanding who is responsible for each part makes it easier to know where a decision comes from and what your integration needs to handle. ## The parties ```mermaid flowchart LR subgraph You["Your platform"] App["Your app
and backend"] end subgraph Aleta["AletaPlanet"] API["Public API
/v1"] Ledger["Ledger and
account balances"] Decision["Authorization
decisioning"] end Network["Payment network
Visa"] Bank["Issuing bank
and BIN sponsor"] Merchant["Merchant and
acquirer"] App -->|REST| API API --> Ledger Merchant --> Network Network --> Decision Decision --> Ledger API -.->|webhooks| App Aleta --- Bank Bank --- Network ``` ## Who owns what | Concern | Owner | Notes | | --- | --- | --- | | Your users, your product, your UI | **You** | We don't interact with your end users directly, other than the cardholder records you provide | | Identity verification decision | **You or us**, by program | This is agreed during program setup | | Cardholder, account, and card records | **AletaPlanet** | These are authoritative. Any copy you keep is a cache | | Customer funds | **Issuing bank** | Funds are held in a segregated account, with the ledger maintained by us | | Balance and available funds | **AletaPlanet** | See [balances: ledger vs available](/docs/products/card-issuing/accounts-and-funding/balances-ledger-vs-available) | | Approve or decline an authorization | **AletaPlanet** | Real-time decisioning by your platform is planned, but isn't available yet. See [real-time decisioning](/docs/products/card-issuing/real-time-decisioning/) | | Card number, expiry, CVV, PIN | **AletaPlanet**, inside PCI scope | These details are never returned to your servers in clear text | | Card manufacture and delivery | **Fulfillment partner**, coordinated by us | Tracking through the API is planned | | Clearing and settlement with the network | **AletaPlanet and issuing bank** | You see the results through transactions and reconciliation files | | Disputes and chargebacks | **AletaPlanet**, with evidence from you | Scheme deadlines still apply | ## What happens when a card is used A card payment usually shows up as at least two separate events, often hours or days apart. Your integration needs to handle both. 1. **Authorization.** The merchant asks the network whether the payment can be approved. The network routes the request to us. We check the card state, spend controls, and available balance, then approve or decline within the network's timeout window, which is measured in seconds. If approved, the amount is held rather than moved. 2. **Clearing.** Usually the next day, the merchant submits the transaction for processing. The hold is released and the debit is posted. The cleared amount can be different from the original authorized amount, such as when a restaurant adds a tip. 3. **Settlement.** Money moves between the acquiring and issuing banks. You won't see this as part of the real-time API flow, but the results are included in reconciliation files. The important part for your integration is that an authorization isn't the same as money movement. If you calculate balances using only settled transactions, the balance will be wrong for much of the day. That's why the API exposes separate figures for the ledger and available balance. See [balances: ledger vs available](/docs/products/card-issuing/accounts-and-funding/balances-ledger-vs-available). ## Where you plug in There are two main integration points: - **The REST API** at `api.aletaplanet.com` — for actions you initiate. - **Webhooks** — for events we send to you asynchronously. Card issuing is event-driven, so your integration should be built to handle webhooks as well as API responses. A synchronous-only integration will miss important changes. --- ## Real-time decisioning Every other call on this platform runs in the direction you expect: you ask, we answer. An authorization runs the other way. The card network asks, and something has to answer approve or decline while the cardholder stands at the terminal. ```mermaid flowchart LR M["Merchant"] --> N["Card network"] --> P["Card processor"] P -->|"decide this"| I["Issuing path"] I -->|"approve / decline"| P P -->|"answer"| N ``` ## How it works Card networks give the issuer seconds to make an authorization decision, not minutes. That constraint shapes the whole flow. There is no queue to wait in and no retry loop. The system making the decision has to read the card state, check the funds available, apply the relevant rules, and return an answer within a budget that also includes the network round trips. The decision is made somewhere in the issuing path. In some programs we make it ourselves; in others, the processor does. Which one applies depends on how the program is set up. That distinction is intentionally hidden from you. If integrations could depend on it, we would lose the ability to move that responsibility later. What matters is the behavior you can rely on. A card that is not spendable will not authorize. A transaction for more than the available funds will be declined. Declines show up as authorization reasons, not as errors from one of your API calls, because your call did not fail—the authorization itself was declined. An approval does not immediately move money. It places a hold for the authorized amount. The actual movement happens later, when the transaction clears, and the cleared amount may be different from the original authorization. If the system cannot make a decision—for example, because a required read fails or the authorization times out—it declines the transaction. For a prepaid program, that is the safer default. Approving a transaction without being able to verify the balance could mean approving against funds that are not there. A decline is inconvenient for the cardholder, but an unchecked approval can effectively turn into unsecured credit. ## Related - [Transactions](/docs/products/card-issuing/transactions) — the settled record a decision produces, and the only part of the sequence exposed today --- ## 3-D Secure 3-D Secure authenticates the cardholder during an online purchase, before the authorization. It is what stands behind the extra step a shopper sometimes sees at checkout. ## Why it exists Once a transaction is authenticated, liability for fraudulent chargebacks shifts from the merchant to the issuer, so cards enrolled in 3-D Secure see a different dispute profile. Several markets also mandate strong customer authentication for online payments, and where that applies 3-D Secure is not optional. ## What you control | Yours | The Access Control Server's | | --- | --- | | The cardholder's contact details | Whether a purchase is challenged | | Reacting to the resulting transaction | Which method is used, and delivering it | | | The challenge screen and its verification | 3-D Secure is switched on for a BIN range, with the network, as part of setting up your program. Once it is on, every card in the range is covered. There is nothing to enroll and no way to opt a single card out. Everything about the challenge itself happens in the ACS; see [how a challenge works](/docs/products/card-issuing/three-d-secure/otp-delivery). ## Challenge methods | Method | What the cardholder sees | | --- | --- | | Frictionless | Nothing. Risk assessment passes silently | | One-time passcode | A code by SMS or email, entered at checkout | | Out-of-band | A prompt in an app, not a code to type | Most authentications are frictionless. Design for the challenge cases anyway; they are the ones that fail. Out-of-band is the better experience: a push notification and a biometric prompt instead of a code to copy across. The processor behind current programs can run it, and the API for delegating it to an app of yours is specified and stable, though not deployed yet. See [out-of-band authentication](/docs/products/card-issuing/three-d-secure/out-of-band-authentication). ## In this section - **[How a challenge works](/docs/products/card-issuing/three-d-secure/otp-delivery)** — what the ACS does, what you control, and whose support question is whose - **[Out-of-band authentication](/docs/products/card-issuing/three-d-secure/out-of-band-authentication)** — approving in your own app, and the contract to build against --- ## How a challenge works The authentication challenge is not yours to run. This page explains what that means for your integration and your support team. ## Before you begin This page covers what happens during an online purchase on a program with 3-D Secure enabled. If you are not sure whether yours is, ask your account contact before reading on. ## How it works There is no challenge API, by design. Once a card is enrolled, the Access Control Server (ACS) decides whether to challenge at all, chooses the method, delivers any passcode, renders the challenge screen, and verifies the answer. ```mermaid flowchart LR CH["Cardholder
at checkout"] --> M["Merchant site"] M --> ACS["Access Control Server"] ACS -->|"passcode, if challenged"| CH CH -->|"answers"| ACS ACS --> AUTH["Authorization proceeds"] AUTH -.->|"webhook"| Y["Your system"] %% Emphasis is a class, not a baked colour: a hardcoded dark fill with %% white text only works in one mode. src/css/docs.css themes `.ec-emphasis`. classDef ec-emphasis stroke-width:2px class Y ec-emphasis ``` Your system is not in that loop. You learn the outcome from the transaction that settles afterwards. ### What you control, and what you do not | You control | The ACS controls | | --- | --- | | Whether the card is enrolled | Whether a purchase is challenged at all | | The cardholder's `phone` and `email` | Which method is used | | Reacting to the resulting transaction | Passcode generation and delivery | | | The challenge screen the cardholder sees | | | Verification, retries, and lockout | There is no endpoint to trigger a passcode, verify one, resend one, or configure delivery. The ACS owns all of it. ### Challenge methods | Method | What the cardholder sees | | --- | --- | | Frictionless | Nothing. Risk assessment passes silently | | Passcode | A code by SMS or email, entered at checkout | | Out-of-band | A prompt in an app, not a code to type | Most authentications are frictionless, because modern 3-D Secure challenges only where the risk warrants it. Design your checkout for the challenge case anyway; that is the case that fails. ## Steps ### 1. Keep contact details accurate The ACS delivers passcodes using the `phone` and `email` on the cardholder record. A stale phone number means a code sent somewhere the cardholder cannot read it, and a purchase that fails for a reason neither of you can see. ### 2. Expect no event for a passcode challenge A passcode challenge produces no webhook and no object. The ACS runs it from end to end, and what reaches you is the [settled transaction](/docs/products/card-issuing/transactions) if the purchase completes. An authentication the cardholder abandons produces no transaction either, so it is invisible to you. When a cardholder says they tried to buy something and you hold no record of it, an abandoned challenge is the usual explanation. Out-of-band is the exception. Those challenges do reach you, because your app is what answers them. See [out-of-band authentication](/docs/products/card-issuing/three-d-secure/out-of-band-authentication). ### 3. Do not build a passcode UI Your application does not display the challenge, collect the code, or submit it. The merchant's checkout hands off to the ACS, which renders its own screen. ## Verifying Since you do not control the challenge, verification is about your side of the boundary: - The cardholder's `phone` and `email` are current and match what they expect - Your checkout flow, if you also operate a merchant surface, tolerates a redirect to an ACS screen and a return - Your support team knows which questions are yours and which belong to the ACS; the table below sorts them - An abandoned authentication produces no transaction, and nothing in your system waits indefinitely for one ## Whose support question is it | Cardholder says | Owner | | --- | --- | | "I didn't get a code" | Yours first. Check `phone` on the record, then escalate | | "The code didn't work" | ACS. Escalate with the card id and the time of the attempt | | "I wasn't asked to authenticate" | Expected. Most purchases are frictionless | | "My card isn't protected online" | Ours. 3-D Secure is enabled per program, not per card. Raise it with your account contact | | "The checkout page looked wrong" | ACS or merchant, not us | The first row is both the most common and the one you can resolve yourself, by checking the `phone` on the cardholder record. ## Common errors No API call produces a challenge, so there is no error table for this page. The failure modes are all elsewhere: | Mistake | Symptom | | --- | --- | | Program not set up for 3-D Secure | Purchases are never challenged; no liability shift | | Stale `phone` on the cardholder | Passcodes sent where the cardholder cannot read them | | Building a passcode UI | Dead code that never receives input | | Waiting for a challenge event | A handler that never fires | ## Related - [Transactions](/docs/products/card-issuing/transactions) — what you do see - [Creating a cardholder](/docs/products/card-issuing/cardholders-and-kyc/creating-a-cardholder) — keeping contact details current --- ## Out-of-band authentication Out-of-band means the cardholder proves who they are somewhere other than the checkout page. Instead of reading a code out of an SMS and typing it into a merchant's form, they get a prompt in an app they are already signed in to and approve with a biometric. ## Before you begin - 3-D Secure enabled on your program, and out-of-band enabled on top of it. - A push channel to your own application that you trust to deliver in seconds. - The `authentications:read` and `authentications:write` scopes on your credential. Neither is granted by default. - A step-up your app can perform on the device: biometric, or the device passcode. ## How it works The ACS decides to challenge and picks out-of-band. From there the cardholder stays in your app, and the merchant's checkout waits on the answer the same way it would wait for a passcode. ```mermaid sequenceDiagram participant CH as Cardholder participant M as Merchant checkout participant ACS as Access Control Server participant A as AletaPlanet participant Y as Your app CH->>M: pays online M->>ACS: authentication request Note over ACS: challenge, method out_of_band ACS->>A: challenge for this cardholder A->>Y: authentication.challenged Y->>CH: push notification CH->>Y: approves with biometric Y->>A: POST /v1/authentications/{id}/approve A->>ACS: approved ACS->>M: authentication complete M->>ACS: authorization proceeds ``` Two things about that sequence shape the implementation. The authorization is separate. Approving authentication does not approve a payment. The authorization follows, and it can still decline for insufficient funds or a frozen card. Your app should say "confirmed", not "paid". ## The authentication object ```json { "id": "authn_01HX8ZQ2K4M7P9RSTVWXYZABCD", "object": "authentication", "status": "pending", "method": "out_of_band", "card_id": "card_01HX8ZQ2K4M7P9RSTVWXYZABCD", "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTVWXYZABCD", "merchant": { "name": "Kopi Roastery" }, "amount": { "amount": 4250, "currency": "SGD" }, "created_at": "2026-09-27T09:38:00Z", "updated_at": "2026-09-27T09:38:00Z" } ``` | `status` | Meaning | | --- | --- | | `pending` | Waiting for an answer. The merchant's checkout is still open | | `approved` | You approved it. The authorization follows | | `declined` | You declined it, or the cardholder did | | `timed_out` | Nobody answered in time. The purchase fails at the merchant | `method` is `out_of_band` on every object you will see. A passcode challenge produces no authentication object at all, because the ACS runs it end to end and your app is not involved. ## Steps ### 1. Subscribe to `authentication.challenged` The event carries the whole object, so your handler has what it needs to raise a prompt without a second call. ``` authentication.challenged a challenge is waiting; the window is minutes authentication.approved answered yes authentication.declined answered no, by the cardholder or by your step-up ``` Treat `authentication.challenged` as the highest-priority event your endpoint handles. It is the only one on this platform where a slow acknowledgement costs the cardholder a purchase. ### 2. Push to the device, then step up Show the cardholder what they are approving, from the object: the merchant name and the amount. Do not paraphrase the amount or round it. Then step up before you send the answer. Biometric or device passcode, immediately before, not the session they already have open. An app that approves whenever it happens to be unlocked provides no authentication at all. ### 3. Answer ```bash curl -X POST "$ALETA_BASE_URL/v1/authentications/authn_01HX8ZQ2K4M7P9RSTVWXYZABCD/approve" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: $(uuidgen)" ``` [`POST .../decline`](/api-reference/resources/decline-authentication) takes the same shape. Both return the updated object. Answer once. The call is idempotent on the `Idempotency-Key`, and a second answer with a different key returns `422 authentication_not_pending`. ### 4. Handle the ones nobody answers If your push channel is down, [`GET /v1/authentications?status=pending`](/api-reference/resources/list-authentications) lists what is waiting, and [`GET /v1/authentications/{id}`](/api-reference/resources/get-authentication) reads one back. Use them when your notification path breaks. They do not work as a polling loop: at the rate a challenge expires, polling often enough to be useful would cost you your rate limit. ### 5. Fall back to the passcode path The ACS picks the method. A cardholder without your app installed, or signed out, or on a device you cannot reach, gets a passcode instead, and your product has to work when that happens. See [how a challenge works](/docs/products/card-issuing/three-d-secure/otp-delivery). ## Verifying - `authentication.challenged` reaches your endpoint within seconds of the cardholder submitting a purchase. - Your prompt shows the merchant and amount from the object, unrounded. - Approving returns the object with `status: approved`, and the authorization arrives separately. - A second approval on the same authentication returns `authentication_not_pending`. - Letting one expire clears your prompt on your own timeout, with no event and no `expires_at` to wait for. - A cardholder without your app still completes a purchase by passcode. ## Common errors | Status | `code` | Fix | | --- | --- | --- | | `403` | `insufficient_scope` | The credential lacks `authentications:write` | | `404` | `authentication_not_found` | Wrong id, or it belongs to another organization | | `422` | `authentication_not_pending` | Already answered, or expired. Read the object for which | | `422` | `operation_unsupported` | Out-of-band is not enabled on this program | ## Related - [How a challenge works](/docs/products/card-issuing/three-d-secure/otp-delivery) — the passcode path, which works today - [3-D Secure](/docs/products/card-issuing/three-d-secure) — why authentication exists and what you control - [Webhooks](/docs/webhooks/overview-and-delivery-guarantees) — delivery and ordering guarantees --- ## Transactions A transaction is money that has moved. Every debit and credit on an account appears as one, and it is the record to reconcile against. ## What you can read ```bash curl "$ALETA_BASE_URL/v1/transactions?account_id=acct_01HX8ZQ2K4M7P9RSTVWXYZAB" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` ```json title="One transaction" { "id": "txn_01HX8ZQ2K4M7P9RSTVWXYZAB", "object": "transaction", "account_id": "acct_01HX8ZQ2K4M7P9RSTVWXYZAB", "card_id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB", "type": "purchase", "direction": "debit", "amount": { "amount": 4600, "currency": "SGD" }, "merchant": { "name": "Kopi Roastery", "mcc": "5814", "country": "SGP" }, "settled_at": "2026-07-30T09:12:00Z", "created_at": "2026-07-30T09:12:00Z" } ``` `type` says what the movement was for: `purchase`, `withdrawal`, `transfer`, `load`, `fee`, `refund` or `adjustment`. `direction` says which way it went, and `amount` is always positive. ## What to build on **Reconcile against transactions.** They are the settled record, and in phase 1 they are the only record you receive. A ledger built from anything else will disagree with ours. **A purchase appears when it settles.** That can be hours or days after the cardholder paid, because clearing runs on the merchant's schedule rather than yours. Treat "no transaction yet" as "not settled yet", never as "no purchase", and keep your reconciliation window open long enough to catch the slow ones. **The settled amount can differ from what the cardholder saw at the till.** A restaurant tip is the everyday case. `amount` is what actually moved, so post that figure and not the one the terminal displayed. **Expect `authorization_id` to be absent.** Nothing populates it while the authorization stage is unexposed, so read it as optional and left join on it if you join at all. Code that inner joins on it today returns nothing. Balances live on the account rather than the card. See [balances: ledger vs available](/docs/products/card-issuing/accounts-and-funding/balances-ledger-vs-available). ## Related - [Accounts and funding](/docs/products/card-issuing/accounts-and-funding) — where the balance these transactions move actually sits - [Card issuing events](/docs/webhooks/events-card-issuing) — the events a current program does emit, for cardholders, accounts and cards - [Real-time decisioning](/docs/products/card-issuing/real-time-decisioning) — how an authorization is decided, for background on the step you do not see --- ## eSIM Sell a traveller a data plan and provision the eSIM profile they install. There are four resources, in the order you use them, and a link to a hosted page. | | What it is | | --- | --- | | **Packages** | The catalog. A plan with a data allowance, validity period, and the countries it works in. | | **Orders** | Buying a package. An order allocates an eSIM and puts the package on it. | | **Profiles** | The eSIM itself, along with the packages running on it. | | **Top-ups** | Adding more data to a package that's already running. | | **H5** | A short-lived link to a hosted eSIM page for one cardholder. | ## How it fits together ```mermaid graph LR P["package
catalog entry"] -->|order| O["order"] O -->|allocates| E["eSIM profile
+ activation code"] E -->|extend| T["top-up"] E -->|installs| TR["traveller's device"] style E fill:#1f5f3a,color:#fff ``` Ordering is synchronous. There's no queue or callback: a `201` means the eSIM is ready. If something goes wrong, you get an error response and the order isn't left half-finished. ## Before you start - You need a credential with the eSIM scopes. See [authentication](/docs/get-started/authentication). - The catalog is shared across the platform and read-only. It changes when coverage or pricing changes, so cache it for a few minutes, not days. ## The one thing to get right **The activation code is the profile.** It's returned when you retrieve a single eSIM and deliberately left out of listings. Otherwise, a page showing a hundred eSIMs could also expose a hundred secrets. Treat the activation code like a card number: don't put it in logs, analytics, or URLs. Retrieve an eSIM when you're about to send the customer their install instructions, not when the page first loads. ## Ordering can fail, so check first A package must be `available` when you place the order. Fetch the package you're about to buy right beforehand instead of relying on a cached list. If the package has been frozen or withdrawn, the order is rejected with `esim_package_unavailable`. The same idea applies to top-ups. Which add-ons an eSIM can use depends on what's currently running on it, so fetch the list right before creating a top-up. ## Create H5 links on demand An H5 link sends one cardholder to a hosted eSIM page. It lasts 15 minutes, and it opens as many times as you like until then. Create the link from your backend when the cardholder asks for the page, then redirect them straight to `url`. Don't put the link itself in an email, SMS, or push notification, because it has usually expired by the time it's opened. Link to your own endpoint instead, and have it create a fresh H5 link and redirect when it's followed. Treat `url` like the activation code. Anyone holding it acts as that cardholder until it expires, so keep it out of logs, analytics, and storage. ## Reference - [eSIM Packages](/api-reference/resources/list-esim-packages) — the catalog - [eSIM Orders](/api-reference/resources/create-esim-order) — buying one - [eSIM Profiles](/api-reference/resources/list-esims) — the eSIMs you've allocated - [eSIM Top-ups](/api-reference/resources/list-esim-top-ups) — adding more data - [eSIM H5](/api-reference/resources/create-esim-h-5-link) — sending a cardholder to the hosted page --- ## Base URL and versioning ## Base URL ```text https://api.aletaplanet.com ``` Both environments use the same base URL. There is no separate sandbox host. The access token determines which environment a request goes to: `at_test_…` for sandbox and `at_live_…` for production. See [environments](/docs/get-started/environments). All API traffic must use HTTPS. Plain HTTP requests are rejected rather than redirected. This avoids sending credentials over the network before the redirect happens. TLS 1.2 is the minimum supported version. TLS 1.3 is recommended. ## Versioning The major API version is part of the URL from the start. This makes it possible to introduce breaking changes later without breaking existing clients. ```text https://api.aletaplanet.com/v1/cards ``` ### Changes that don't require a new version The following changes are considered backward-compatible, so your integration should be able to handle them: - Adding a new endpoint - Adding an optional request parameter - Adding a new field to a response - Adding a new value to an existing enum - Adding a new webhook event type - Adding a new error `code` under an existing error `type` - Changing the wording of a `message` (the `code` won't change) - Reordering fields in a JSON object Two things are easy to miss here: **new enum values** and **new webhook event types**. For example, if your code exhaustively handles an enum and throws an error for anything it doesn't recognize, a new enum value could break it. Treat unknown values as unknown instead of treating them as errors. The same applies to webhook event types: your integration should safely ignore event types it doesn't know about. ### Changes that require a new major version A new major version is required for changes such as: - Removing or renaming an endpoint, field, or enum value - Changing a field's type or meaning - Making an optional parameter required - Changing default behavior in a way that changes existing results - Removing a webhook event type ## Deprecation policy When we deprecate something, we'll: 1. Announce it in the developer documentation. 2. Add a `Sunset` header to responses from the affected endpoint with the removal date, along with a `Deprecation: true` header. 3. Publish a migration guide for that specific change. Minimum notice periods: | Change | Minimum notice | | --- | --- | | Endpoint or field removal | 12 months | | Major version end-of-life | 24 months | | Security-driven removal | As short as the risk requires, communicated directly | ```http HTTP/1.1 200 OK Deprecation: true Sunset: Sat, 31 Jul 2027 23:59:59 GMT Link: ; rel="deprecation" ``` If you're only going to monitor one header from this API, make it `Sunset`. ## Optional date-pinned versions You can use the `Aleta-Version` request header to pin behavior to a specific dated release within a major version: ```http Aleta-Version: 2026-07-28 ``` If you leave the header out, you'll get the current release of that major version. When you pin a version, you'll still receive additive changes, but behavioral defaults stay the same as they were on the date you pinned. --- ## ID prefixes Every object has an opaque, prefixed identifier. The prefix tells you what type of object it is; the rest of the ID has no meaning and shouldn't be parsed. ```text id="k5kq2d" card_01HX8ZQ2K4M7P9RSTV └──┘ └────────────────┘ type opaque ``` ## The full list | Prefix | Object | | --- | --- | | `prog_` | Program | | `chd_` | Cardholder | | `acct_` | Account | | `card_` | Card | | `auth_` | Authorization | | `txn_` | Transaction | | `disp_` | Dispute | | `tok_` | Network token | | `epkg_` | eSIM data package | | `esim_` | eSIM | | `eord_` | eSIM order | | `etop_` | eSIM top-up | | `whk_` | Webhook endpoint | | `evt_` | Event | | `req_` | Request (used in `Aleta-Request-Id` and the error `request_id`) | | `oc_` | OAuth client | | `oat_` | Access token record (shown in Cockpit, never sent over the wire) | Access tokens are different. They aren't object IDs and don't follow this format. They're opaque secrets prefixed with `at_test_` for sandbox or `at_live_` for production. Using a token in the wrong environment will fail rather than accidentally accessing objects from the other environment. ## Format The part after the prefix is a 26-character uppercase Crockford base-32 string. Full IDs match: ```text id="5q9e8a" ^[a-z]+_[0-9A-HJKMNP-TV-Z]{26}$ ``` Crockford base-32 leaves out `I`, `L`, `O`, and `U` because they're easy to confuse with other characters when copying IDs from things like support tickets. When storing IDs, size your database column for up to 64 characters. IDs are currently shorter than that, but the format and maximum length aren't something your integration should rely on. ## Rules 1. **Don't parse IDs.** The opaque part has no meaning. It's not a timestamp, sequence number, hash, or anything else you can reproduce. 2. **Don't sort by ID.** If you need chronological order, sort by `created_at`. 3. **Don't assume IDs are portable between environments.** The same string in sandbox and production refers to unrelated objects. 4. **Use the prefix for basic validation.** For example, if your code expects a `card_` ID but receives an `acct_` ID, reject it before making a request. 5. **Store IDs as strings.** Keep the prefix and don't convert them to numbers. ## Why IDs are opaque There are a couple of practical reasons. First, sequential IDs can leak information about your volume. If someone signs up and creates one card, they could potentially infer roughly how many cards you've issued. Sequential IDs also make enumeration easier. More importantly, opaque IDs give us room to change what's happening behind the scenes. The ID you store belongs to Aleta, rather than exposing a key from some downstream system. If our internal processing changes, the IDs you've already stored can keep working. That's intentional. Exposing an upstream identifier through the API would make that kind of change much harder. ## Your own identifiers Most resources accept a `reference` field for your own identifier. It's returned when you read the resource and included in webhook payloads: ```json id="6f9y9x" { "id": "card_01HX8ZQ2K4M7P9RSTV", "reference": "employee-4471-card-2" } ``` `reference` is indexed and can be used as a filter: ```bash id="j3f2m0" curl "https://api.aletaplanet.com/v1/cards?reference=employee-4471-card-2" ``` `reference` doesn't have to be unique and isn't used for idempotency. If you need idempotency, use [`Idempotency-Key`](./idempotency). --- ## Idempotency Networks can fail after the server has already done its work. A timeout doesn't tell you whether a card was actually issued. Idempotency keys let you safely retry a request without accidentally creating the same thing twice. ## Using it Send a unique key with every `POST` request: ```bash curl -X POST "https://api.aletaplanet.com/v1/cards" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \ -H "Idempotency-Key: 1f3a8c2e-5b7d-4e91-a0c6-2d8f4b6a9e13" \ -H "Content-Type: application/json" \ -d '{ "cardholder_id": "chd_01HX8ZQ2K4M7P9RSTV", "form_factor": "virtual" }' ``` Create the key before the first attempt and reuse the same key for every retry of that operation. A UUID v4 works well. A key based on your own primary key, such as `card-issue-{your_id}`, can be even better because you can recreate it after a process restart. ## Semantics | Situation | Result | | --- | --- | | First request with a key | Processed normally; response is cached | | Same key, identical body | Cached response is replayed. Nothing happens twice | | Same key, different body | `409` with `idempotency_key_reuse` | | Same key, original request still in flight | `409` with `idempotency_request_in_progress` | | Key is older than 24 hours | Treated as a new request | The replay window is 24 hours. Replayed responses include this header: ```http Idempotent-Replay: true ``` The replay contains the exact status code and body from the original request, including errors. If the first attempt returned `422`, the replay returns `422` as well. ### Beyond the 24-hour window: `client_transaction_id` An idempotency key protects a retry. It doesn't protect you if the same batch job runs again a week later, after the idempotency record has expired. For the two endpoints that move money, `POST /v1/accounts/{id}/credit` and `POST /v1/accounts/{id}/debit`, also send your own `client_transaction_id` in the request body. This should be the ID of the thing that caused the movement in your system, such as a payroll line, invoice, or sweep. It's unique per account and doesn't expire. | Situation | Result | | --- | --- | | First movement with an ID | Processed normally | | Same ID, same amount and direction | `200`, `Idempotent-Replay: true`, nothing moves | | Same ID, different amount or direction | `409` with `adjustment_already_applied` | | Same ID, original request still in flight | `409` with `idempotency_request_in_progress` | `reference` is different. It's just a free-text label with no uniqueness requirement and is intended for reconciliation. Multiple movements can have the same `reference`. ## Which methods | Method | Behavior | | --- | --- | | `POST` | Honors `Idempotency-Key`. Always send one | | `GET` | Naturally idempotent. Key is ignored | | `PATCH` | Honors the key. Recommended | | `DELETE` | Naturally idempotent. Key is ignored | ## Body matching Requests are compared using a canonical form of the body. Key order and whitespace don't matter, but values do. These two requests are treated as the same: ```json {"form_factor": "virtual", "cardholder_id": "chd_01HX8Z"} {"cardholder_id":"chd_01HX8Z","form_factor":"virtual"} ``` This one is different and returns `409`: ```json {"cardholder_id": "chd_01HX8Z", "form_factor": "physical"} ``` That `409` is intentional. It means a request that was supposed to be a retry wasn't actually identical. That's something your application should surface rather than silently resolving. ## Retrying correctly ```javascript const idempotencyKey = randomUUID(); for (let attempt = 0; attempt < 5; attempt++) { try { return await aleta.cards.create(body, { idempotencyKey }); } catch (err) { // 4xx other than 409-in-progress won't succeed on retry. if (err.status < 500 && err.code !== 'idempotency_request_in_progress') { throw err; } await sleep(2 ** attempt * 200 + Math.random() * 100); } } ``` Keep these three rules in mind: 1. **Create the key once, outside the loop.** Generating a new key for every attempt defeats idempotency and is the most common mistake. 2. **Use exponential backoff with jitter.** 3. **Don't retry `4xx` errors**, except `409 idempotency_request_in_progress`. Other `4xx` errors won't start succeeding just because you retry them. ## Scope Idempotency keys are scoped to your organization and environment. The same key in sandbox and production refers to completely separate requests. Keys are not scoped per endpoint, either. Reusing the same key on two different endpoints returns `409`. --- ## OpenAPI specification The OpenAPI specification is the source of truth for the API. The reference pages on this site are generated from it, and changes to the spec are reviewed alongside the implementation. ## Download | Format | URL | | --- | --- | | YAML | [`/openapi.yaml`](pathname:///openapi.yaml) | | JSON | [`/openapi.json`](pathname:///openapi.json) | Both files contain the same specification. It's written in OpenAPI 3.1, which is compatible with JSON Schema 2020-12. Make sure your generator supports OpenAPI 3.1. Some older tools only support 3.0. ## Why the docs are generated Keeping field tables and the implementation in sync by hand doesn't work for long. Eventually, the two drift apart. Instead, the reference documentation is generated from the specification at build time. The build also runs the spec through our linter, so a broken or invalid specification won't make it into the docs. The rule is simple: if a field appears in the docs, it exists in the specification. If it isn't in the specification, it won't appear in the docs. ## Generating a client AletaPlanet doesn't provide server-side client libraries. The API is plain HTTP with bearer-token authentication, so you can use any HTTP client you like. If you want a typed client, generating one from the OpenAPI specification is the recommended approach: ```bash id="3b7f5x" # Using openapi-generator openapi-generator-cli generate \ -i https://developer.aletaplanet.com/openapi.yaml \ -g typescript-fetch \ -o ./aleta-client ``` A generated client is a good starting point, but there are two things it won't handle for you reliably: - **Idempotency keys.** Generated clients usually don't know about the [retry-with-the-same-key](/api-reference/conventions/idempotency) pattern. You'll need to add that logic yourself. - **Money types.** Most generators map integers to the language's default numeric type. Make sure amounts stay integers and don't get mapped to floating-point types. See [timestamps and money](/api-reference/conventions/timestamps-and-money). ## Pinning The specification at the URL above always tracks the current release. For reproducible builds, download the specification and commit it to your repository instead of fetching it during the build. Additive changes are released continuously, so generating a client from the live URL can change your generated code without any change on your side. ## Validating The specification is linted in CI using rules that enforce the conventions used throughout the API, including `snake_case` fields, `_at` timestamp suffixes, and ID prefix patterns. If you extend or fork the specification, running the same lint rules is the quickest way to keep your changes consistent with the rest of the API. --- ## Pagination Every list endpoint uses cursor-based pagination. There are no offset or page-number alternatives. ## The list envelope ```json id="h7c2n1" { "object": "list", "url": "/v1/cards", "data": [ { "id": "card_01HX8ZQ2K4M7P9RSTV", "object": "card" }, { "id": "card_01HX8ZQ2K4M7P9RSTW", "object": "card" } ], "has_more": true, "next_cursor": "eyJpZCI6ImNhcmRfMDFIWDhaUTJLNE03UDlSU1RXIn0" } ``` | Field | Meaning | | --- | --- | | `data` | The current page. Always an array, though it can be empty | | `has_more` | Whether there are more results | | `next_cursor` | Pass this as `starting_after` to fetch the next page. `null` when `has_more` is `false` | ## Paging forward Start with the first page: ```bash id="q8a5w2" curl "https://api.aletaplanet.com/v1/cards?limit=50" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` Then use the returned `next_cursor` for the next page: ```bash id="3q2x4m" curl "https://api.aletaplanet.com/v1/cards?limit=50&starting_after=eyJpZCI6…" \ -H "Authorization: Bearer $ALETA_ACCESS_TOKEN" ``` Keep going until `has_more` is `false`. Don't use an empty `data` array as your stopping condition. A filtered page can be empty even when more results are available. ## Parameters | Parameter | Type | Default | Max | Notes | | --- | --- | --- | --- | --- | | `limit` | integer | `25` | `100` | Values above the maximum are rejected, not clamped | | `starting_after` | string | | | Cursor for the next page | | `ending_before` | string | | | Cursor for the previous page | `starting_after` and `ending_before` can't be used together. ## Cursors Cursors are opaque. Don't decode them, create your own, or rely on their internal format. The encoding isn't part of the API contract and can change. A cursor is valid for 24 hours. After that, the API returns `400` with `expired_cursor`. Start the traversal again with a fresh cursor. ## Why not offsets Offsets seem simple, but they can cause problems when the collection is changing. For example, if new records are added between fetching page 1 and page 2, an offset-based page 2 can repeat records you've already seen or skip records entirely. On a transaction list, that could mean silently missing transactions during reconciliation. Cursors point to a position in a stable sort, so the page boundary stays consistent even as new records are added. The trade-off is that you can't jump directly to page 47. If you need that kind of UI, a narrower filter is usually a better approach. ## Iterating safely ```javascript id="m2v7ce" async function* allCards(aleta, params = {}) { let cursor; do { const page = await aleta.cards.list({ ...params, limit: 100, starting_after: cursor }); yield* page.data; cursor = page.next_cursor; } while (cursor); } ``` There are two things to keep in mind: - Loop on `next_cursor`, not `data.length`. - Don't parallelize a traversal. Each page gives you the cursor needed to request the next one, so the requests are sequential by design. For very large exports, use the reports endpoints instead of walking the API page by page. A complete transaction history is better treated as a file than as one huge list traversal. --- ## Postman ## Download | File | URL | | --- | --- | | Collection | [`/aletaplanet.postman_collection.json`](pathname:///aletaplanet.postman_collection.json) | | Environment | [`/aletaplanet.postman_environment.json`](pathname:///aletaplanet.postman_environment.json) | Import both files into Postman. Then select the environment from the picker in the top right. Requests will fail with `missing_credentials` until the environment is active because every request uses `Authorization: Bearer {{access_token}}`. ## Set up the environment The environment includes five variables. You only need to set the first two before you can start making requests. | Variable | Set it to | | --- | --- | | `base_url` | `https://api.aletaplanet.com`, which works for both environments | | `access_token` | A token from `POST /oauth/token`. It's stored as a Postman secret and expires after 15 minutes | | `cardholder_id` | Filled in as you go from the response to **Create a cardholder** | | `account_id` | Filled in as you go from the response to **Create an account** | | `card_id` | Filled in as you go from the response to **Issue a card** | The token request isn't included in the collection on purpose. It requires a JWT signed with your private key, which Postman can't generate. Run the token step from the [quickstart](/docs/get-started/quickstart#step-1--get-an-access-token) using Python or Node, then paste the resulting token into `access_token`. Once it expires after 15 minutes, repeat the process. The token determines both your organization and environment, so `base_url` stays the same for sandbox and production. See [environments](/docs/get-started/environments). The token doesn't determine which program you're working with. **Create an account** includes `program_id` in its request body and is the only request in the collection that requires it. Replace the placeholder with one of your own program IDs before running it. To find your program IDs, use **List your programs** in the Programs folder, or check Settings → Programs in Cockpit. Idempotency keys are generated automatically for each request using Postman's `{{$guid}}`. This means sending a mutation again creates a new operation instead of retrying the previous one. That's a good default when exploring the API, but it's not what you want when testing retry behavior. For retry tests, replace the generated value with a fixed key. See [idempotency](/api-reference/conventions/idempotency). ## What's in the collection The collection includes every operation currently served by the API, organized by resource: - Programs - Cardholders - Accounts - Cards - Transactions Request bodies come with working example values, including valid document types, amounts in minor units, and accepted enum values. IDs are placeholders that follow the same format as real IDs, for example `chd_01HX8ZQ2K4M7P9RSTVWXYZAB`. Replace them with IDs from your own responses. List requests include their available query parameters, but they're disabled by default. The first request therefore returns an unfiltered page. Enable the parameters you need before sending it. --- ## Rate limits Rate limits are applied per client, not per organization or IP address. Creating a second client to get around a limit won't help, because sustained limits are also tracked at the organization level. ## Limits | Environment | Sustained | Burst | | --- | --- | --- | | Sandbox | 100 requests/minute | 20 requests/second | | Production | Per agreement, from 1,000 requests/minute | 100 requests/second | Some endpoints are more expensive to run and have their own lower limits. Those limits are called out on the endpoint's documentation page. Card secret retrieval and report generation are the main examples. Production limits are set during program setup. If you need more capacity, you can request an increase and include some detail about your traffic pattern. ## Headers Every response includes your current rate-limit status: ```http RateLimit-Limit: 1000 RateLimit-Remaining: 943 RateLimit-Reset: 37 ``` | Header | Meaning | | --- | --- | | `RateLimit-Limit` | Requests permitted in the current window | | `RateLimit-Remaining` | Requests left | | `RateLimit-Reset` | Seconds until the window resets | Read these headers on every response, not just when a request fails. Starting to back off when you're at 10% remaining is much smoother than waiting until the limit hits zero. ## When you exceed the limit ```http HTTP/1.1 429 Too Many Requests Retry-After: 12 ``` ```json { "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded", "message": "Too many requests. Retry after 12 seconds.", "doc_url": "https://developer.aletaplanet.com/api-reference/errors/error-code-catalog", "request_id": "req_01HX8ZQ2K4M7P9RSTV" } } ``` Honor `Retry-After`. The value is calculated from your actual rate-limit window; it isn't just a placeholder telling you to try again later. ## Backing off ```javascript async function withBackoff(fn, maxAttempts = 5) { for (let attempt = 0; ; attempt++) { try { return await fn(); } catch (err) { const retryable = err.status === 429 || err.status >= 500; if (!retryable || attempt >= maxAttempts - 1) throw err; const wait = err.retryAfter ? err.retryAfter * 1000 : Math.min(2 ** attempt * 250, 8000); // Jitter matters: without it, every client that hit the limit together // retries together, and the next window is exhausted instantly. await sleep(wait + Math.random() * 250); } } } ``` Retry `429` and server errors with backoff. Use the `Retry-After` value when the server provides one, and add some jitter so a group of clients doesn't all retry at exactly the same time. ## Staying under the limit - Use webhooks instead of polling. Polling a card list for a status change is the biggest source of avoidable traffic, and we'll push the event to you anyway. - Expand instead of following IDs when you only need a single object. One request is better than three. - Batch background work and spread it out instead of firing a reconciliation job as one large burst at midnight. - Cache data that doesn't change often. Program configuration and card product metadata are good examples. - Use reports for bulk history instead of walking through paginated lists. ## Limits that are not rate limits A `429` means you've sent too many requests. It does **not** mean: - `402 insufficient_funds` — the account is empty - `403 ip_not_allowed` — the source address isn't permitted - `422 velocity_limit_exceeded` — a spend control was triggered, not an API rate limit The last one is especially important. `velocity_limit_exceeded` is a business rule about money, so retrying it is always the wrong thing to do. --- ## Request and response format ## Content type Requests with a body use JSON: ```http Content-Type: application/json ``` Responses are always `application/json; charset=utf-8`, including errors. There is no XML, form-encoded variant, or content negotiation. The one exception is the OAuth2 token endpoint, which accepts `application/x-www-form-urlencoded` because that's required by the OAuth2 specification. ## Methods | Method | Use | | --- | --- | | `GET` | Retrieve a resource or list. Never has side effects | | `POST` | Create a resource, or perform a named action | | `PATCH` | Partially update. Only the fields you send change | | `DELETE` | Rarely used. Most "deletions" are state changes, such as closing a card | `PUT` is not used. Full replacement is a poor fit for objects with server-managed fields and creates more problems than it solves. Actions that don't fit normal CRUD use sub-paths instead of a `type` field in the request body: ```http POST /v1/cards/{card_id}/freeze POST /v1/cards/{card_id}/unfreeze POST /v1/cards/{card_id}/replace ``` ## Object shape Every object includes `id`, `object`, and `created_at`: ```json { "id": "card_01HX8ZQ2K4M7P9RSTV", "object": "card", "status": "active", "created_at": "2026-07-29T14:32:05Z", "updated_at": "2026-07-29T15:01:44Z" } ``` `object` tells you what kind of resource you're looking at, so you can dispatch on the object type without parsing the ID prefix. This is especially useful for polymorphic handling, such as when an object comes from a webhook payload or a mixed search result. ## Field conventions | Rule | Example | | --- | --- | | `snake_case` field names | `cardholder_id`, not `cardholderId` | | Timestamps suffixed `_at` | `created_at`, `expires_at` | | Booleans prefixed `is_` or `has_` | `is_default`, `has_pin` | | Enums lowercase `snake_case` | `"review_required"`, never `"REVIEW_REQUIRED"` or `3` | | Money is always an object | `{"amount": 1050, "currency": "SGD"}` | | Nullable fields documented as such | Absent and `null` mean the same thing | Fields that are `null` are omitted from responses. You don't need to distinguish between a missing key and a `null` value. ## Request headers | Header | Required | Purpose | | --- | --- | --- | | `Authorization` | Yes | `Bearer ` | | `Content-Type` | On requests with a body | `application/json` | | `Idempotency-Key` | Strongly recommended on `POST` | See [idempotency](./idempotency) | | `Aleta-Version` | No | Pin to a dated release | ## Response headers | Header | Always present | Purpose | | --- | --- | --- | | `Aleta-Request-Id` | Yes | Unique per request. Include it in every support ticket | | `RateLimit-Limit` | Yes | See [rate limits](./rate-limits) | | `RateLimit-Remaining` | Yes | | | `RateLimit-Reset` | Yes | | | `Retry-After` | On `429` and `503` | Seconds to wait | | `Deprecation` / `Sunset` | On deprecated endpoints | See [versioning](./base-urls-and-versioning) | `Aleta-Request-Id` is probably the most useful thing to log. When you have a question about a specific request, that ID lets support find the call directly instead of having to dig through a whole thread. ## Unknown fields Unknown fields in a request body are rejected with `400` and the error code `unknown_parameter`. The offending field is included in `param`. This is intentional. Silently ignoring an unknown field turns a simple typo, like `ammount` instead of `amount`, into a card issued with the wrong limit without giving you any indication that something went wrong. --- ## Timestamps and money Timestamps and money are two primitives you want to get right. Bugs in either one can be surprisingly hard to spot. ## Timestamps Every timestamp uses RFC 3339, is in UTC, and ends with `Z`: ```json { "created_at": "2026-07-29T14:32:05Z", "expires_at": "2029-04-30T23:59:59Z" } ``` - Always UTC. Never local time or an offset other than `Z`. - Second precision. Sub-second precision isn't returned. - Timestamp fields end in `_at`. Dates that don't include a time, such as a date of birth, use `YYYY-MM-DD` and don't use the `_at` suffix: ```json { "date_of_birth": "1990-04-12" } ``` Send timestamps in the same format. Timestamps with an offset, such as `+08:00`, are accepted and normalized to UTC in the response. Timestamps without a timezone are rejected with `400 invalid_parameter`. We don't guess the timezone, because that's how you end up with dates and times being eight hours off. ## Money Money is always represented as an object, never as a bare number: ```json { "amount": 1050, "currency": "SGD" } ``` `amount` is an integer in minor units. For example, `1050` means S$10.50. `currency` is an uppercase ISO 4217 code. ### Never use a float This isn't just a style preference. Binary floating-point numbers can't represent most decimal fractions exactly: ```javascript 0.1 + 0.2 === 0.3; // false (0.1 + 0.2).toFixed(20); // "0.30000000000000004441" ``` That kind of error might look harmless for one transaction, but accumulate it over a month and your reconciliation can end up off by an amount nobody can explain. Integers in minor units don't have this problem. Keep the integer representation all the way through your stack: database columns, application types, and JSON parsing. Be careful with parsers that convert large integers to floats, since amounts above 2⁵³ minor units can be corrupted. ### Minor units are not always two decimals | Currency | Minor units | `amount: 1000` means | | --- | --- | --- | | SGD, USD, EUR, GBP | 2 | 10.00 | | JPY, KRW | 0 | 1,000 | | BHD, KWD, JOD | 3 | 1.000 | Don't assume every currency has two decimal places. Use the currency's actual exponent. ISO 4217 publishes it, and established money libraries generally provide it for you. ### Rounding We don't round amounts you send. They're used exactly as provided. When the platform has to produce a fractional result, such as during a currency conversion or percentage fee calculation, the result is rounded half up to the currency's minor unit. The response includes both the value before rounding and the final rounded value. ### Negative amounts `amount` can be negative when the direction matters, such as for a refund line or ledger entry. If a field represents a magnitude rather than a signed value, negative amounts are rejected with `400 invalid_parameter`. ### Comparing money Two money objects can only be compared when their `currency` values match. The API never converts currencies implicitly. If an operation mixes currencies, it returns `422` with `currency_mismatch`. --- ## Error code catalog Every code the `/v1` API can return, one section each. `code` is stable and safe to branch on; the `message` accompanying it is not. **Retryable** means retrying the identical request may succeed without any other change. Where it says not retryable, retrying will fail identically — fix the request, the state, or the credential. ## `doc_url` links to the code Every error carries a `doc_url` pointing at its own section on this page: ```json { "error": { "type": "validation_error", "code": "kyc_not_approved", "message": "Cardholder chd_01HX8ZQ2K4M7P9RSTV is not verified (status not_started).", "param": "card_id", "doc_url": "https://developer.aletaplanet.com/api-reference/errors/error-code-catalog#kyc_not_approved", "request_id": "req_01HX8ZQ2K4M7P9RSTV" } } ``` The fragment is always the code itself, so you can build the link for any code here without waiting for an error to hand you one. Surfacing `doc_url` in your own logs and support tooling turns a code into a page an engineer can read. `type` is the broad category — decide *how* to handle a failure from it, and *what* happened from `code`. See [the error object](./error-object) for the envelope and [HTTP status mapping](./http-status-mapping) for what each status means on its own. ## Token endpoint — RFC 6749 shape `POST /oauth/token` does **not** use the `ErrorObject` envelope. It answers with the flat body OAuth client libraries expect, and carries no `code` or `doc_url`: ```json { "error": "invalid_client", "error_description": "…" } ``` | Status | `error` | Meaning and fix | | --- | --- | --- | | `401` | `invalid_client` | The client is unknown or revoked, the assertion's signature did not verify, it was signed for the other environment, or the source address is not on the allowlist. The description says which — but only once the signature is proven; before that, every refusal reads the same on purpose | | `400` | `invalid_grant` | This assertion's `jti` was already redeemed. Sign a fresh one | | `400` | `invalid_scope` | A requested scope is not held by the client, or is not a scope at all | | `400` | `invalid_request` | The assertion is not a well-formed JWT, or a required form field is missing | | `400` | `unsupported_grant_type` | `grant_type` was not `client_credentials` | | `500` | `server_error` | Not the request's fault — an unexpected failure on our side. Log the request and retry once; treat a second `server_error` as a real outage, not a mistaken assertion | An assertion that has expired, or is more than five minutes from expiring, is just `invalid_client` with the same uninformative description every other pre-signature failure gets — the clock skew is not named, on purpose. A clock more than sixty seconds off is the most common cause, so check the signing host's time before anything else, even though the error will not point you there. ## Request Malformed or unacceptable input, refused before anything was done with it. ### `invalid_request_body` {#invalid_request_body} `400` · `invalid_request_error` · not retryable Body is not valid JSON, or not an object where one was required. ### `missing_parameter` {#missing_parameter} `400` · `invalid_request_error` · not retryable A required field is absent. `param` names it. `program_id` on `POST /v1/accounts` is the one to watch: it is required even if your organization holds a single program, because defaulting to your only one would be right until the day a second product went live. `GET /v1/programs` lists the ids you may send, for the environment your token names. ### `invalid_parameter` {#invalid_parameter} `400` · `invalid_request_error` · not retryable Wrong type, format, or out of range. `param` names it. This is also the code a `422` falls back to when several fields fail at once — `errors[]` then carries one entry per field. ### `unknown_parameter` {#unknown_parameter} `400` · `invalid_request_error` · not retryable A field in the body is not one this endpoint accepts. `param` names it. Unknown fields are refused rather than ignored, so a typo cannot silently do nothing. ### `invalid_public_key` {#invalid_public_key} `400` · `invalid_request_error` · not retryable The RSA key supplied for card details is malformed, or weaker than 2048-bit. ## Authentication The credential itself. See [authentication](/docs/get-started/authentication) for how tokens are obtained. ### `missing_credentials` {#missing_credentials} `401` · `authentication_error` · not retryable No `Authorization` header. Send `Bearer `. ### `invalid_access_token` {#invalid_access_token} `401` · `authentication_error` · not retryable The token is expired, unknown, or its client has been revoked. Get a new one from the token endpoint; if that is refused too, the client itself is the problem. Tokens live fifteen minutes — treat this as "fetch a new token, then retry once", and a second `401` as final. ## Permission The credential is valid and is not allowed to do this. ### `insufficient_scope` {#insufficient_scope} `403` · `permission_error` · not retryable The client does not hold the scope this endpoint needs. Scopes are frozen at token issue, so add it to the client in Cockpit and get a new token. ### `ip_not_allowed` {#ip_not_allowed} `403` · `permission_error` · not retryable The source address is not on the allowlist. Two lists apply: your organization's and the client's own. A client can only ever be more restricted than its organization. ### `environment_mismatch` {#environment_mismatch} `403` · `permission_error` · not retryable This token was issued for the other environment. Sandbox tokens start `at_test_`, production `at_live_`; both use the same base URL. ## Not found A resource that exists but belongs to another organization answers exactly as one that does not exist. The two are indistinguishable by design. ### `not_found` {#not_found} `404` · `invalid_request_error` · not retryable No route matches this path. Simulator endpoints answer this way in production, where they do not exist. ### `cardholder_not_found` {#cardholder_not_found} `404` · `invalid_request_error` · not retryable No cardholder with this id in this environment. ### `account_not_found` {#account_not_found} `404` · `invalid_request_error` · not retryable No account with this id in this environment. ### `card_not_found` {#card_not_found} `404` · `invalid_request_error` · not retryable No card with this id in this environment. ### `authorization_not_found` {#authorization_not_found} `404` · `invalid_request_error` · not retryable No authorization with this id in this environment. ### `transaction_not_found` {#transaction_not_found} `404` · `invalid_request_error` · not retryable No transaction with this id in this environment. ### `dispute_not_found` {#dispute_not_found} `404` · `invalid_request_error` · not retryable No dispute with this id in this environment. ### `esim_package_not_found` {#esim_package_not_found} `404` · `invalid_request_error` · not retryable No eSIM package with this id in this environment. ### `esim_order_not_found` {#esim_order_not_found} `404` · `invalid_request_error` · not retryable No eSIM order with this id in this environment. If you are recovering from a timed-out create and never saw an id, look the order up by your own `reference` instead. ### `esim_not_found` {#esim_not_found} `404` · `invalid_request_error` · not retryable No eSIM with this id in this environment. ### `token_not_found` {#token_not_found} `404` · `invalid_request_error` · not retryable No network token with this id in this environment. A key minted under one program answers this way when presented against a card in another — from that card's side the key genuinely does not exist, and saying so is the same answer as for an id that was never issued. If you run more than one program, mint the key with the card's own `program_id`. ## Idempotency See [idempotency](../conventions/idempotency) for how keys are matched. ### `idempotency_key_reuse` {#idempotency_key_reuse} `409` · `invalid_request_error` · not retryable Same `Idempotency-Key`, different body. Almost always a bug in your retry logic. ### `idempotency_request_in_progress` {#idempotency_request_in_progress} `409` · `invalid_request_error` · **retryable** The original request is still running. Retry after a short backoff. Also returned when a credit or debit carrying the same `client_transaction_id` is still in flight. ### `adjustment_already_applied` {#adjustment_already_applied} `409` · `invalid_request_error` · not retryable A credit or debit with this `client_transaction_id` was already applied to the account with a different amount or direction. The `message` says what was applied. A repeat with the *same* amount is not an error — it returns `200` with `Idempotent-Replay: true` and moves nothing. ## Cardholders ### `identity_document_required` {#identity_document_required} `422` · `validation_error` · not retryable A card cannot be issued to a cardholder with no `identity_document`. Set it when creating the cardholder; only the number and type are held, and neither ever comes back out of the API. ### `address_required` {#address_required} `422` · `validation_error` · not retryable The cardholder has no `address`, and the card is registered to it. Add one with `PATCH /v1/cardholders/{id}`. Not the same as `delivery_address_required`, which is about the address on the issuance request. ### `kyc_not_approved` {#kyc_not_approved} `422` · `validation_error` · not retryable `kyc_status` is not `approved`. Report an approved attestation before issuing a card. ### `cardholder_already_exists` {#cardholder_already_exists} `409` · `invalid_request_error` · not retryable Another cardholder in your organization already has this `email` or `phone`. `param` names which. ### `address_change_requires_kyc` {#address_change_requires_kyc} `422` · `validation_error` · not retryable This program's processor cannot take an address change through `PATCH /v1/cardholders/{id}`. Report a KYC attestation that carries the new address instead. ### `kyc_identity_required` {#kyc_identity_required} `422` · `validation_error` · not retryable The program's processor runs its own identity verification, and the cardholder's current attestation carries no `identity`. Report one through `POST /v1/cardholders/{cardholder_id}/kyc` before opening the account — an attestation reporting only an outcome is a valid record, and not enough here. The same code answers an attestation whose supporting documents are no longer held. Report a fresh one with the documents attached. ## Accounts ### `insufficient_funds` {#insufficient_funds} `402` · `validation_error` · not retryable `available_balance` is too low for this debit. Credit the account, then retry. Note the `402`: retrying after funding is correct, where retrying a `422` never is. ### `account_blocked` {#account_blocked} `422` · `validation_error` · not retryable The account is blocked and cannot fund a card or take this operation. Unblock it first. ### `currency_mismatch` {#currency_mismatch} `422` · `validation_error` · not retryable Currencies differ across the operation. Nothing is converted implicitly — a cardholder needing two currencies needs two accounts. ### `account_cardholder_mismatch` {#account_cardholder_mismatch} `422` · `validation_error` · not retryable The account belongs to a different cardholder than the one named. Both objects exist and both are yours, so this is a genuine mismatch rather than a tenancy failure. ### `account_not_provisioned` {#account_not_provisioned} `422` · `validation_error` · not retryable The account does not exist at the processor yet, so it cannot hold a card. `has_upstream_reference` reports this on the account. ### `account_not_fundable` {#account_not_fundable} `422` · `validation_error` · not retryable This account cannot take a credit in its current state. ## Cards ### `card_invalid_state` {#card_invalid_state} `422` · `validation_error` · not retryable The card's status does not permit this operation: not yet issued or activated, frozen, expired, or already in the state asked for. ### `card_already_activated` {#card_already_activated} `422` · `validation_error` · not retryable Activate card was called on a card that is already `active`. The card is usable; there is nothing to repeat. ### `card_closed` {#card_closed} `422` · `validation_error` · not retryable Closure is not reversible, by any call. Issue a new card. ### `card_not_provisioned` {#card_not_provisioned} `422` · `validation_error` · not retryable The processor has not issued the card yet, so the operation cannot reach it. Wait for `card.issued`, then try again. ### `card_limit_reached` {#card_limit_reached} `422` · `validation_error` · not retryable The cardholder already has a card with the processor behind this program, which holds one card per cardholder. ### `unsupported_form_factor` {#unsupported_form_factor} `422` · `validation_error` · not retryable The program does not issue the form factor asked for. `message` lists the ones it does issue. ### `embossed_name_required` {#embossed_name_required} `422` · `validation_error` · not retryable `embossed_name` is required for every card, virtual or physical. It is fixed when the card is issued. ### `delivery_address_required` {#delivery_address_required} `422` · `validation_error` · not retryable Every card needs a complete `delivery_address` on the request, virtual or physical. It is fixed when the card is issued. Not the same as `address_required`, which is the cardholder having no address at all. ### `card_type_not_configured` {#card_type_not_configured} `422` · `validation_error` · not retryable Your program has no card product for the `card_type` asked for — most often `credit` on a debit-only program. Nothing was sent to the processor. A configuration change on our side, so raise it with your account contact. ## PIN ### `pin_invalid` {#pin_invalid} `422` · `validation_error` · not retryable The PIN is unusable, and `message` says why: it is not the program's PIN length, it is not all digits, or it was not encrypted to the key you were given. Nothing was sent to the processor. ## Transactions and tokens ### `transaction_not_refundable` {#transaction_not_refundable} `422` · `validation_error` · not retryable The transaction's state does not permit a refund: not settled, or already fully refunded. ### `refund_exceeds_original` {#refund_exceeds_original} `422` · `validation_error` · not retryable The refund is larger than what remains of the transaction. ### `token_invalid_state` {#token_invalid_state} `422` · `validation_error` · not retryable The token's status does not permit this operation. ### `token_deleted` {#token_deleted} `422` · `validation_error` · not retryable Deletion is terminal. The cardholder must add the card to the wallet again. ## Program and controls ### `program_not_provisioned` {#program_not_provisioned} `422` · `validation_error` · not retryable No program is attached to your organization in this environment, or the one you named cannot be served by the deployment that answered you. A configuration matter on our side — raise it with your account contact rather than retrying. ### `program_not_configured` {#program_not_configured} `422` · `validation_error` · not retryable Your program's processor configuration does not cover the operation you asked for — a PIN operation on a program with no PIN key, for example. Nothing was sent to the processor. A configuration matter on our side; raise it with your account contact rather than retrying. Card issuance reports its own case as [`card_type_not_configured`](#card_type_not_configured). Most endpoints reach this without you naming a program at all. A program is named once, on `POST /v1/accounts`, and every card and account operation afterwards resolves it through the account — so a program that has stopped being serviceable by the deployment answering you surfaces here rather than at the call that named it. ### `spend_control_unsupported` {#spend_control_unsupported} `422` · `validation_error` · not retryable A control in the request is one no upstream operation can enforce. Accepting it would tell you the card is restricted when nothing is restricting it. ### `operation_unsupported` {#operation_unsupported} `422` · `validation_error` · not retryable The processor behind your program has no such operation. This is a fact about which program you are pointed at rather than something failing, so repeating the request returns the identical answer. It appears in the simulator: some events cannot be injected because they originate outside the platform — a chargeback begins with a cardholder at another bank, and arrives here only as a notification — and no request of yours causes one. The simulator route itself is mounted because your program's processor offers simulation generally; this is one event its sandbox has no injector for. ## eSIM ### `esim_package_unavailable` {#esim_package_unavailable} `422` · `validation_error` · not retryable The package exists but is not on sale — we have frozen or withdrawn it. Read the package to see its `status`; a frozen one may return, a withdrawn one will not. ### `esim_top_up_unavailable` {#esim_top_up_unavailable} `422` · `validation_error` · not retryable This add-on cannot be bought on this eSIM. Either it has stopped being sold, or the package it extends is not running — a package that has never reached a network has nothing under way to add to. List the eSIM's top-ups for what it can take right now. ### `esim_inventory_exhausted` {#esim_inventory_exhausted} `422` · `validation_error` · retryable once stock returns The package is on sale and your request is valid — we have no eSIM left to provision it onto. Nothing was charged. This is about our inventory, not about the package, which is why it is a distinct code: reading the package will show it perfectly healthy, and ordering a different one will not help, because the shortage is upstream of the catalog. Retry later. If it persists, raise it with us rather than working around it — a pool that stays empty is ours to refill. ### `esim_order_reference_exists` {#esim_order_reference_exists} `409` · `invalid_request_error` · not retryable An order in your organization already carries this `reference` — package orders and top-ups share one ledger, so a reference spent on either is spent. Either the purchase went through and you are seeing your own earlier order, or the id was reused by mistake — check before sending a different one. Retrying an ambiguous request belongs to [`Idempotency-Key`](../conventions/idempotency), not to a new `reference`. ## Rate limits ### `rate_limited` {#rate_limited} `429` · `rate_limit_error` · **retryable** Too many requests. Wait for `Retry-After`, then retry with jitter. ## Server None of these are your request's fault, and all three are safe to retry. A write that timed out must be retried with the **same** `Idempotency-Key` — see [retrying 504 and network timeouts](./http-status-mapping#retrying-504-and-network-timeouts). ### `processor_unavailable` {#processor_unavailable} `502` · `api_error` · **retryable** A system we depend on failed or timed out. Its own error is never passed through; the `request_id` on the response is the reference to quote. ### `processor_permission_denied` {#processor_permission_denied} `502` · `api_error` · **retryable** We were refused by a system we depend on. Not something you can fix from your side — send us the `request_id`. ### `internal_error` {#internal_error} `500` · `api_error` · **retryable** An unexpected failure on our side. Retry with backoff; if it persists, send us the `request_id`. ## Codes not on this page Cockpit's own API — sign-in, team management, credential management — has its own codes, which a `/v1` client never sees. They are not published here because nothing you integrate against can raise them. --- ## The error object Every failure returns the same object. There is no second error shape, and no endpoint that fails differently. ```json { "error": { "type": "invalid_request_error", "code": "card_invalid_state", "message": "Card card_01HX8ZQ2K4M7P9RSTV is not frozen.", "param": "card_id", "doc_url": "https://developer.aletaplanet.com/api-reference/errors/error-code-catalog#card_invalid_state", "request_id": "req_01HX8ZQ2K4M7P9RSTV" } } ``` ## Fields | Field | Always present | Purpose | | --- | --- | --- | | `type` | Yes | Broad category. Use to decide *how* to handle | | `code` | Yes | Stable, machine-readable identifier. **Branch on this** | | `message` | Yes | Human-readable. For logs and engineers, not end users | | `param` | No | The offending field, when one can be identified | | `doc_url` | Yes | Deep link to this specific error | | `request_id` | Yes | Matches the `Aleta-Request-Id` header | ## Types | `type` | HTTP | Meaning | | --- | --- | --- | | `invalid_request_error` | `400`, `404`, `409` | The request is malformed or contradicts current state | | `authentication_error` | `401` | Credentials missing, invalid, or expired | | `permission_error` | `403` | Credentials valid but not permitted | | `validation_error` | `422` | Well-formed but fails a business rule | | `rate_limit_error` | `429` | Too many requests | | `api_error` | `500`, `502`, `503` | Something failed on our side | ## Branch on `code`, not `message` `code` is part of the contract and will not change. `message` is prose and may be reworded at any time for clarity. ```javascript // Correct if (err.code === 'insufficient_funds') { promptTopUp(); } // Wrong — breaks the moment the wording improves if (err.message.includes('insufficient')) { promptTopUp(); } ``` ## Do not show `message` to end users `message` is written for the engineer reading the log. It names internal object IDs and assumes familiarity with the object model. Map `code` to your own user-facing copy, in your own voice and language. ## Multiple validation failures When several fields fail at once, `422` returns them together in `errors`. The top-level `code` falls back to the generic `invalid_parameter` — with more than one failure there is no single code to promote, so `errors[]` carries the detail. A `422` with exactly one failure promotes that failure's own code instead: ```json { "error": { "type": "validation_error", "code": "invalid_parameter", "message": "The request failed validation.", "doc_url": "https://developer.aletaplanet.com/api-reference/errors/error-code-catalog#invalid_parameter", "request_id": "req_01HX8ZQ2K4M7P9RSTV", "errors": [ { "code": "invalid_parameter", "param": "address.country", "message": "address.country must be an uppercase ISO 3166-1 alpha-3 country code, for example SGP." }, { "code": "missing_parameter", "param": "date_of_birth", "message": "This field is required for individual cardholders." } ] } } ``` ## What never appears in an error Errors from the platform never expose the internals of systems underneath it. You will not see a raw downstream status code, a downstream identifier, or a vendor name in any field. Where an underlying system fails in a way we cannot map to something actionable, you get a clean `502` with `code: "processor_unavailable"` and a `request_id`. That `request_id` is enough for us to trace the whole call — quoting it in a support ticket is all that is needed. This matters for your own error handling: the set of codes you must handle is finite, documented, and stable, rather than an open-ended passthrough of whatever a downstream system decided to emit. ## Always log `request_id` Log it on every response, not just failures. It is the difference between "a card creation failed sometime yesterday afternoon" and a single lookup that returns the exact request, its body, and its response. It is also mandatory in support tickets — see [contacting support](/docs/support/contacting-support). --- ## HTTP status mapping The HTTP status and the error `type` always agree. There is no case where a `200` carries a failure in the body — a common pattern in older payment APIs and a reliable source of bugs, because a client that checks only the status code silently treats failures as successes. ## Statuses | Status | `type` | Meaning | Retry? | | --- | --- | --- | --- | | `200` | — | Success | — | | `201` | — | Created | — | | `202` | — | Accepted; processing asynchronously | — | | `204` | — | Success, no body | — | | `400` | `invalid_request_error` | Malformed request, unknown parameter, bad JSON | **No** | | `401` | `authentication_error` | Missing, invalid, or expired credentials | **No** | | `403` | `permission_error` | Valid credentials, insufficient permission | **No** | | `404` | `invalid_request_error` | Resource does not exist, or is not yours | **No** | | `409` | `invalid_request_error` | Conflict — idempotency key reuse, concurrent modification | Only for `idempotency_request_in_progress` | | `422` | `validation_error` | Well-formed but fails a business rule | **No** | | `429` | `rate_limit_error` | Rate limit exceeded | **Yes**, after `Retry-After` | | `500` | `api_error` | Unexpected failure on our side | **Yes**, with backoff | | `502` | `api_error` | A system we depend on failed | **Yes**, with backoff | | `503` | `api_error` | Temporarily unavailable, e.g. maintenance | **Yes**, after `Retry-After` | | `504` | `api_error` | Timed out downstream | **Yes**, with backoff — **see below** | ## `400` versus `422` The distinction is consistent and worth relying on: - **`400`** — the request could not be understood. Malformed JSON, an unknown field, a string where an integer belongs. The request is wrong in isolation. - **`422`** — the request was understood and is well-formed, but conflicts with a rule or with current state. Issuing a card to a cardholder whose KYC has not passed is a `422`; the request is perfectly valid, the situation is not. Practically: a `400` means fix your code. A `422` means fix the state, or tell the user something. ## `404` on objects that exist A `404` is returned both when an object does not exist and when it exists but belongs to another organization. The two are deliberately indistinguishable — distinguishing them would let anyone probe for valid IDs across tenants. ## Retrying `504` and network timeouts A `504` or a client-side timeout is the genuinely ambiguous case: the request may have succeeded before the connection dropped. **Do not resolve this by guessing.** Retry with the *same* [`Idempotency-Key`](../conventions/idempotency). If the original succeeded, you get the original response back, with `Idempotent-Replay: true`. If it never landed, it runs now. Either way, exactly one card is issued. Without an idempotency key there is no safe answer, which is why the key should be on every `POST` rather than only the ones that felt important at the time. ## A correct retry predicate ```javascript function isRetryable(err) { if (err.status === 429) return true; if (err.status >= 500) return true; if (err.code === 'idempotency_request_in_progress') return true; return false; } ``` Retrying anything else wastes your rate limit and delays surfacing a real bug. Every code's retryable flag is listed in the [error code catalog](./error-code-catalog).