> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lumx.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Convert USDT to MXN and pay out over SPEI

> Register a SPEI destination with a CLABE, quote USDT to MXN, and send an off-ramp that pays a Mexican bank account. Webhooks, hours and errors.

To convert USDT to MXN and pay out over SPEI with Lumx, you register the recipient's CLABE as a destination, then send one off-ramp request from the customer's USDT wallet to that destination. Lumx converts USDT to MXN and sends the pesos over SPEI. You follow the result through webhooks.

SPEI settles instantly, 24/7/365, with no cut-off time. Lumx supports USDT to MXN on Ethereum, Polygon, and Tron. See [Coverage](https://docs.lumx.io/get-started/coverage.md).

## Before you start

* An approved [customer](https://docs.lumx.io/concepts/customers.md). Request `MXN` in the `accounts` array when you create the customer, as described in [Coverage](https://docs.lumx.io/get-started/coverage.md).
* A USDT balance in the customer's [wallet](https://docs.lumx.io/concepts/wallets.md) on a supported network. Fund it with an on-ramp, or send USDT to the wallet address from any external wallet.

## 1. Create a SPEI destination

A destination is where the pesos land. Send a `POST` request to `/destinations` with `rail` set to `SPEI` and `currency` set to `MXN`.

```bash Request theme={null}
curl -X POST https://api-sandbox.lumx.io/destinations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "customerId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
    "name": "Supplier in Mexico City",
    "rail": "SPEI",
    "currency": "MXN",
    "identifier": {
      "clabe": "012180001234567899"
    },
    "holder": {
      "type": "BUSINESS",
      "relationship": "SUPPLIER",
      "legalName": "Proveedor Ejemplo S.A. de C.V.",
      "taxId": "PEJ123456789"
    }
  }'
```

```json Response (excerpt) theme={null}
{
  "id": "d04a6571-bcde-4135-a3fc-1b81c724d739",
  "rail": "SPEI",
  "currency": "MXN",
  "identifier": { "clabe": "012180001234567899" },
  "holder": {
    "type": "BUSINESS",
    "relationship": "SUPPLIER",
    "verification": { "status": "UNDER_VERIFICATION", "level": "BASIC" }
  }
}
```

### Request fields

| **Field** | **Required** | **Description** |
| :- | :- | :- |
| `customerId` | Yes | The customer that owns the destination |
| `name` | Yes | A display name for the destination |
| `rail` | Yes | `SPEI` |
| `currency` | Yes | `MXN` |
| `identifier.clabe` | Yes | The recipient's CLABE: exactly 18 digits |
| `holder.type` | Yes | `BUSINESS` or `INDIVIDUAL` |
| `holder.legalName` | For `BUSINESS` | The business's legal name |
| `holder.name` | For `INDIVIDUAL` | The person's full name |
| `holder.taxId` | Yes | The holder's tax ID. For Mexico, the RFC |
| `holder.relationship` | Yes, per the Destinations page | How the holder relates to your customer, such as `SELF`, `SUPPLIER`, or `EMPLOYEE`. See [Destinations](https://docs.lumx.io/concepts/destinations.md#holder-relationships) |

> [!NOTE]
>
> For a `SELF` destination, the holder's type and tax ID also have to match the customer's, or the request fails with `HOLDER_TYPE_MISMATCH` or `HOLDER_TAX_ID_MISMATCH`.

## 2. Wait for the destination to be approved

It starts with `holder.verification.status` set to `UNDER_VERIFICATION`. A payout needs an approved destination, so wait for the `destinations.approved` [webhook](https://docs.lumx.io/developer/webhooks.md#available-events) before you send funds. A destination that fails verification triggers `destinations.final_rejection`.

You can also read the destination with `GET /destinations/{id}` and check `holder.verification.status`.

## 3. Check the balance and the limits

Read the customer with the balance and limit options on:

```bash Request theme={null}
curl "https://api-sandbox.lumx.io/customers/3c90c3cc-0d44-4b50-8888-8dd25736052a?includeBalances=true&includeTransactionLimits=true" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The USDT amount has to be on the wallet's balance, or the off-ramp fails with `INSUFFICIENT_BALANCE`. The `offramp` entry of `transactionLimits` shows what is left, in the USD equivalent, against the `single`, `daily`, and `monthly` limits. The minimum per payout is the equivalent of USD 40 today, and it may change. See [Transaction limits](https://docs.lumx.io/compliance/transaction-limits.md).

## 4. Quote the conversion

* **Floating.** Skip the quote and let the off-ramp convert at the rate at execution time. Request a `FLOATING` rate to show an estimate in your UI.
* **Locked.** Request a `LOCKED` rate and pass its `id` to the off-ramp as `exchangeRateId`. The timelock is `30s`, `1m`, or `5m`, and the default is `30s`. See [Exchange rates](https://docs.lumx.io/concepts/exchange-rates.md).

```bash Request theme={null}
curl -X POST https://api-sandbox.lumx.io/exchange-rates \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "customerId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
    "type": "LOCKED",
    "rail": "SPEI",
    "sourceCurrency": "USDT",
    "targetCurrency": "MXN",
    "sourceAmount": "1000.00",
    "blockchain": "TRON",
    "timelock": "1m"
  }'
```

```json Response (excerpt, illustrative numbers) theme={null}
{
  "id": "123e4567-e89b-12d3-a456-426614174001",
  "type": "LOCKED",
  "rail": "SPEI",
  "sourceCurrency": "USDT",
  "sourceAmount": "1000.00",
  "targetCurrency": "MXN",
  "baseTargetAmount": "17250.000000",
  "baseExchangeRate": "17.2500",
  "finalTargetAmount": "17210.000000",
  "finalExchangeRate": "17.2100",
  "expiresAt": "2026-11-02T15:31:30Z"
}
```

`finalTargetAmount` is what the recipient receives. It is already net of Lumx fees and any partner fee you attach. The difference between the base and final amounts is the fee, not a hidden spread. Lumx does not deduct anything further from the pesos that arrive. Only the recipient's bank can.

If the lock expires before you use it, request a new one.

## 5. Create the off-ramp

Send a `POST` request to `/transactions/off-ramp`. The destination fixes the rail and the currency, so you only say where the money goes and why.

```bash Locked rate theme={null}
    curl -X POST https://api-sandbox.lumx.io/transactions/off-ramp \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "destinationId": "d04a6571-bcde-4135-a3fc-1b81c724d739",
        "exchangeRateId": "123e4567-e89b-12d3-a456-426614174001",
        "purpose": "TRADE_TRANSACTIONS",
        "memo": "INV-2026-1102"
      }'
```

```json Response (202) theme={null}
{
  "id": "123e4567-e89b-12d3-a456-426614174004",
  "customerId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
  "type": "OFF_RAMP",
  "request": {
    "destinationId": "d04a6571-bcde-4135-a3fc-1b81c724d739",
    "exchangeRateId": "123e4567-e89b-12d3-a456-426614174001",
    "purpose": "TRADE_TRANSACTIONS"
  },
  "state": {
    "status": "TRANSFERRING_STABLECOIN"
  },
  "metadata": {},
  "createdAt": "2026-11-02T15:31:00Z",
  "updatedAt": "2026-11-02T15:31:05Z"
}
```

| **Field** | **Required** | **Description** |
| :- | :- | :- |
| `destinationId` | Yes | The approved SPEI destination |
| `exchangeRateId` | Locked only | The `id` of the locked rate |
| `sourceCurrency` | Floating only | `USDT` |
| `sourceAmount` | Floating only | Amount of USDT to convert, as a string |
| `purpose` | Yes | End use of the funds, such as `TRADE_TRANSACTIONS` or `PROFESSIONAL_SERVICES`. `PERSONAL_ACCOUNT` only works with `SELF` destinations. See [Transaction purpose](https://docs.lumx.io/concepts/transactions.md#transaction-purpose) |
| `memo` | No | An optional reference for the payment |
| `blockchain` | No | `ETHEREUM`, `POLYGON`, or `TRON` for USDT. Falls back to your project's default |
| `partnerFeeId` | No | A [partner fee](https://docs.lumx.io/concepts/partner-fees.md) for floating off-ramps. Without it, partner fees are zero |
| `metadata` | No | Any JSON object, returned on the transaction |

Send a new `Idempotency-Key` for each payout. If a request times out, retry with the same key and Lumx returns the original result instead of paying twice. See [Idempotency](https://docs.lumx.io/developer/idempotency.md).

## 6. Track it with webhooks

An off-ramp moves through `TRANSFERRING_STABLECOIN`, `TRADING`, `TRANSFERRING_FIAT`, and then `SUCCESS`. Each step sends a webhook:

| **Event** | **Meaning** |
| :- | :- |
| `offramp.transferring_stablecoin` | USDT is leaving the customer's wallet |
| `offramp.trading` | The conversion is executing |
| `offramp.transferring_fiat` | The SPEI payment is being sent |
| `offramp.success` | Done. The payload carries the receipt, including `transactionHash` |
| `offramp.failed` | The off-ramp failed |

Verify the signature on every request and use the `webhook-id` header to deduplicate retries. See [Webhooks](https://docs.lumx.io/developer/webhooks.md#verifying-webhook-signatures). To check a transaction without waiting for an event, call `GET /transactions/{id}`.

## Hours and timing

SPEI has no cut-off and runs 24/7/365 with instant settlement. If a bank holiday ever stops SPEI settlement, the payout waits in `TRANSFERRING_FIAT` and settles as soon as the rail is back. These are processing targets, not guarantees. A transaction held for review (see [Request for Information](https://docs.lumx.io/compliance/rfi.md)), a large amount that needs extra risk review, or a hold at the recipient's bank can add time. Lumx counts the off-ramp as complete once the funds leave its banking partner. The SPEI row also lists the Banxico calendar of banking holidays. See [Payment rail cut-off times](https://docs.lumx.io/additional-information/sla-and-cutoffs.md) and build your UI around webhook events, not the clock.

## Errors

Match on `code`, never on `message`. See [Errors](https://docs.lumx.io/developer/errors.md).

| **Status** | **Code** | **Cause** |
| :- | :- | :- |
| `400` | `VALIDATION_ERROR` | A field failed validation, for example a CLABE that is not 18 digits. Details are in `validationErrors` |
| `400` | `PERSONAL_ACCOUNT_RELATIONSHIP_REQUIRED` | `purpose` is `PERSONAL_ACCOUNT` on a non-`SELF` destination |
| `400` | `INVALID_PAYMENT_DESTINATION` | The destination is not `APPROVED` yet, or its rail or currency does not match the payout |
| `403` | `KYC_NOT_APPROVED` | The customer is not approved |
| `403` | `HOLDER_TYPE_MISMATCH`, `HOLDER_TAX_ID_MISMATCH` | A `SELF` destination does not match the customer |
| `409` | `IDEMPOTENCY_KEY_CONFLICT` | The same `Idempotency-Key` was sent with a different body |
| `422` | `INSUFFICIENT_BALANCE` | The wallet holds less USDT than the amount |
| `422` | `MINIMUM_AMOUNT_NOT_MET` | The payout is below the minimum, the equivalent of USD 40 today (it may change) |
| `422` | `TRANSACTION_LIMIT_EXCEEDED` | The amount passes the customer's `single`, `daily`, or `monthly` off-ramp limit |
| `422` | `EXCHANGE_RATE_EXPIRED` | The locked rate expired before you used it |

## Related resources

> [card] [Destinations](https://docs.lumx.io/concepts/destinations.md)
>
> Holder relationships and what each one means.

> [card] [Payment rail cut-off times](https://docs.lumx.io/additional-information/sla-and-cutoffs.md)
>
> Settlement windows for each rail.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.