# Penny Validation

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**


```json
{
  "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**


```json
{
  "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](#billing-you-only-pay-for-the-first-validation-per-account)).

## 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**


```json
{
  "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**


```json
{
  "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 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` |


### 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](#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](#cep-processing-timeline)).
- **Account already validated:** the charge moves to `pending` or `canceled` almost instantly.



**Request**


```json
{
  "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**


```json
{
  "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 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` |


## 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**


```json
{
  "instrument_id": "a1b2c3d4-e5f6-4789-a0b1-c2d3e4f5a6b7",
  "amount": 150.00,
  "currency": "MXN",
  "reference": "INV-002"
}
```

**Response** — `400`


```json
{
  "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`](#option-2-validate-when-creating-a-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.


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


```json
{
  "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

| 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` |


## 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](#webhooks) for event types and payload shapes — or poll the relevant resource.