Kryxo Cards API · v1
API Documentation
Installation guide and endpoint reference for partners who build their own screens on top of Kryxo. One API for issuing, balances and secure card display.
- REST + JSON, always over HTTPS.
- Calls are made from the partner's server, never from the customer's browser. The key gives access to the whole partner account.
- Every response contains only the data of the partner that owns the key.
- The rules defined in your agreement apply equally to the API and the dashboard: enabled functions, limits, deposit networks and BINs.
Getting access
Version
All routes are prefixed with /v1. Breaking changes ship under a new prefix; /v1 stays available while you migrate. Every response carries a Kryxo-Version header and a Kryxo-Request-Id you can quote to support.
Limits
| Item | Rule |
|---|---|
| Rate | 120 calls per minute per key. Above that, 429 rate_limited. |
| Pagination | ?page=1&limit=50, maximum 200. Responses carry data, total, page and limit. |
| Amounts | US dollars, numbers with a decimal point (20.5). Card load is an integer. |
| Dates | ISO 8601 with offset, Swiss time (Europe/Zurich): 2026-10-22T03:58:41+02:00. |
| Concurrency | One issuance or top-up at a time per account. A second one returns 409 in_progress; wait for the event. |
HTTP methods
| Method | Use |
|---|---|
| GET | Read. Parameters in the query string. Never changes state. |
| POST | Create or execute. Body in JSON. Always send Idempotency-Key. |
Common headers
| Header | Value | When |
|---|---|---|
Authorization | Bearer pk_… | Every call |
Content-Type | application/json | Every POST |
Idempotency-Key | UUID v4 | Every POST |
There is no request signing. The key is the secret: keep it on the server, restrict it to your IPs, and rotate it on suspicion.
Idempotency
Send Idempotency-Key: <uuid> in every POST. Repeating the same key within 24 hours returns the first response, with "repeated": true, without executing again. After a 5xx, retry with the same key.
API keys
| Key | Format | What it does |
|---|---|---|
| Test | pk_test_<id>.<secret> | Reads real account data. Writes are validated in full but not executed: "simulated": true, nothing charged. |
| Production | pk_live_<id>.<secret> | Executes for real: deposits, issuance, top-ups and blocks. |
Each key has a scope: read (read only) or write (read and execute). The secret is shown once, at creation. If lost, ask for revocation and a new key. A key can be restricted to a list of server IPs.
Keep the key in an environment variable or a secrets vault. Never in source code, never in the browser.
# .env on the server, outside the repository API_KEY=pk_test_xxxxxxxxxxxx.yyyyyyyyyyyyyyyy API_SECRET= # stays empty: the secret is inside the key, after the dot API_URL=https://api.your-base-url # base URL delivered with your credentials
First call
curl -s "$API_URL/v1/account" -H "Authorization: Bearer $API_KEY"
{
"partner": "Green Store",
"mode": "test",
"scope": "write",
"status": "active",
"permissions": ["manage_customers", "manage_cards", "customer_deposit", "customer_issue"],
"limits": {"max_customers": null, "cards_per_customer": null, "max_topup": 50, "daily_topup": null, "daily_limit": 5000},
"networks": ["polygon", "sol", "base", "tron"],
"customers": 1
}
In limits, null means no limit.
Examples
// Node.js 18+
const api = async (method, route, body) => {
const r = await fetch(`${process.env.API_URL}/v1${route}`, {
method,
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`,
'Content-Type': 'application/json',
...(method === 'POST' ? {'Idempotency-Key': crypto.randomUUID()} : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json();
if (!r.ok) throw Object.assign(new Error(j.message), {code: j.code, status: r.status});
return j;
};
const dep = await api('POST', '/customers/CLI2041/deposits', {network: 'polygon', amount: 20});
$ch = curl_init(getenv('API_URL') . '/v1/customers');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('API_KEY')],
]);
$customers = json_decode(curl_exec($ch), true)['data'];
import json, os, urllib.request, uuid
def api(method, route, body=None):
h = {'Authorization': 'Bearer ' + os.environ['API_KEY'], 'Content-Type': 'application/json'}
if method == 'POST':
h['Idempotency-Key'] = str(uuid.uuid4())
data = json.dumps(body).encode() if body else None
r = urllib.request.Request(os.environ['API_URL'] + '/v1' + route, data, h, method=method)
return json.loads(urllib.request.urlopen(r).read())
Error codes
Every error has the same shape. Show message to the user and always branch on code, which never changes.
{"message": "human-readable text", "code": "stable_code"}
| HTTP | code | When |
|---|---|---|
| 400 | invalid_data | Missing field or wrong format |
| 401 | invalid_key | Key missing, wrong or revoked |
| 402 | insufficient_balance | Customer balance does not cover amount plus fees |
| 403 | function_disabled | Function not enabled in the agreement, or limit reached |
| 403 | read_only_scope | Write with a read-only key |
| 403 | code_required | Action without a ticket, or ticket expired, used, or for another email |
| 400 | wrong_code, code_blocked | Wrong code; blocked after 5 attempts |
| 403 | ip_not_allowed | IP outside the key's allow-list |
| 403 | account_suspended | Partner account suspended |
| 400 | below_cost | Fee below your cost (carries type and your_cost) |
| 400 | below_minimum | Deposit or withdrawal below the minimum (deposit carries minimum) |
| 409 | open_withdrawal | A withdrawal is already awaiting payment |
| 409 | in_progress | An issuance or top-up is already running for this account; wait for the event |
| 503 | network_unavailable | The chosen deposit network is unavailable right now; offer another |
| 404 | not_found, unknown_route | Resource of another partner, or nonexistent |
| 409 | already_exists, card_blocked | Conflict with current state |
| 429 | rate_limited | More than 120 calls in a minute |
| 5xx | failed, internal_error | System failure, never a data error: retry with the same Idempotency-Key |
Constants
Deposit status · status
| Value | Meaning |
|---|---|
pending | Address issued, nothing received yet (valid 30 min) |
detected | Transfer seen on the network, confirmations pending |
confirmed | Credited to the customer balance, net of the deposit fee. Carries credited, fee, net |
expired | Window closed without a transfer |
Issuance status · GET /v1/issuances/{ord_id}
| Value | Meaning |
|---|---|
processing | Order accepted, card being created |
completed | Card created; response carries card |
failed | Issuance failed; balance is returned |
Card status · status
| Value | Meaning |
|---|---|
active | Card can transact |
blocked | Blocked by the customer or by Kryxo; unblock with /block |
closed | Closed; cannot be reopened |
Transaction · type, status, reason
| Field | Values |
|---|---|
type | purchase purchase · refund refund · decline declined |
status | authorized · settled · declined |
reason | On declines, e.g. insufficient_balance, card_blocked |
amount / currency | Amount in the merchant currency (e.g. 42.00 EUR) |
amount_usd | What left the card in US$, converted by the network. Use this one to sum |
Withdrawal status · GET /v1/withdrawals
| Value | Meaning |
|---|---|
requested | Requested, awaiting payment |
paid | Paid; carries the transfer hash |
refused | Refused; carries reason |
Functions & limits
| Group | Keys |
|---|---|
| Account permissions | manage_customers, manage_cards, customer_deposit, customer_issue, plus "Results" for commission withdrawals |
| Per-customer functions | deposit, issue, topup, data, block, support |
| Limits | max_customers, cards_per_customer, max_topup, daily_topup, daily_limit; null = no limit |
| Value ranges | min_load, max_load, min_topup, max_topup, daily_topup, cards_per_customer |
| Fee types | issuance (standard card) · issuance_wallet (card with digital wallet) · topup · deposit |
Identifiers
| Object | Format |
|---|---|
| Customer | CLI0000 |
| Card | crt_… |
| Issuance order | ord_… |
| Deposit | dep_… |
| Transaction | trn_… |
| Code notice | ntc_… |
| Withdrawal | wdr_… |
Secure display · card data on your page
Number, expiry, CVV and PIN are drawn on your own card element by the secure display layer, inside a transparent overlay fitted over it. Your page and your server never receive the values.
Prepare once
- Include the script:
<script src="https://secure.kryxo.xyz/visor.js"></script> - In your site's CSP, allow
frame-src https://secure.kryxo.xyzand addhttps://secure.kryxo.xyztoscript-src.
Layer "on the card"
// When the cards screen opens (or on "Card details", before asking for the code): the layer loads hidden over the card const layer = SecureCard.mount(document.querySelector('#card-4242'), null, { font: 'widescreen', color: '#fff', fields: ['number', 'expiry', 'cvv', 'pin'], onOpen: () => {}, onHide: reason => {}, onError: err => {}, onTick: s => {} }); // After the customer confirms the code, your server calls POST /v1/cards/{crt_id}/details and returns only the url: layer.show(url); // values appear over the card instantly layer.hide(); // hide early (optional) layer.unmount(); // remove the layer when leaving the screen
- The layer has no background, header or buttons of its own. It shows number, expiry, CVV and PIN (PIN only on tap), each with a copy icon.
- Fonts:
widescreen,widescreen-ex,widescreen-mixed. Need another? Send us the file. - While open, the card element gets
data-securecard="open". Use it in CSS to hide your own•••• 4242and expiry. - After 59 s the layer closes and calls
onHide('timeout'); viahide()it callsonHide('page'). Eventssecurecard:open,securecard:hidden,securecard:erroralso fire on the element. Expired or used link →onError('expired'): request a new code. - Fields are positioned for the standard card layout: number over number, expiry over expiry, CVV and PIN right of the chip. The element must be the whole card.
Card + number line, one click
const layer = SecureCard.mount([
{el: document.querySelector('#card-4242')}, // digits over the card
{el: document.querySelector('#title-4242'), format: 'line', fields: ['number']} // "400000 •••• 4242" next to the card
], null, {font: 'widescreen', color: '#fff', onHide: reason => {}});
layer.show(url); // one link, one code: both open and close together
- In
lineformat the layer shows only the full number, inheriting font, color, size and spacing from the element, plus the copy icon. It may exceed the element width: avoidoverflow:hiddenon it and its parent. - The link is used once: the first layer fetches the data and the others receive the values inside the secure area. Your page has no access to them.
Code input & Copy button
// "Copy" click: request the code (your server POSTs to send the email), mount the layer and the code boxes const layer = SecureCard.mount([{el: card}, {el: title, format: 'line', fields: ['number']}], null, { font: 'widescreen', color: '#fff', onOpen: () => layer.copyButton(copyBtn, {label: 'Copy', font: 'widescreen-ex', color: '#1a1a1a', background: '#fff', border: '#1a1a1a'}), onCopy: (field, automatic) => toast('Card number copied'), onCopyFailed: () => toast('Click Copy to copy the card number'), onCodeError: err => toast(err.message) }); layer.codeInput(document.querySelector('#boxes-4242'), { copy: 'number', // 'all' = number, expiry, CVV and PIN border: '#1a1a1a', background: '#fff', color: '#1a1a1a', onCode: async code => (await myServer.cardData(crt_id, code)).url, // your server verifies the code and calls POST /v1/cards/{crt_id}/details onCancel: () => {} // Esc inside the boxes });
codeInputplaces the 6 code boxes inside the secure area, replacing yours. Give the element width and height, e.g.width:330px; height:52px.- On the 6th digit (or paste) the boxes call
onCode(code). Return theurl(or a Promise). The layer opens by itself. - With
copy, the full number is already on the clipboard when the data opens. Browsers only allow copying from a user gesture, and that gesture happens inside the secure boxes, so automatic copy only works withcodeInput. - If
onCodefails (wrong or expired code), the boxes turn red, clear, and nothing is copied. The message goes toonCodeError. copyButton(btn, {label, copy, font, color, background, border}): while the data is open, your Copy button is covered by an identical secure one. Each click copies again without your page seeing the value.onCopy(field, automatic)reports'number'or'all', or'number','val','cvv','pin'. Eventssecurecard:copiedandsecurecard:copy-failedalso fire.
Window (alternative): SecureCard.open(url) opens a "Card details" window over the page. The frame only renders on your domain's sites, has no cache or indexing, and your page has no access to its contents.
Endpoints
GET/v1/account
Mode, scope, enabled functions, limits, networks and values: min_load, max_load, min_topup, max_topup, daily_topup, cards_per_customer. Validate on your screen before calling.
GET/v1/networks?customer={id}
Enabled deposit networks: id, name, token (PoS, SPL, TRC20), currency, minimum, and default_network (the suggested network). minimum is the minimum deposit for that customer, deposit fee included; without customer, it applies to a new customer. Always show this value: deposits below it are refused.
GET/v1/bins
BINs you can issue: bin, brand, category, wallets (apple_pay, google_pay; empty list = no digital wallet), fee (issuance or issuance_wallet) and issuance_fee (what your account pays per card on that BIN).
GET/v1/fees
version, types, your_cost, customer_default and per_customer. Keep version: it changes when the fees.changed event arrives. Issuance has two fees: issuance (standard card) and issuance_wallet (card with digital wallet).
POST/v1/fees
{"target": "default", "values": {"issuance": 50}}
{"target": "CLI2041", "values": {"topup": 4}}
Nothing below your cost (below_cost). Changing the default also moves the customers who follow it. Returns version and what changed; every change becomes a fees.changed event.
POST/v1/code/send
Actions that require a code: block/unblock and card data. Sign-up uses the entry code (below). Top-up does not ask for a code. A 6-digit code goes to the customer's email; the customer types it on your page and you exchange it for a ticket (ticket).
{"email": "customer@example.com"}
Valid 5 minutes; resend after 60 s; up to 10 per day per email. For an email without an account yet, sending requires the sign-up function enabled.
POST/v1/code/verify
{"email": "customer@example.com", "code": "123456"}
→ {"ticket": "9c1e7a2b4d6f8e0a3b5c7d9e1f2a4b6c", "valid_s": 60, "signup_s": 1800}
The ticket is valid for 60 seconds and one action, only for the email that received the code. Send it in the action body as "ticket". Up to 5 attempts per code. Never store the ticket or the code.
GET/v1/customers
Paginated list: id, email, name, phone, address, since, balance.
GET/v1/customers/{id}
One customer. balance rises by confirmed deposit or by a credit posted by our team; both show here.
GET/v1/customers/{id}/rules
Functions and limits that apply to the customer (functions, limits), the ones set only for them (own), the account ones (from_partner) and their value ranges (values).
POST/v1/customers/{id}/rules
{"functions": {"issue": false}, "limits": {"max_topup": 500}}
Only restricts what your account has: turns a function off or lowers a limit. null removes a customer-specific limit. Functions: deposit, issue, topup, data, block, support. Limits: cards_per_customer, max_topup, daily_topup.
POST/v1/customers
{
"email": "customer@example.com",
"ticket": "9c1e7a2b4d6f8e0a3b5c7d9e1f2a4b6c",
"name": "Full Name",
"country_code": "+1",
"phone": "212 555 0100",
"postal_code": "10001",
"street": "Main Street",
"number": "100",
"random": false
}
Opens the account; the customer gets the default fees. Only email is required. If the customer confirmed the entry code within the last 30 minutes, sign-up passes without another code. After that, send the ticket of a new code. With "random": true, empty fields are filled with placeholder name, phone and address; sent fields are kept. The response returns the record as stored.
POST/v1/customers/{id}/deposits
{"network": "polygon", "amount": 20}
→ {"id": "dep_…", "address": "0x…", "amount_to_send": 20.00, "valid_until": "2026-10-22 14:32:00"}
Returns address, amount_to_send and valid_until (30 min). Show the address and amount to the customer, with a QR code if you like.
GET/v1/deposits?customer={id}
List with status. A confirmed deposit carries credited (what arrived), fee (deposit fee) and net (what entered the balance). Show all three to the customer.
GET/v1/deposits/{id}
One deposit, to follow until confirmed. After that, GET /v1/customers/{id} shows the new balance.
POST/v1/customers/{id}/cards
{"bin": "400000", "load": 10}
→ {"id": "ord_…", "status": "processing"}
Issues the card. Debits from the customer balance the load, the issuance fee and the percentage. Returns the order (ord_…). A BIN with a digital wallet (wallets not empty) charges issuance_wallet; others charge issuance. Show the price of each BIN before issuing.
GET/v1/issuances/{ord_id}
Follows the issuance: processing, completed or failed. When complete, carries card.
GET/v1/cards?customer={id}
id, last4, brand, expiry, balance, limit, status, billing_address.
GET/v1/cards/{crt_id}
One card.
POST/v1/cards/{crt_id}/topups
{"amount": 25}
Tops up from the customer's account balance. Does not ask for a code.
POST/v1/cards/{crt_id}/block
{"block": true, "ticket": "…"}
true blocks, false unblocks. Requires the code ticket.
POST/v1/cards/{crt_id}/details
{"ticket": "…"}
→ {"url": "https://secure.kryxo.xyz/v/…", "expires_s": 60}
Do not show or open the url: pass it to layer.show(url) (see Secure display). Valid once, for 60 s. Requires the code ticket.
GET/v1/topups?customer={id}
Top-up history.
GET/v1/transactions?customer={id}
Purchases, refunds and declines: merchant, amount and currency, amount_usd, type, status, declined, reason.
GET/v1/notices?customer={id}
Card code notices: last4, card, reason, when, recent.
{"id": "ntc_…", "customer": "CLI2187", "card": "crt_…", "last4": "4242",
"reason": "Purchase at AMAZON · USD 25.00", "sent_to_email": true,
"when": "2026-10-19 14:02:11", "recent": true}
recent stays true for 5 minutes. Suggested text: Code for card ending 4242 · Purchase at AMAZON · USD 25.00 · We sent the code to your email. Do not share it. If this was not you, block the card. Never ask the customer for this code, not even through support.GET/v1/commission
Your commission is computed and recorded by us on every completed operation of your customers: fee charged to the customer − your cost, on issuance, top-up and deposit. Returns available, pending, paid, credited, min_withdrawal (200), terms_h, network_fee per network, open_withdrawal, negative_margin.
GET/v1/commission/statement
One line per operation: when, operation, customer, reference, charged, your_cost, commission, negative_margin.
POST/v1/withdrawals
// to a wallet: paid within 48 h, network fee deducted; response carries net and eta {"destination": "wallet", "network": "polygon", "wallet": "0x…", "amount": 200, "ticket": "…"} // to a customer of yours (own use): credited within 24 h, no network fee {"destination": "card", "customer": "CLI2003", "amount": 200, "ticket": "…"}
- Only the account owner withdraws. The
ticketcomes from a code sent to the owner's email. - Minimum US$ 200 and one open request at a time. Refunded operations leave the commission.
- Negative margin: if you charge a customer less than your cost, that operation's commission is zero and the difference is absorbed by us. It appears in
negative_marginso you can fix the fee. - Requires the "Results" function enabled on your account.
GET/v1/withdrawals
Requests with status: requested, paid (with hash) or refused (with reason).
GET/v1/withdrawals/{wdr_id}
One request.
Events
GET/v1/events?after={cursor}
Returns what happened after the cursor, in order, up to 200 events per call. Keep the cursor and poll every 30 to 60 seconds. Webhooks are planned for v2.
{
"data": [
{"when": "2026-10-17 13:41:37", "type": "deposit.confirmed", "object": {"id": "dep_…", "credited": 48.50}},
{"when": "2026-10-17 14:20:05", "type": "card.issued", "object": {"id": "crt_…", "last4": "4242"}}
],
"cursor": "2026-10-17 14:20:05"
}
Event types
| Type | When |
|---|---|
deposit.confirmed | Deposit credited to the customer balance |
card.issued | Card created |
card.code_notice | Bank sent a confirmation code to the customer's email |
topup.completed | Top-up applied to the card |
transaction.recorded | Purchase, refund or decline recorded |
commission.credited | Commission recorded for an operation |
commission.negative_margin | An operation was charged below your cost |
withdrawal.requested · withdrawal.paid · withdrawal.refused | Withdrawal lifecycle |
fees.changed | Fees changed. Carries version, scope (your_cost, customer_default, customer or types), customer when single, changes ({"issuance": {"from": 50, "to": 55}}) and origin. Re-read GET /v1/fees. |
customer.rules_changed | Per-customer functions or limits changed |
Typical flow
POST /v1/code/sendandPOST /v1/code/verify: confirm the customer's email and receive theticket.POST /v1/customers(up to 30 min after entry, no new code): open the customer account.POST /v1/customers/{id}/deposits: show the customeraddressandamount_to_send.- Follow
GET /v1/deposits/{id}or the events untildeposit.confirmed. POST /v1/customers/{id}/cards: issue the card.- Follow
GET /v1/issuances/{ord_id}untilcompleted. - Use
GET /v1/cardsday to day. Block, unblock and card data ask for a new code on each action. Top-up does not.
Security checklist
- The key lives only on the server (environment variable or vault), never in the browser, the app or the repository.
- The production key is restricted to your servers' IPs.
- Use a read key where there is no writing, such as reports and internal dashboards.
- Every POST carries
Idempotency-Key, and a retry after a failure reuses the same key. - Your system verifies the customer's login before calling the API on their behalf. The API trusts the partner's server.
- Full card data is never requested nor stored by your system.
- On suspected leakage, request immediate revocation. The key stops working at once.
Certification
- Integrate everything with the test key: write responses carry
"simulated": true. - Validate the typical flow and the error handling.
- Request the production key, with scope and IPs defined.
- Run a first real operation of low value, followed by our team.
Contact
| Subject | Channel |
|---|---|
| Keys, certification and technical questions | contact@kryxo.xyz |
| Agreements and partnerships | kryxo.xyz |
| Suspected key leakage | contact@kryxo.xyz · we revoke immediately |