Endpoints

Deposit accounts (CLABE)

POST/api/v1/me/deposit-accounts

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

The create endpoint is get-or-create. You send YOUR id for the end user (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.
So you can call it every time you render your customer's deposit screen without tracking whether you asked before, and two simultaneous calls can never leave one user holding two CLABEs.
The shop comes from your credential — no request carries a 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

POST/api/v1/me/deposit-accountssecret key

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

Body parameters
  • endUserRefstringrequired
    YOUR 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.
  • namestringrequired
    The 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.
  • emailstringrequired
    REQUIRED 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.
  • phonestring
    CONDITIONAL — some shops' deposit accounts cannot be opened without a phone as well (see the note below). Max 40 chars.
  • documentTypestring
    Optional. 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.
  • documentIdstring
    Optional. The document number itself. Max 60 chars.
  • countrystring
    Optional. The end user's country as ISO-2 or ISO-3 (MX / MEX). Uppercased on write.
  • metadataobject
    Optional. Up to 20 string key/values (each value ≤ 500 chars), echoed back on every read. Use it to carry your own row ids.
Request
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" }
  }'
Response
{
  "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"
  }
}
Whether 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_request with details.required naming 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.
Try itPOST/api/v1/me/deposit-accountssandbox
Request body
endUserRefstringRequired
namestringRequired
emailstring
phonestring
documentTypestring
documentIdstring
metadataobject
Live snippet
curl -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"
  }
}'
Snippet updates as you edit the form. Sandbox responses are deterministic.

Read your accounts

GET/api/v1/me/deposit-accountssecret key

Your shop's deposit accounts, newest first. Filter by endUserRef to resolve one customer's CLABE without keeping your own copy of it.

Body parameters
  • limitinteger
    Query. 1–200, default 50.
  • offsetinteger
    Query. Default 0. Page with pagination.total, which is the count of the whole filtered set — you never have to probe for the end.
  • endUserRefstring
    Query. Return only that end user's account. This is the cheapest way to look up one customer.
  • includestring
    Query. 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.
Request
# 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"
Response
{
  "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 }
}
GET/api/v1/me/deposit-accounts/{id}secret key

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

Body parameters
  • idstringrequired
    Path. The account id returned on create (eda_…).
Request
curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts/eda_9f2c1b7ad0e34f5a8c6b2d10" \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y"
Response
{
  "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.

GET/api/v1/me/deposit-accounts/{id}/depositssecret key

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

Body parameters
  • idstringrequired
    Path. The account id (eda_…).
  • limitinteger
    Query. 1–200, default 50.
  • offsetinteger
    Query. Default 0. Page with pagination.total.
Request
curl "https://sandbox.key2pay.ai/api/v1/me/deposit-accounts/eda_9f2c1b7ad0e34f5a8c6b2d10/deposits?limit=50" \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y"
Response
{
  "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 }
}
Read 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

A deposit notifies you like any other payment. A transfer into one of these CLABEs is credited as a normal pay-in and fires 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 is null on payments that are not deposits.
  • merchantOrderId — for a deposit there is no order of yours to echo (the money arrived unprompted), so we generate a DEP-… reference of our own. It identifies the deposit, not the customer — that is endUserRef.
To deduplicate deliveries, use the envelope's own id (wh_…): it is unique per delivery attempt, so a retry after your endpoint timed out is recognisable as the same one.
json
{
  "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.

Before your first call, your sandbox shop needs deposit accounts enabled. Until it does, every 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:

bash
# 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_account to open an account, mod.deposit_accounts to 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 sends sk_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 that endUserRef, 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-After tells you how long to wait.
  • internal_error (500) — something broke on our side. Safe to retry: a repeated create for the same endUserRef can 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 same endUserRef.