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 feelsWhat 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 validationWith 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 CLABEYour 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 rememberedOne 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 notname_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 verdictTonder 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 slowOccasionally the receipt takes longer. The status stays pending — that's a "try again shortly", not a bad account.
Built for auditsEvery 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".

MethodPathPurpose
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

statusMeaningFinal?
pendingThe bank receipt hasn't arrived yet — poll again shortly.No
succeededOwnership confirmed; holder_name and holder_id returned.Yes
failedCould not verify; see reason.Yes

Response fields

FieldDescription
account_numberThe validated CLABE.
verification_idIdentifier of this validation — store it; it's your audit trail.
statuspending, succeeded or failed.
holder_nameHolder name as the bank has it on file. null while pending.
holder_idHolder RFC as the bank has it on file. null while pending.
institutionBank institution code, resolved from the CLABE — present from the first response, before the bank answers. See Banking reference.
name_matchholder_name vs. expected_holder_name, fuzzy. null if you didn't send one.
rfc_matchholder_id vs. expected_holder_id, exact. null if you didn't send one.
reasonWhy a validation failed. null otherwise.

Errors

HTTPBodyMeaning
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

ModeCall it when…Then
depositThe user saves the CLABE they will deposit from.Show Verified once it succeeds; attribute deposits and route refunds to that account.
withdrawalThe user requests a payout to a CLABE you haven't verified yet.Only create the withdrawal after the validation succeeds and matches.
bothThe 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:

statusrfc_matchname_matchDo
succeededtruetruePay / mark Verified.
succeededtruefalseFlag for review. Names are fuzzy — accents, order, a second surname — and the RFC already proves ownership.
succeededfalseanyBlock. rfc_match is exact; a different RFC is a different person.
succeedednullnullYou 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.

Next steps

Was this page helpful?

On this page