Deposit accounts (CLABE)
/api/v1/me/deposit-accountsGet or create a fixed CLABE for one of your end users. Every transfer into it is credited to your balance automatically.
How it works
A deposit account is a fixed CLABE that belongs to ONE of your end users. You ask us for it once, show it to that user, and it never changes: every SPEI transfer they send to it is credited to your balance and appears as a normal pay-in transaction, with no action on your side.
Four endpoints cover the whole lifecycle: POST /me/deposit-accounts (get or create), GET /me/deposit-accounts (list), GET /me/deposit-accounts/{id} (retrieve one) and GET /me/deposit-accounts/{id}/deposits (what came in).
endUserRef) and:- if that user already has an account on this shop → the SAME one comes back,
created: false, HTTP 200 — nothing is opened; - if not → a CLABE is minted,
created: true, HTTP 201.
shopId.If you run several shops, each one's accounts are invisible to the others: a listing returns only your own, and retrieving another shop's account id returns the same 404 as an id that does not exist. A merchant-wide credential (one not bound to a shop) cannot use these endpoints at all — it has no shop to credit the money to, so it gets 400 invalid_request.Get or create an account
/api/v1/me/deposit-accountssecret keyReturns the end user's deposit account, opening one if they don't have it yet. Keyed on endUserRef: calling it again for the same end user returns the same CLABE.
endUserRefstringrequiredYOUR identifier for the end user. Max 200 chars. Two calls with the same value always return the same account, so this is what makes the endpoint safe to retry.namestringrequiredThe end user's full name. Max 200 chars. You show it next to the CLABE, and we use it to label the deposit when the incoming transfer carries no payer name.emailstringrequiredREQUIRED TO OPEN AN ACCOUNT. It identifies the end customer on every surface the CLABE appears on, and becomes the userEmail of each transaction its deposits credit. Omitting it on a create returns 400 invalid_request with details.required: ["email"]. The requirement is on CREATION only: a call for an endUserRef that already has an account still returns it (200) with or without an email, so accounts opened before this rule keep working.phonestringCONDITIONAL — some shops' deposit accounts cannot be opened without a phone as well (see the note below). Max 40 chars.documentTypestringOptional. The end user's ID document type — RFC, CURP, DNI… Max 20 chars. Stored and echoed back on every read; no network requires it today.documentIdstringOptional. The document number itself. Max 60 chars.countrystringOptional. The end user's country as ISO-2 or ISO-3 (MX / MEX). Uppercased on write.metadataobjectOptional. Up to 20 string key/values (each value ≤ 500 chars), echoed back on every read. Use it to carry your own row ids.
curl -X POST "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts" \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Content-Type: application/json" \
-d '{
"endUserRef": "user_12345",
"name": "Juan Perez",
"email": "juan@example.com",
"phone": "+525512345678",
"documentType": "CURP",
"documentId": "PEJJ850101HDFRRN08",
"metadata": { "tier": "vip" }
}'{
"created": true,
"account": {
"id": "eda_9f2c1b7ad0e34f5a8c6b2d10",
"endUserRef": "user_12345",
"clabe": "646180111812345678",
"bank": null,
"currency": "MXN",
"country": "MEX",
"methodName": "SPEI — Deposito",
"customer": {
"name": "Juan Perez",
"email": "juan@example.com",
"phone": "+525512345678",
"documentType": "CURP",
"documentId": "PEJJ850101HDFRRN08",
"country": null
},
"metadata": { "tier": "vip" },
"status": "active",
"environment": "sandbox",
"createdAt": "2026-07-29T12:00:00.000Z",
"updatedAt": "2026-07-29T12:00:00.000Z"
}
}email and phoneare required depends on how your shop's deposit accounts are provisioned. Some setups open an account with just a name; others reject it without contact details. You do not have to know which one serves you:- Send all four fields (
endUserRef,name,email,phone) — as the sample above does — and the call works on every setup. Extra fields are simply stored and echoed back. - If you omit one that IS required, you get
400 invalid_requestwithdetails.requirednaming exactly which fields to add:{"required":["email","phone"]}. Add them and retry — nothing was created, and the retry is not a second account.
bank is null unless the network states the institution. Do NOT infer it from the CLABE prefix to fill your UI: show the CLABE (and the bank only when we send one). A bank name that doesn't match the account sends your customer into the wrong app./api/v1/me/deposit-accountssandboxendUserRefstringRequirednamestringRequiredemailstringphonestringdocumentTypestringdocumentIdstringmetadataobjectcurl -X POST "https://sandbox.key2pay.ai/api/v1/api/v1/me/deposit-accounts" \
-H "Authorization: Bearer sk_test_…YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"endUserRef": "user_12345",
"name": "Juan Perez",
"email": "juan@example.com",
"phone": "+525512345678",
"metadata": {
"tier": "vip"
}
}'Read your accounts
/api/v1/me/deposit-accountssecret keyYour shop's deposit accounts, newest first. Filter by endUserRef to resolve one customer's CLABE without keeping your own copy of it.
limitintegerQuery. 1–200, default 50.offsetintegerQuery. Default 0. Page with pagination.total, which is the count of the whole filtered set — you never have to probe for the end.endUserRefstringQuery. Return only that end user's account. This is the cheapest way to look up one customer.includestringQuery. Pass `stats` to add each account's rolled-up activity (how much came in, how many deposits, the last one). Opt-in because it costs an extra aggregate query.
# First page curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts?limit=50" \ -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" # One customer's CLABE curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts?endUserRef=user_12345" \ -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" # With per-account totals curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts?include=stats" \ -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y"
{
"accounts": [
{
"id": "eda_9f2c1b7ad0e34f5a8c6b2d10",
"endUserRef": "user_12345",
"clabe": "646180111812345678",
"bank": null,
"currency": "MXN",
"country": "MEX",
"methodName": "SPEI — Deposito",
"customer": { "name": "Juan Perez", "email": "juan@example.com", "phone": "+525512345678", "documentType": "CURP", "documentId": "PEJJ850101HDFRRN08", "country": null },
"metadata": { "tier": "vip" },
"status": "active",
"environment": "sandbox",
"createdAt": "2026-07-29T12:00:00.000Z",
"updatedAt": "2026-07-29T12:00:00.000Z",
"stats": {
"deposits": 2,
"credited": 2,
"totalUsd": 205.5,
"localTotals": [{ "currency": "MXN", "amount": 3500 }],
"lastDepositAt": "2026-07-28T19:04:11.000Z"
}
}
],
"pagination": { "total": 1, "limit": 50, "offset": 0 }
}/api/v1/me/deposit-accounts/{id}secret keyOne deposit account by its id. Scoped to your shop: an id from another of your shops returns the same 404 as an id that does not exist.
idstringrequiredPath. The account id returned on create (eda_…).
curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts/eda_9f2c1b7ad0e34f5a8c6b2d10" \ -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y"
{
"account": {
"id": "eda_9f2c1b7ad0e34f5a8c6b2d10",
"endUserRef": "user_12345",
"clabe": "646180111812345678",
"bank": null,
"currency": "MXN",
"country": "MEX",
"methodName": "SPEI — Deposito",
"customer": { "name": "Juan Perez", "email": "juan@example.com", "phone": "+525512345678", "documentType": "CURP", "documentId": "PEJJ850101HDFRRN08", "country": null },
"metadata": { "tier": "vip" },
"status": "active",
"environment": "sandbox",
"createdAt": "2026-07-29T12:00:00.000Z",
"updatedAt": "2026-07-29T12:00:00.000Z"
}
}status is "active" or "disabled". You cannot change it through the API; only our support team disables an account, and only when something is wrong with it. What "disabled" means for you: stop showing that CLABE to your end user and open a ticket. Get-or-create keeps returning the same disabled account (we never mint a replacement behind your back), and a transfer that still reaches it is not bounced — so a disabled account is a signal to act on, not a wall.Deposits received on a CLABE
This is the endpoint that answers “how much did this customer actually deposit?” — every transfer received on one account, with what the end user sent, its USD equivalent, the fee, the net credited, and the id of the pay-in transaction it produced.
/api/v1/me/deposit-accounts/{id}/depositssecret keyEvery transfer received on one deposit account, newest first, plus the rolled-up totals. The USD amount and the fee are the ones snapshotted when each deposit was credited — they are never re-derived from today's FX rate or today's pricing, so this report does not change under you.
idstringrequiredPath. The account id (eda_…).limitintegerQuery. 1–200, default 50.offsetintegerQuery. Default 0. Page with pagination.total.
curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts/eda_9f2c1b7ad0e34f5a8c6b2d10/deposits?limit=50" \ -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y"
{
"account": { "id": "eda_9f2c1b7ad0e34f5a8c6b2d10", "endUserRef": "user_12345", "clabe": "646180111812345678", "currency": "MXN", "status": "active" },
"stats": {
"deposits": 2,
"credited": 2,
"totalUsd": 205.5,
"localTotals": [{ "currency": "MXN", "amount": 3500 }],
"lastDepositAt": "2026-07-28T19:04:11.000Z"
},
"deposits": [
{
"id": "edp_4b81f0c2a9d7e6135f80ab24",
"transactionId": "TXN-DEP-9F2C1B7AD0E3",
"amount": 2000,
"currency": "MXN",
"amountUsd": 117.65,
"feeUsd": 3.53,
"netUsd": 114.12,
"status": "credited",
"payerName": "JUAN PEREZ",
"payerBank": "BBVA MEXICO",
"payerDocument": null,
"trackingKey": "2026072840012345678901234567",
"reference": null,
"receivedAt": "2026-07-28T19:04:11.000Z"
}
],
"pagination": { "total": 2, "limit": 50, "offset": 0 }
}status before you credit anything on your side. credited means the money is in your balance and transactionId points at the pay-in transaction (look it up with GET /payments/{id}). pending means the transfer arrived and is still being confirmed — it credits on its own, no call needed. rejected means it will not be credited. The totals in stats count only credited deposits, so they never promise money you cannot see yet.Getting notified
payment.completed on your webhook, with the same payload shape every payment uses plus source: "deposit". Two fields let you post it to the right customer without a lookup:endUserRef— the id YOU declared on the account. This is your join key back to your own player/user. It isnullon payments that are not deposits.merchantOrderId— for a deposit there is no order of yours to echo (the money arrived unprompted), so we generate aDEP-…reference of our own. It identifies the deposit, not the customer — that isendUserRef.
id (wh_…): it is unique per delivery attempt, so a retry after your endpoint timed out is recognisable as the same one.{
"id": "wh_7c1e4a95b6d24f038ab1c7e2",
"event": "payment.completed",
"merchantId": "MCH-ON-006",
"shopId": "SHP-MQ88KQII-9B50",
"createdAt": "2026-07-28T19:04:12.140Z",
"data": {
"id": "TXN-DEP-9F2C1B7AD0E3",
"amount": 117.65,
"currency": "USD",
"amountLocal": 2000,
"currencyLocal": "MXN",
"status": "completed",
"paymentMethod": "spei",
"country": "MEX",
"userEmail": "juan@example.com",
"merchantOrderId": "DEP-4b81f0c2a9d7e6135f80ab24",
"endUserRef": "user_12345",
"timestamps": { "paymentReceived": "2026-07-28T19:04:11.000Z" },
"source": "deposit"
}
}If you have no webhook yet, or want to reconcile a gap, the same information is on GET /me/deposit-accounts/{id}/deposits — polling it is a valid strategy, and the amounts there are the same snapshotted ones the webhook carried. GET /payments/{id} also echoes endUserRef and merchantOrderId, so the polling path can map a deposit to your customer exactly like the webhook does.
Testing in sandbox
Everything above works identically in sandbox and in production — same paths, same payloads, same errors. The one thing you cannot do from your own code is make money arrive: an inbound transfer has to come from the banking network, so a test deposit is fabricated by us, not by an API call of yours.
POST /me/deposit-accounts answers 422 deposit_accounts_unavailable — and that error means exactly one thing: your shop is not configured for this product yet. It is not your request, not your credential, and not transient, so retrying or changing the body will never turn it into a 201. Ask us to enable it for your sandbox shop and the same code starts working untouched.A suggested first run, in order — each step tells you something the next one assumes:
# 1. Open an account for a fake end user. Expect 201 + a CLABE.
# A 422 here means the shop is not enabled yet (see above) — stop and ask us.
curl -X POST "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts" \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Content-Type: application/json" \
-d '{ "endUserRef": "sandbox_user_1", "name": "Test User", "email": "test@test.com", "phone": "+525512345678" }'
# 2. Call it AGAIN with the same endUserRef. Expect 200 + created:false
# + the identical CLABE. This is the idempotency you will rely on in prod.
curl -X POST "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts" \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
-H "Content-Type: application/json" \
-d '{ "endUserRef": "sandbox_user_1", "name": "Test User", "email": "test@test.com", "phone": "+525512345678" }'
# 3. Ask us to fire a test deposit into that CLABE, then read the report.
# Expect the transfer with status "credited" and a transactionId.
curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts/<id>/deposits" \
-H "Authorization: Bearer sk_test_51N8mP...exampleK3Y"Step 3 exercises the real path end to end — banking network, our ingestion, the pay-in transaction, the settlement queue and your payment.completed webhook — so once it lands, your integration is proven. Register the webhook first (see Register webhook) so the delivery has somewhere to go.
Errors
Every response uses the standard error envelope (see Errors). These are the codes these four endpoints return:
invalid_request(400) — the body failed validation (details.issues), a contact field your shop's deposit accounts require is missing (details.required), or your credential is not bound to a shop. Fix the request; a retry of the same call fails the same way.permission_denied(403) — a dashboard user whose role lacks the permission:deposits.create_accountto open an account,mod.deposit_accountsto read any of them. A server-to-server API key is never gated by roles — if you are integrating with a key, you will not see this one. You can still get the OTHER 403 below.environment_mismatch(403) — your credential's environment does not match the shop's. This is the 403 an API key DOES get, and it is what you hit the day your shop is promoted to production while your code still sendssk_test_. Not transient and not about roles: swap the key for the live one (nothing else in your code changes).deposit_account_not_found(404) — no account with that id on YOUR shop. An id belonging to another of your shops returns this same 404 on purpose: an id never confirms that somebody else's account exists.deposit_account_conflict(409) — more than one account already exists for thatendUserRef, so we will not pick one at random and hand you a different CLABE each call. No account was created; contact support to resolve it.deposit_accounts_unavailable(422) — deposit accounts are not enabled for your shop. Not transient: retrying will not fix it, contact support.rate_limited(429) — too many creates.Retry-Aftertells you how long to wait.internal_error(500) — something broke on our side. Safe to retry: a repeated create for the sameendUserRefcan only ever return the existing account.service_unavailable(503) — the account could not be opened upstream right now. Safe to retry with backoff: a retry never opens a second CLABE for the sameendUserRef.