Andres Apolinar
Back to systems
TelecomCTO2022–2026

Xpace — The platform behind 20 mobile carriers

As CTO, I led the MVNO management platform that grew to operate 20 carriers and more than 300,000 users in production: SIM activation, top-ups, portability, tax invoicing and regulatory identity verification.

  • Laravel
  • PHP
  • MySQL
  • OpenAI Vision
  • Stripe
  • OpenPay
  • MercadoPago
  • Docker

Visit the system(opens in a new tab)

Leer este caso en español

Constraints

  • Regulatory with a hard date: the official guidelines in force since January 2026 require every line to be tied to a person with verified identity.
  • Twenty carriers share one platform and each one is accountable for its own data.
  • Top-ups and commissions move money. A duplicated credit is a leak, not a bug.
  • Identity verification has to hold up in a carrier audit, not just work.
  • Every verification consumes a vision call and a national-ID lookup. The cost is real.

The problem

The scale

Xpace is the system that runs a mobile virtual network operator business: companies that sell lines over a larger carrier’s network, under their own brand, without owning any network infrastructure.

Everything that implies goes through one platform: activate the SIM, tie it to a verified person, top up the balance, port a number, issue the tax invoice, pay the seller’s commission, and answer the carrier when it asks.

It grew to operate 20 MVNO carriers and more than 300,000 users in production, with clients including Metrocel, Beneleit, WIWI and Wiki Katat.

And that is the real engineering problem: it is not a system with twenty customers. It is one data model shared by twenty businesses competing with each other, where an isolation mistake is not a bug — it is a leak and a phone call.

What makes this domain hard

Regulation with an effective date. The official guidelines in force since January 2026 require every line to be tied to a person with identity verified against an official document. It is not optional and not negotiable: if the process is skipped, the one who pays is the carrier, with its licence.

Money flowing in three directions. The user tops up, the seller earns commission, the platform settles. Each is a source of duplicated credits, and the mistake makes no noise until someone balances the month.

Twenty owners of their own data. Isolation between carriers is not an abstract architecture decision: it is the commercial promise.

Identity verification, where judgement shows most

Every production flow —client linking, carrier validation, sales, activation and top-up APIs— passes through a single orchestrator. Nine chained gates:

uploads front and back of the ID card
        │
        ▼
 0  Deterministic colour gate (GD)          → photocopy or B/W: reject, cost $0
 1  Vision analysis (front + back)          → output bound to a strict json_schema
 2  Image quality                           → unreadable ID number: reject
 3  Physical presence                       → any reproduction: reject
 4  Identity consistency                    → different people: reject
 4b Document integrity                      → critical or high anomaly: reject
 4c Holder photo integrity                  → CONFIRMED overlay: reject · doubt: warn
 5  Same credential                         → front and back of the same document
 6  Captured ID vs ID on the card           → match
 7  Official national ID status             → non-current key: reject
 8  Adversarial second pass                 → only if everything above would pass
        │
        ▼
  is_identity_validated = true   (the image is NOT stored)

Two things in that design deserve attention.

Gate 0. It is decided with arithmetic over the image bytes before spending a single token. It eliminates the most frequent class of fraud —the photocopy, the desaturated screenshot— in milliseconds at zero cost. It is the negation of “everything goes through the model”: what can be decided cheaply is decided cheaply.

Gate 8. If the previous eight would pass, a second pass runs with an adversarial prompt that assumes the model was fooled. It can only reject on confirmed evidence, never on doubt. It is paid for only on credentials that are about to pass — that is, where the real risk of a false positive lives.

And one rule that is not technical: the credential is not stored. The result is a boolean and a status. The ID image is not persisted. The system’s goal is met without becoming the custodian of a document it does not need.

What being strict costs

Two things in this project were made deliberately harder, and they are worth stating.

The integrator API was rebuilt from scratch. The previous version had a single global key in the environment file mapped to operators hardcoded in the source. You could not onboard a new integrator without touching both the .env and the code. The new one uses keys with a visible prefix for indexed lookup, hashing for comparison and revocation, in the format xp_{live|test}_{48 hex}.

The test suite is held back by an explicit guard. Tests that touch the database require an environment variable and an isolated database, and the config file forces that database name with a comment stating tests must never run against development. It is deliberate protection against destroying real data — and at the same time it means that, in practice, some tests do not run by default.

Both facts are true and both are published. A system that only counts its virtues is not a documented system; it is a brochure.

About the team

I led this platform as CTO. The data model, the architecture and the domain rules are my design; the module implementation was done by my team, and the work went through pull-request review. The numbers —158 migrations, 96 models, 171 controllers— describe the magnitude of what was operated, not a sum of commits.

And the identity audit document exists for a reason that is not technical: a verification system that lives only in the head of whoever wrote it does not pass an audit. The documentation was part of the deliverable.

Decisions

  1. 01

    The credential decides the environment, not the request

    In the first version of the integrator API, sandbox mode was a request parameter checked by conditionals scattered across every method, and in several flows sandbox did write to the database. The fix was to move the decision into the authenticated subject: a sandbox key can never touch production and vice versa, and there is no manipulable flag.

    RejectedPatching the conditionals. As long as the mode is an input, anyone can lie about it.

  2. 02

    Sandbox with zero writes, telemetry included

    In sandbox nothing is written and the provider is never called. That forced giving up something anyone would add without thinking: the key's last-used timestamp is updated only in production, because even recording that you authenticated is a write. That is the cost of making the zero-effect guarantee literal and verifiable.

  3. 03

    Deterministic sandbox via a seed, not random data

    The simulator seeds its responses from a hash of the payload, so the same request always produces the same line identifier. That makes tests reproducible and assertions stable.

    RejectedRandom data, which makes tests non-deterministic, and fixed per-operation data, which does not distinguish one payload from another.

  4. 04

    Exactly-once via a database constraint, not logic

    The monthly referral bonus is idempotent through a unique index on client and month. Three different entry points could trigger the credit, and the constraint is the only thing that survives a race between processes. A violation is caught as an expected case, not as an error.

    RejectedA conditional that checks whether the bonus exists. Another request fits between the read and the insert.

  5. 05

    Balance mutations take an explicit lock

    Every wallet mutation runs inside a transaction with a locking read on the row. The risk was documented as known because it is not theoretical: a concurrent top-up and the monthly processing touch the same balance.

  6. 06

    Identity belongs to the LINE, not the client

    The previous version stored the national ID on the client record. It was moved to a per-line identity table, and unlinking is terminal: an unlinked line never re-links through automated flows. The impact analysis was written and quantified before the change —573 rows, around thirty write sites across fifteen files, and every controller's read gate with exact line numbers.

    RejectedKeeping the ID on the client because "it already worked". The problem is that a client with five lines has five identities, and the model did not represent that.

  7. 07

    The cheap gate before the vision model

    Before anything reaches the vision API, a deterministic check measures the image's chroma and rejects black-and-white credentials or photocopies. It runs even in sandbox because it costs nothing. What can be decided with arithmetic is decided with arithmetic: free, deterministic and auditable.

  8. 08

    Fail-closed on evidence, tolerant of doubt

    The system rejects on any confirmed evidence —an overlay on the holder's photo, a critical alteration— but only warns when the model says it does not know. A laminate reflection on a real credential produces doubt, and doubt cannot cost a legitimate customer their line. The asymmetry is deliberate and calibrated after real false rejections.

    RejectedRejecting at any confidence level. Safer on paper and it destroys conversion in production.

Outcome

  • 20 MVNO carriers operating on the platform, with more than 300,000 users in production.
  • Clients including Metrocel, Beneleit, WIWI and Wiki Katat.
  • 158 migrations, 96 models and 171 controllers over a complete telecom domain.
  • An identity audit document describing the nine gates, the official INE and Civil Registry criteria, and the role of every file — written to defend the decision in front of a carrier auditor.
  • An integrator API rebuilt around per-key prefixes, hashing and revocation instead of a single global key in the environment file.