Skip to content
Last updated

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:

  1. We send a MXN $0.01 deposit to the account.
  2. BANXICO returns a CEP (Comprobante Electrónico de Pago) for that deposit. The CEP includes the name and tax ID of the account holder.
  3. We compare the CEP's beneficiary data against the customer record you provided.
  4. 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.


Why it matters

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.


Before you start: create a customer

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"
}
FieldRequiredDescription
nameYesCustomer's full legal name
document_typeYesDocument type, e.g. MX_RFC, PASSPORT
document_numberYesDocument number
emailNoCustomer email
phone_numberNoCustomer 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.


Two ways to trigger penny validation

You can validate an account in either of these flows:

  1. By creating an instrument — you validate once, then reuse the instrument for future charges.
  2. 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).


Option 1: Validate when creating an instrument

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
}

How the instrument's status evolves

CEP resultstatusownership_verification_result
Account holder matches the customeractivematched
Account holder does not matcherroredno_match
CEP couldn't be retrievederroredno_match
Validation error occurrederrorederrored
Still waiting on CEPverification_in_progressnull

What you can do with each status

  • active — safe to use. You can now create charges that reference this instrument's id.
  • 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.

Option 2: Validate when creating a charge with inline_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_progress while 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 pending or canceled almost 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
}

How the charge's status evolves

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 resultstatusownership_verification_result
Account holder matches the customerpending (ready to process)matched
Account holder does not matchcanceledno_match
CEP couldn't be retrievedcanceledno_match
Validation error occurredcancelederrored
Still waiting on CEPverification_in_progressnull

Trying to charge an un-validated instrument

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"
}

Response400

{
  "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.


Billing: you only pay for the first validation per account

Penny validation is billed per account, not per request.

  • The first time an account is validated successfully — meaning the validation completed with a matched or no_match result — 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 matched or no_match (for example, the CEP could never be retrieved and the validation ended in errored), 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.


Batch charges

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.

CEP processing timeline

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 errored with no_match.
  • Any associated charges are canceled with no_match.

Once an account has been validated with a matched or no_match result, future validations of the same account resolve almost instantly.


Webhooks

We notify you of penny validation outcomes through webhooks, so you don't need to poll.

For charges created with inline_instrument

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"
    }
  }
}

For instruments

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"
    }
  }
}

Summary

TriggerEvent typeWhen it fires
Charge with inline_instrumentcharge_resultOnly when the charge is canceled due to no_match or errored
Instrument creationinstrument_ownership_verification_resultWhenever the instrument transitions to active or errored

Status flow reference

Instrument lifecycle with penny validation

Created
  → verification_in_progress
      → active           (matched)
      → errored          (no_match / errored)

Charge lifecycle with penny validation (inline data)

Created
  → verification_in_progress
      → Risk engine
          → Penny validation
              → pending  (matched)   → processing → confirmed / declined
              → canceled (no_match / errored)

Charge lifecycle without penny validation (stored instrument, already active)

Created
  → pending
      → Risk engine
          → processing → confirmed / declined

Frequently asked questions

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