Account Validation
Know who owns a CLABE — name and RFC, straight from the bank — before the money moves. One call to start, one to read the answer.
Your users type one number: their CLABE. Behind the scenes, Tonder confirms the account is real and that it belongs to them — by name and RFC, straight from the bank — before a single peso of real money is at risk. It is a small, standalone API: one call to start, one to read the answer.
How it works
Tonder sends a 0.01 MXN deposit to the CLABE. It's on us — it never shows up in your statements — and it happens once per account.
The official interbank receipt (the CEP Banxico publishes for every transfer) returns the owner's name and RFC — what the bank has on file, not what someone typed into a form.
Compare to who you expected, then pay, flag or block. name_match and rfc_match come back
with the result.
The result is remembered: a repeat check on the same CLABE within the window (24 hours by default) reuses it — no second deposit. Beyond the window Tonder re-verifies, since an account can change hands at the bank.
Where it plugs in
Send mode to tell Tonder how the account will be used. The same validation serves all three.
Deposits — mode: "deposit"
When a user registers the account they will deposit from, one silent check turns it into a verified account, and everything that account touches afterwards inherits that trust.
| What your user feels | What your business gets |
|---|---|
| One field, zero friction. No bank dropdown, no RFC to type, no "verification pending" screen — a Verified state that appears on its own. | Every deposit attributable to a confirmed owner from day one; refunds that cannot be socially engineered to a stranger's account; a compliance story that answers itself. |
Payouts — mode: "withdrawal"
The expensive mistake in payouts is money sent to an account that is not your user's. Validation moves that discovery from after the transfer to before it — the only moment you can still do something about it.
| Without validation | With validation |
|---|---|
| You learn about a wrong account when the transfer bounces — or worse, when it doesn't. The user waits, support investigates, and the loss is often yours. | A mismatch surfaces before dispatch, with the bank's own data in hand. You pick the reaction — block with a clear message, or pay and flag — and repeat payouts to known accounts stay instant. |
Both — mode: "both"
Used on both sides, one validation becomes a loop: the account verified at deposit time is already trusted at cash-out time. Money in and money out move against one confirmed identity — you never pay the check twice, the first withdrawal has nothing left to wait for, and depositing from someone else's account and cashing out to someone else's account (the two halves of most payment fraud) close with the same proof of ownership.
Good to know
| Just the CLABE | Your user never selects a bank or types an RFC for their account. Tonder resolves the institution from the CLABE and the bank tells us the rest. |
| Once, then remembered | One validation per account, cached. A repeat check within the window (24 hours by default) reuses the result. Beyond it, Tonder re-verifies. |
| Names are fuzzy, RFC is not | name_match ignores case, accents and extra spaces — but not word order or an extra surname (Kuhlman Dede and Dede Kuhlman Garcia both come back false for Dede Kuhlman). rfc_match is exact, case-insensitive. Both are recomputed on every call, even a cached one. |
| You own the verdict | Tonder returns the bank's holder_name, holder_id (RFC) and the two match flags. Your policy decides what a match — or a mismatch — means: pay, flag, or block. |
| If the bank is slow | Occasionally the receipt takes longer. The status stays pending — that's a "try again shortly", not a bad account. |
| Built for audits | Every validation keeps its verification_id and the bank's confirmation, so any decision can be explained months later. |
Endpoints
Two calls plug validation into your flow. Both are authenticated with your Tonder API key —
Authorization: Token <API_KEY>, like every other endpoint. Validations are scoped by your API
key; business_id is optional.
The Token prefix is not optional. The header carries a scheme and a credential:
Authorization: Token c256f3…. Send the bare key — Authorization: c256f3… — and the API has no
scheme to read it under, so it answers 401 "Authentication credentials were not provided" as if
you had sent nothing. Same rule as every other Tonder endpoint; it's the most common first-call
mistake. Use your public key — the secret key answers 401 "Invalid token".
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/account-verifications/ | Start (or return the cached) validation for a CLABE. |
GET | /api/v1/account-verifications/{account_number} | Read a validation, refreshed live while pending. |
Sandbox test accounts. Use CLABEs of the test institution 646180… with a valid check digit —
646180000000000009 works, and so does any other in that range. Other institutions are rejected
upstream (502). The receipt lands within seconds and returns a synthetic holder name and RFC;
copy them from the first GET into expected_holder_name / expected_holder_id to see the match
flags turn true.
Steps
Send the account_number (CLABE). Optionally add your business_id, the mode
(deposit · withdrawal · both) and the values you expect — expected_holder_name and
expected_holder_id (RFC) — to get the match flags back.
curl -X POST https://stage.tonder.io/api/v1/account-verifications/ \
-H "Authorization: Token YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_number": "646180000000000009",
"business_id": "97",
"mode": "withdrawal",
"expected_holder_name": "Dede Kuhlman",
"expected_holder_id": "RBZZ190718TEA"
}'curl -X POST https://app.tonder.io/api/v1/account-verifications/ \
-H "Authorization: Token YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_number": "646180000000000009",
"business_id": "97",
"mode": "withdrawal",
"expected_holder_name": "Dede Kuhlman",
"expected_holder_id": "RBZZ190718TEA"
}'You get back a verification_id and a status — usually pending:
{
"account_number": "646180000000000009",
"verification_id": "accv_3JkKAxCDvjeoUGgQPYzOMEFDl0K",
"status": "pending",
"holder_name": null,
"holder_id": null,
"institution": "90646",
"name_match": null,
"rfc_match": null,
"reason": null
}While it's pending Tonder refreshes it live from the bank; once it's succeeded or failed
it's final.
curl "https://stage.tonder.io/api/v1/account-verifications/646180000000000009?business_id=97" \
-H "Authorization: Token YOUR_API_KEY"curl "https://app.tonder.io/api/v1/account-verifications/646180000000000009?business_id=97" \
-H "Authorization: Token YOUR_API_KEY"Once the bank's receipt lands:
{
"account_number": "646180000000000009",
"verification_id": "accv_3JkKAxCDvjeoUGgQPYzOMEFDl0K",
"status": "succeeded",
"holder_name": "Dede Kuhlman",
"holder_id": "RBZZ190718TEA",
"institution": "90646",
"name_match": true,
"rfc_match": true,
"reason": null
}Use status plus the match flags to pay, flag or block — on your terms. A repeat check on the
same CLABE within the window returns the cached result instantly.
Statuses
status | Meaning | Final? |
|---|---|---|
pending | The bank receipt hasn't arrived yet — poll again shortly. | No |
succeeded | Ownership confirmed; holder_name and holder_id returned. | Yes |
failed | Could not verify; see reason. | Yes |
Response fields
| Field | Description |
|---|---|
account_number | The validated CLABE. |
verification_id | Identifier of this validation — store it; it's your audit trail. |
status | pending, succeeded or failed. |
holder_name | Holder name as the bank has it on file. null while pending. |
holder_id | Holder RFC as the bank has it on file. null while pending. |
institution | Bank institution code, resolved from the CLABE — present from the first response, before the bank answers. See Banking reference. |
name_match | holder_name vs. expected_holder_name, fuzzy. null if you didn't send one. |
rfc_match | holder_id vs. expected_holder_id, exact. null if you didn't send one. |
reason | Why a validation failed. null otherwise. |
Errors
| HTTP | Body | Meaning |
|---|---|---|
400 | {"account_number": ["This value does not match the required pattern."]} | A field failed validation — the body maps each field to its errors. Same shape for a bad mode. |
401 | {"detail": "Authentication credentials were not provided."} | Missing or wrong header. Use Authorization: Token <API_KEY> with your public key. |
404 | {"detail": "No verification for **************0009"} | No validation exists for that CLABE. The number comes back masked. |
502 | {"detail": "…create returned HTTP 400"} | The bank could not start the validation — usually a CLABE with a bad check digit, or an institution not supported in this environment. |
Integrate it into your flow
Two calls, one loop, one decision. Here is where each piece goes.
When to call it
| Mode | Call it when… | Then |
|---|---|---|
deposit | The user saves the CLABE they will deposit from. | Show Verified once it succeeds; attribute deposits and route refunds to that account. |
withdrawal | The user requests a payout to a CLABE you haven't verified yet. | Only create the withdrawal after the validation succeeds and matches. |
both | The user registers their account. | One validation covers every deposit and payout after it. |
Send expected_holder_name and expected_holder_id from your user record — that's what turns
the answer into a yes/no instead of two strings you have to compare yourself.
The sequence
POST /account-verifications/. If the CLABE was validated within the last 24 hours, the
response is already succeeded (or failed) — skip the loop and decide.
The bank receipt usually lands in seconds. Poll GET /account-verifications/{account_number}
every few seconds; stop at the first succeeded or failed. If it's still pending after your
budget (a minute is plenty), don't treat it as a failure — keep the request queued and try
again shortly. Never pay against a pending result.
Apply your policy to status and the match flags (table below). Store verification_id with
the user's account record — it's your audit trail.
Create the payout with POST /process/ using the same CLABE,
or mark the deposit account Verified. Before a later payout, call POST again: within the
window it's an instant cached answer; beyond it, Tonder re-verifies for you.
A policy to start from
Tonder returns facts; the verdict is yours. This is a sensible default:
status | rfc_match | name_match | Do |
|---|---|---|---|
succeeded | true | true | Pay / mark Verified. |
succeeded | true | false | Flag for review. Names are fuzzy — accents, order, a second surname — and the RFC already proves ownership. |
succeeded | false | any | Block. rfc_match is exact; a different RFC is a different person. |
succeeded | null | null | You didn't send expected values — compare holder_name / holder_id yourself. |
failed | — | — | Block, and show the user something actionable from reason. |
pending (past your budget) | — | — | Retry later. Not a bad account. |
Code
const BASE = 'https://stage.tonder.io/api/v1'; // app.tonder.io in production
const headers = { Authorization: `Token ${process.env.TONDER_API_KEY}`, 'Content-Type': 'application/json' };
async function validateAccount(clabe, user) {
let v = await fetch(`${BASE}/account-verifications/`, {
method: 'POST',
headers,
body: JSON.stringify({
account_number: clabe,
business_id: process.env.TONDER_BUSINESS_ID,
mode: 'withdrawal',
expected_holder_name: user.fullName,
expected_holder_id: user.rfc,
}),
}).then((r) => r.json());
// Poll while pending — usually seconds. Give up on *waiting*, not on the account.
for (let i = 0; i < 20 && v.status === 'pending'; i++) {
await new Promise((r) => setTimeout(r, 3000));
v = await fetch(
`${BASE}/account-verifications/${clabe}?business_id=${process.env.TONDER_BUSINESS_ID}`,
{ headers },
).then((r) => r.json());
}
await db.saveVerification(user.id, clabe, v.verification_id, v.status);
if (v.status === 'pending') return { decision: 'retry_later' };
if (v.status === 'failed') return { decision: 'block', reason: v.reason };
if (v.rfc_match === false) return { decision: 'block', reason: 'rfc_mismatch' };
if (v.name_match === false) return { decision: 'flag', reason: 'name_mismatch' };
return { decision: 'pay', holder: v.holder_name };
}import os, time, requests
BASE = "https://stage.tonder.io/api/v1" # app.tonder.io in production
HEADERS = {"Authorization": f"Token {os.environ['TONDER_API_KEY']}", "Content-Type": "application/json"}
BUSINESS_ID = os.environ["TONDER_BUSINESS_ID"]
def validate_account(clabe: str, user) -> dict:
v = requests.post(f"{BASE}/account-verifications/", headers=HEADERS, json={
"account_number": clabe,
"business_id": BUSINESS_ID,
"mode": "withdrawal",
"expected_holder_name": user.full_name,
"expected_holder_id": user.rfc,
}).json()
# Poll while pending — usually seconds. Give up on *waiting*, not on the account.
for _ in range(20):
if v["status"] != "pending":
break
time.sleep(3)
v = requests.get(
f"{BASE}/account-verifications/{clabe}",
headers=HEADERS, params={"business_id": BUSINESS_ID},
).json()
db.save_verification(user.id, clabe, v["verification_id"], v["status"])
if v["status"] == "pending":
return {"decision": "retry_later"}
if v["status"] == "failed":
return {"decision": "block", "reason": v["reason"]}
if v["rfc_match"] is False:
return {"decision": "block", "reason": "rfc_mismatch"}
if v["name_match"] is False:
return {"decision": "flag", "reason": "name_mismatch"}
return {"decision": "pay", "holder": v["holder_name"]}There is no webhook for validations — the GET is the notification. Keep the loop server-side and
never expose the API key to the browser.
