How to Accept CoDi (and the Truth About DiMo) in Your App: A Mexican Engineer's Integration Guide — Cesar Ayala
← All posts

How to Accept CoDi (and the Truth About DiMo) in Your App: A Mexican Engineer's Integration Guide

To accept CoDi programmatically you either become a SPEI participant (heavy regulatory lift) or go through an aggregator: generate a dynamic QR/charge per order, then verify the RSA-SHA512 signed webhook on the raw body before fulfilling. Settlement is real-time, 24/7, and irreversible — a refund is a new outbound SPEI payment, not a chargeback. There is no public DiMo business-collection API; DiMo is phone-keyed P2P.

You can’t just call Banxico — here’s how CoDi acceptance actually works

Let me kill the most common wrong assumption first: there is no self-serve public Banxico API you can hit to start accepting CoDi or DiMo. You don’t sign up on banxico.org.mx, get a key, and start charging. For most of us building apps, that door isn’t there.

There are exactly two real paths. One, you become a SPEI participant — a bank or a regulated IF (institución financiera). That’s a heavy regulatory lift, and it’s a compliance/legal project, not a weekend integration. Two, you go through an aggregator — a provider that already is a participant and exposes a normal HTTP API on top of CoDi/SPEI. For almost everyone reading this, it’s path two.

Why bother now? Banxico’s Circular 9/2026 (published in the DOF, June 2026) pushes institutions toward a standardized SPEI/CoDi/DiMo experience, with compliance due by December 14, 2026. I’m not going to re-explain the policy — others will — but the practical effect for builders is simple: more customers will expect to pay these rails, so more merchants will want to accept them. If you want the regulatory backstory, I wrote it up separately in the Banxico Nivel 2 Bis / SPEI / CoDi rules for 2026. This post is the integration how-to.

One disclaimer up front, and I’ll repeat it: I’m an engineer sharing a pattern that ships in production, not your compliance team. Whether you should be a direct SPEI participant, and the exact regulatory edges, go to a contador and your compliance people. The build, I can help with. As of 2026, confirm current rules against official sources.

CoDi vs DiMo: what each one actually is (and what you can build on)

These get lumped together, but they’re different tools.

CoDi (Cobro Digital, live since 2019) is the one you actually build a checkout on. It’s a merchant-initiated collection that rides SPEI: you generate a QR code (or a push notification) for a charge, the customer scans it in their banking app, approves, and the money moves over SPEI. The key distinction is dynamic vs static. A dynamic QR is an individual code tied to a single charge and a unique transaction — one order, one code, one amount. A static QR is reusable (think a printed code taped to a counter). For an app charging variable amounts per order, you want dynamic.

DiMo (Dinero Móvil, live since 2023) is a real-time interbank transfer keyed by phone number instead of a CLABE, also riding SPEI. It’s a phone-aliased transfer rail — mostly person-to-person, with some business-to-business use — your cousin sends you 500 pesos to your phone number, no account details exchanged. The clean UX is real. But here’s the operational catch that matters to a builder: there is no merchant-driven business-collection API you can generate, track, and confirm programmatically from your backend. More on that below.

Both settle the same way: on SPEI, in real time, 24/7, and irreversibly. There’s no card-style chargeback. That last property reshapes your whole backend, and we’ll get to it. For where these sit next to cards, OXXO, and SPEI generally, see payment gateways in Mexico.

CoDiMerchant-initiated COLLECTION over SPEI via QR / push. Dynamic QR = one code per single charge. This is what you build a checkout on.
DiMoReal-time interbank transfer keyed by PHONE NUMBER, no CLABE, over SPEI. Phone-aliased (mostly P2P). No merchant-driven business-collection API.
BothSettle on SPEI: real-time, 24/7, irreversible. No card-style chargeback — a refund is a new outbound payment.

The honest part: there’s no DiMo business-collection API

This is the section the bank marketing pages will never write, so here it is plainly: as of 2026, there is no clean public DiMo business-collection API you can drive from your backend. DiMo is phone-number-keyed and lives mostly in the P2P (and some B2B) world. It is not a merchant collection rail you can generate, track, and confirm programmatically.

So when a customer says “can I just pay you by DiMo?”, what that means in practice is a manual transfer — they send money to a phone number, and you reconcile it by hand or by matching the incoming SPEI. It is not an API-driven charge you can generate, track, and confirm in code.

For programmatic business acceptance, your options are CoDi (a dynamic QR via an aggregator) or virtual CLABEs over SPEI. Do not architect a “DiMo backend collection” flow — you’ll be building on something that doesn’t exist, and you’ll find out the hard way during integration. If your aggregator claims a DiMo collection path, great, confirm exactly what it does and what settles — but assume CoDi until proven otherwise.

Generating a dynamic CoDi charge (POST /dapp-codes/)

Here’s the core build step. One dynamic charge per order: the amount plus your own internal reference (order id), so you can match it later.

I’ll use dapp.mx as one concrete example of the aggregator pattern. This is vendor-neutral — dapp is an example, not a recommendation — and you must confirm the exact endpoint, auth, and fields against your aggregator’s current docs. Treat any fees or amounts as “confirm current.”

The pattern: POST /dapp-codes/ creates a dynamic charge code. Auth is a private API key over HTTP Basic — the API key goes in as the password, with an empty username. And the gotcha that’ll cost you an hour: a missing User-Agent header returns 403. Set it explicitly.

# Illustrative — aggregator-specific (dapp.mx example). Confirm endpoints/auth/fields
# against your aggregator's CURRENT docs. Amounts/fees: confirm current.
import requests
from requests.auth import HTTPBasicAuth

API_KEY = "sk_live_xxx"  # private key — server-side only, never ship to client

def create_codi_charge(order_id: str, amount_mxn: str):
    resp = requests.post(
        "https://api.dapp.mx/dapp-codes/",
        # HTTP Basic: API key as the PASSWORD, empty username
        auth=HTTPBasicAuth("", API_KEY),
        headers={
            # Missing User-Agent => 403. Set it explicitly.
            "User-Agent": "nixbly-checkout/1.0",
            "Content-Type": "application/json",
        },
        json={
            "amount": amount_mxn,        # e.g. "150.00" — confirm format/units
            "description": f"Order {order_id}",
            "reference": order_id,       # YOUR id, so the webhook can match back
        },
        timeout=15,
    )
    resp.raise_for_status()
    data = resp.json()
    # Persist the returned charge/transaction id against the order BEFORE showing the QR
    return data  # contains the QR payload + charge id (field names: confirm in docs)

Store the returned charge id against your order before you render the QR. You want the linkage in place before any money can move, because the confirmation comes back asynchronously — and that’s the part that matters most.

Verifying the signed webhook before you trust a peso

Payment confirmation arrives as a signed webhook. Internalize this: the webhook is the money. Because settlement is irreversible and real-time, if you fulfill on a forged or unverified event, there’s no chargeback to claw it back — you just shipped goods for free.

dapp signs webhooks with RSA + SHA-512. You verify the signature against the raw request body — before you parse it, before you trust a single field. Parse first and you’ve already let unverified data into your logic. After verification: idempotency keyed on the charge/transaction id (retries and duplicates are normal — fulfill exactly once), and match the event back to your order by your reference.

# Illustrative — aggregator-specific signature scheme (RSA-SHA512 over RAW body).
# Confirm the exact header name + public key source with your aggregator's docs.
import json
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
import base64

AGGREGATOR_PUBLIC_KEY = serialization.load_pem_public_key(PUBLIC_KEY_PEM)

# NOTE: raw_body must be the EXACT bytes received (e.g. Flask request.get_data()),
# never a re-serialized dict — RSA-SHA512 is verified over the raw payload.
def handle_webhook(raw_body: bytes, signature_b64: str):
    # 1) Verify RSA-SHA512 on the RAW body BEFORE parsing
    try:
        AGGREGATOR_PUBLIC_KEY.verify(
            base64.b64decode(signature_b64),
            raw_body,                       # raw bytes, not re-serialized JSON
            # padding scheme not specified by spec — PKCS1v15 assumed;
            # PSS is the other option. Confirm with your aggregator.
            padding.PKCS1v15(),
            hashes.SHA512(),
        )
    except InvalidSignature:
        return 401  # reject — do NOT fulfill

    event = json.loads(raw_body)
    charge_id = event["charge_id"]          # field names: confirm in docs

    # 2) Idempotency: fulfill once, even on retries/duplicates
    if ledger.already_processed(charge_id):
        return 200

    # 3) Match to YOUR order; never fulfill an unmatched event
    order = orders.get_by_reference(event["reference"])
    if order is None:
        return 200  # ack so they stop retrying, but don't fulfill

    ledger.record_payment(charge_id, order, clave_de_rastreo=event.get("tracking_key"))
    fulfill(order)
    return 200

And handle the QR lifecycle: expiry/timeout, and the “scanned but didn’t pay” state. A scanned code is not a paid code. Only the verified webhook means money landed. If you’ve wired SPEI/OXXO webhooks before, the discipline mirrors my Conekta OXXO/SPEI webhook integration.

Create orderPersist the order with your internal reference id.
Generate dynamic chargePOST /dapp-codes/ — HTTP Basic key, User-Agent set. Store charge id.
Show QR / pushCustomer scans in their bank app. Handle expiry + scanned-but-unpaid.
Customer paysFunds move over SPEI — real-time, 24/7, irreversible.
Signed webhookVerify RSA-SHA512 on the RAW body before parsing anything.
Match + idempotentKey on charge id; match to order by reference; fulfill exactly once.

Irreversible settlement: what changes in your backend vs cards

If you’re card-trained, retrain your mental model here. Cards give you a two-step auth/capture, plus chargebacks and disputes — the transaction is reversible, and the network arbitrates fights after the fact.

CoDi/SPEI gives you none of that. Settlement is irreversible and real-time. There are no chargebacks, no dispute webhooks, no pending-capture limbo to model. That’s genuinely simpler in places — your state machine loses a whole branch.

But the risk doesn’t vanish, it moves. A refund is a brand-new outbound SPEI payment, not a reversal of the original. You own that flow now: you initiate a transfer back to the customer. And because there’s no undo, you have to get the amount right the first time — validate before you charge, not after. The dispute surface shrinks; the cost of a pre-charge bug grows.

CoDi / SPEI

  • Irreversible, real-time settlement
  • No chargebacks or disputes
  • No auth/capture two-step
  • Refund = new outbound SPEI payment you initiate
  • No undo — validate amounts before charging

Card payments

  • Reversible; auth then capture
  • Chargebacks + dispute webhooks to handle
  • Pending-capture state to model
  • Refund = reversal of the original charge
  • Network arbitrates disputes after the fact

If you want a card-style gateway alongside CoDi for the customers who still reach for plastic, Mercado Pago is the dominant MX option and a sane second rail.

Reconciliation: match every payment by clave de rastreo

This is where production actually breaks, and it’s exactly what the bank pages skip. Every SPEI settlement carries a clave de rastreo — a tracking key for that transfer. Store it on the order/ledger record for every payment.

That clave de rastreo is your source of truth for “did this actually pay?” When a customer swears “ya pagué” and your system disagrees, the clave de rastreo is how you resolve it — you can trace the exact SPEI movement. It’s also what feeds clean accounting downstream, including your CFDI/REP (complemento de pago, the receipt complement) work for fiscal records.

Pair it with the idempotent webhook handling from earlier so the ledger entry gets written exactly once. Idempotency keeps you from double-crediting; the clave de rastreo lets you prove and audit each credit. Together they’re the reconciliation discipline that holds up when volume and edge cases arrive.

  1. Pick a pathSPEI participant (heavy regulatory lift) vs aggregator. Most builders: aggregator.
  2. Dynamic QR per orderOne charge, one amount, one reference id. Store the charge id first.
  3. Verify the webhookRSA-SHA512 on the raw body, then idempotency on the charge id.
  4. ReconcileStore the clave de rastreo, match to order/ledger as source of truth.
  5. Handle edgesQR expiry, scanned-but-unpaid, refund = new outbound payment.

FAQ: CoDi, DiMo, and aggregators

Can I accept DiMo via an API? Not as a business collection rail — there’s no public DiMo business-collection API you can drive from a backend today. Use CoDi (dynamic QR via an aggregator) or virtual CLABEs over SPEI. A customer “paying by DiMo” means a manual transfer you reconcile by hand.

Do I have to be a SPEI participant? No. Most builders go through an aggregator. Direct participation has real regulatory requirements — that’s a conversation for compliance and legal, not a checkbox.

Are there chargebacks on CoDi? No. Settlement is irreversible. A refund is a new outbound SPEI payment you initiate yourself, not a reversal.

How long is a CoDi QR valid? It’s aggregator-configured. Build for expiry/timeout and the scanned-but-unpaid state explicitly — don’t assume a scan means payment.

What about fees and limits? Treat them as time-sensitive. Confirm current values with your aggregator, and confirm fiscal handling with a contador. As of 2026, verify everything against current docs.

For more on the regional rails, I keep a running set of LATAM payments guides.

Ship it

The path, end to end:

  • Pick a path — SPEI participant vs aggregator. Most of us: aggregator.
  • Generate a dynamic QR per order, then verify the RSA-SHA512 signed webhook on the raw body with idempotency on the charge id, and reconcile by clave de rastreo.
  • Handle QR expiry and treat refunds as new outbound payments — there are no chargebacks, so validate amounts before you charge.
  • DiMo: no business-collection API. Use CoDi or virtual CLABEs. Don’t build the path that doesn’t exist.

That’s the operational reality the marketing pages leave out — and it’s most of the work. One last time, because regulated rails earn the repetition: I’m an engineer, not your contador. Confirm fees, limits, SAT/CFDI handling, and whether you should be a SPEI participant with compliance, as of 2026, against current sources. The build pattern above is what ships; the regulatory shape around it is theirs to sign off on.