Penny validation is an ownership check that confirms a Mexican bank account (CLABE) belongs to the customer you say it belongs to, before you start pulling real money from it.
It works like this:
- We send a MXN $0.01 deposit to the account.
- BANXICO returns a CEP (Comprobante Electrónico de Pago) for that deposit. The CEP includes the name and tax ID of the account holder.
- We compare the CEP's beneficiary data against the customer record you provided.
- If the two match, the account is trusted and direct debits can proceed. If they don't match, the account is rejected.
Penny validation is turned on per organization. Once enabled, it applies automatically to every instrument and charge that meets the requirements below — you don't need to toggle anything on a per-request basis.
Penny validation protects you from:
- Customers entering the wrong CLABE by mistake.
- Fraudulent attempts to debit an account the customer doesn't own.
- Failed charges (and the fees that come with them) on accounts that were never going to work.
Because the check runs before the real charge, a rejected account never gets debited.
Penny validation needs a customer record to compare the CEP against. Always create the customer first.
Endpoint: POST /customers
Request
{
"name": "Jane Doe",
"document_type": "MX_RFC",
"document_number": "XXXX000000XXX",
"email": "jane@example.com",
"phone_number": "+521234567890"
}| Field | Required | Description |
|---|---|---|
name | Yes | Customer's full legal name |
document_type | Yes | Document type, e.g. MX_RFC, PASSPORT |
document_number | Yes | Document number |
email | No | Customer email |
phone_number | No | Customer phone number |
Response
{
"id": "8f14e45f-ceea-467a-9575-2f3d1a8b7c90",
"org_id": "3b9e2a1d-7c4f-4b28-9a6e-0d8e1f5a2c3b",
"name": "Jane Doe",
"document_type": "MX_RFC",
"document_number": "XXXX000000XXX",
"email": "jane@example.com",
"phone_number": "+521234567890",
"created_at": "2026-03-29T12:00:00Z",
"updated_at": null
}Keep the returned id. You'll use it either as customer_id on an instrument, or inline as inline_customer on a charge.
You can validate an account in either of these flows:
- By creating an instrument — you validate once, then reuse the instrument for future charges.
- By creating a charge with
inline_instrument— you validate and charge in a single call.
Both flows follow the same match/no-match logic, and they share the same billing rule: you only pay for the first successful validation of an account (see Billing).
Use this flow when you want to save a customer's bank account for repeat billing.
Endpoint: POST /instruments
Request
{
"customer_id": "8f14e45f-ceea-467a-9575-2f3d1a8b7c90",
"type": "clabe",
"mx_clabe": {
"clabe": "012345678901234567"
}
}The instrument is created immediately with status: "verification_in_progress", and we kick off the penny validation in the background.
Response
{
"id": "a1b2c3d4-e5f6-4789-a0b1-c2d3e4f5a6b7",
"org_id": "3b9e2a1d-7c4f-4b28-9a6e-0d8e1f5a2c3b",
"customer_id": "8f14e45f-ceea-467a-9575-2f3d1a8b7c90",
"type": "clabe",
"status": "verification_in_progress",
"ownership_verification_result": null,
"ownership_verification_result_at": null,
"mx_clabe": {
"clabe": "012345678901234567",
"can_credit": true,
"can_debit": true
},
"created_at": "2026-03-29T12:00:00Z",
"updated_at": null
}| CEP result | status | ownership_verification_result |
|---|---|---|
| Account holder matches the customer | active | matched |
| Account holder does not match | errored | no_match |
| CEP couldn't be retrieved | errored | no_match |
| Validation error occurred | errored | errored |
| Still waiting on CEP | verification_in_progress | null |
active— safe to use. You can now create charges that reference this instrument'sid.errored— the account failed validation. Trying to create a charge that references this instrument will return an error (see Trying to charge an un-validated instrument).verification_in_progress— the CEP hasn't arrived yet. Wait for the webhook, or poll the instrument.
Use this flow when you want to validate and charge in one shot — for example, a one-off direct debit where you don't need to store the account.
Endpoint: POST /charges
When a charge includes both inline_instrument and inline_customer, penny validation always runs. There is no way to skip it for this flow.
How long does it take? It depends on whether this account has been validated before.
- First time the account is validated: the charge stays in
verification_in_progresswhile we wait for the CEP (typically under 90 seconds, but can take up to 3 hours in the worst case — see CEP processing timeline).- Account already validated: the charge moves to
pendingorcanceledalmost instantly.
Request
{
"amount": 150.00,
"currency": "MXN",
"reference": "INV-001",
"inline_instrument": {
"type": "clabe",
"identifier": "012345678901234567"
},
"inline_customer": {
"name": "Jane Doe",
"document_type": "rfc",
"document_number": "XXXX000000XXX"
}
}Response
{
"id": "d4e5f6a7-b8c9-4012-a3b4-c5d6e7f8a9b0",
"org_id": "3b9e2a1d-7c4f-4b28-9a6e-0d8e1f5a2c3b",
"instrument_id": null,
"customer_id": null,
"amount": 150.00,
"currency": "MXN",
"reference": "INV-001",
"status": "verification_in_progress",
"inline_instrument": { "type": "clabe", "identifier": "012345678901234567" },
"inline_customer": { "name": "Jane Doe", "document_type": "rfc", "document_number": "XXXX000000XXX" },
"risk_status": null,
"risk_evaluated_at": null,
"created_at": "2026-03-29T12:00:00Z",
"updated_at": null
}Penny validation only runs on charges that pass the risk engine first. If risk flags or cancels a charge, penny validation is skipped entirely.
| CEP result | status | ownership_verification_result |
|---|---|---|
| Account holder matches the customer | pending (ready to process) | matched |
| Account holder does not match | canceled | no_match |
| CEP couldn't be retrieved | canceled | no_match |
| Validation error occurred | canceled | errored |
| Still waiting on CEP | verification_in_progress | null |
If you try to create a charge that references an instrument_id whose penny validation failed (status errored, result no_match or errored), the API will reject the request:
Request
{
"instrument_id": "a1b2c3d4-e5f6-4789-a0b1-c2d3e4f5a6b7",
"amount": 150.00,
"currency": "MXN",
"reference": "INV-002"
}Response — 400
{
"error": {
"code": "instrument_in_invalid_state",
"message": "Instrument is not in active state"
}
}The instrument is a dead end once validation fails. Ask the customer for a different account and create a new instrument.
Penny validation is billed per account, not per request.
- The first time an account is validated successfully — meaning the validation completed with a
matchedorno_matchresult — you are charged for that validation. - Any subsequent validations of the same account are free, regardless of whether they're triggered by creating a new instrument or by a charge with
inline_instrument. - If a validation did not complete with
matchedorno_match(for example, the CEP could never be retrieved and the validation ended inerrored), it doesn't count as the billable first validation. The next attempt on that account will be billed as the first.
The comparison against the customer still runs every time — an account that matched one customer can still come back as no_match when checked against a different customer. Only the billing changes.
Every charge created through the batch endpoint behaves exactly like a single charge with inline_instrument. The same rules apply individually to each row in the batch:
- Penny validation always runs for each charge.
- Each charge goes through the risk engine first; charges flagged or canceled by risk skip validation.
- Each charge transitions independently based on its own CEP result, using the same status table as single charges.
- Billing follows the same per-account rule: you only pay for the first successful validation of each unique account, no matter how many charges in the batch (or across batches) reference it.
When an account has never been validated before, we have to actually pull the CEP from BANXICO. Most complete within 90 seconds, but banks vary. If the CEP isn't available on the first try, we retry automatically over roughly 3 hours.
As soon as any attempt succeeds, the result is locked in as matched or no_match and the instrument or charge transitions accordingly.
If all attempts come back empty, the validation fails:
- The instrument is set to
erroredwithno_match. - Any associated charges are
canceledwithno_match.
Once an account has been validated with a matched or no_match result, future validations of the same account resolve almost instantly.
We notify you of penny validation outcomes through webhooks, so you don't need to poll.
You will receive a charge_result webhook tied to penny validation only when the charge is canceled because the validation came back no_match or errored. Successful validations don't send a dedicated penny-validation webhook on their own — the charge simply moves on to normal processing, and you'll get the usual downstream events for it.
{
"event": "charge_result",
"timestamp": "event-datetime-iso-format",
"data": {
"charge_id": "charge-uuid",
"charge_result": "canceled",
"charge_reference": "reference-by-client",
// null when penny validation is not enabled
"ownership_verification_result": "null | NO_MATCH | MATCHED | ERRORED",
"ownership_verification_result_at": "null | event-datetime-iso-format",
// null when the result is ERRORED or penny validation is not enabled
"ownership_information": {
"name": "name-on-instrument",
"document_id": "document-id-on-instrument"
}
}
}You will receive an instrument_ownership_verification_result webhook every time an instrument finishes penny validation and transitions to active or errored. This fires for both successful (matched) and unsuccessful (no_match / errored) outcomes.
{
"event": "instrument_ownership_verification_result",
"timestamp": "event-datetime-iso-format",
"data": {
"instrument_id": "instrument-uuid",
"instrument_reference": "reference-by-client",
"ownership_verification_result": "NO_MATCH | MATCHED | ERRORED",
"ownership_verification_result_at": "event-datetime-iso-format",
// null when the result is ERRORED
"ownership_information": {
"name": "name-on-instrument",
"document_id": "document-id-on-instrument"
}
}
}| Trigger | Event type | When it fires |
|---|---|---|
Charge with inline_instrument | charge_result | Only when the charge is canceled due to no_match or errored |
| Instrument creation | instrument_ownership_verification_result | Whenever the instrument transitions to active or errored |
Created
→ verification_in_progress
→ active (matched)
→ errored (no_match / errored)Created
→ verification_in_progress
→ Risk engine
→ Penny validation
→ pending (matched) → processing → confirmed / declined
→ canceled (no_match / errored)Created
→ pending
→ Risk engine
→ processing → confirmed / declinedCan I opt a single instrument or charge out of penny validation? No. Once your organization has penny validation enabled, it runs automatically for anything that meets the requirements table above. If you don't want validation on a specific charge, reference an already-validated instrument_id instead of using inline_instrument.
What happens if the customer data I send doesn't exactly match what the bank has on file? The comparison is tolerant of common formatting differences (casing, whitespace, punctuation in names), but material mismatches — a different name, a different RFC — will come back as no_match and the charge will be canceled.
How do I know when validation is done? Listen for webhooks — see Webhooks for event types and payload shapes — or poll the relevant resource.