Skip to main content

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.
  • curl and 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.

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(),
))

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:

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.

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"])

Export the access token:

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:

curl -s "$ALETA_BASE_URL/v1/programs" \
-H "Authorization: Bearer $ALETA_ACCESS_TOKEN" | jq
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:

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.

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')
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.

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

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')
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.

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')
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:

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:

curl -s -X POST "$ALETA_BASE_URL/v1/cards/$CARD_ID/activate" \
-H "Authorization: Bearer $ALETA_ACCESS_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" | jq
Response
{
"id": "card_01HX8ZQ2K4M7P9RSTVWXYZAB",
"object": "card",
"status": "active",
"form_factor": "virtual",
"created_at": "2026-07-29T14:32:08Z"
}
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.

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.

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

StatuscodeFix
401invalid_access_tokenThe access token has expired. Run step 1 again
422identity_document_requiredStep 2 is missing identity_document. A cardholder needs one before a card can be issued
422address_requiredStep 2 is missing address. Add it with PATCH /v1/cardholders/{id}
402insufficient_fundsThe account hasn't been funded, or the card is linked to a different account
422kyc_not_approvedThe cardholder hasn't been verified. Run step 3 before issuing the card

See the error code catalog for the complete list.