> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.latitude.xyz/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.latitude.xyz/_mcp/server.

# On-ramp integration guide

> Accept fiat deposits and convert to crypto using Latitude's on-ramp product

This guide walks through using Latitude's on-ramp product. The examples cover two corridors: **MXN → USDC via virtual CLABE (SPEI)** and **PHP → USDC via QR Ph (InstaPay)**. The flow is identical across corridors; only the KYC document type in Step 2 and the deposit account shape in Step 5 differ by country.

> Before you start, make sure you're familiar with the [API basics](/api-basics) — authentication, idempotency, webhooks, and prefunding.

## Summary

The high-level steps are as follows:

1. (Optional) Create a webhook subscription for `individual` and `conversion` events
2. Create an `individual` that represents the end user
3. Upload document images for KYC verification
4. Wait for KYC result
5. Create a `conversion_account` that represents the on-ramp mechanism, referencing the end user's `crypto_wallet` financial account as its `payout_account`
   * The response contains `deposit_instructions` (virtual CLABE for MX, QR Ph payload for PH)
   * Share the deposit instructions with the end user
   * The user can now deposit fiat at any time
6. User deposits fiat
7. Receive webhooks for conversion lifecycle events

## Detailed Steps

### Step 1: (Optional) Create a Webhook Subscription

Create a webhook subscription to be notified of individual and conversion events. See [API basics — Webhooks](/api-basics#webhooks) for details. The relevant event types for this flow are:

* `conversion.created` — sent when a deposit is received into a virtual account
* `conversion.payout_initiated` — sent when a blockchain transfer is initiated
* `conversion.completed` — sent when a blockchain transfer is completed
* `individual.status_changed` — sent when an individual's status changes

### Step 2: Create an Individual

The only field that differs between corridors here is `document`: MX uses a CURP, PH uses a TIN. All other fields (name, address, consent, etc.) have the same shape.

#### Mexico (MX)

**Request:**

```http
POST /v1/individuals HTTP/1.1
Content-Type: application/json

{
  "given_name": "María",
  "family_name": "González Hernández",
  "country": "MX",
  "product_types": ["on_ramp"],
  "email": "maria.gonzalez@ejemplo.com",
  "phone": "+525512345678",
  "date_of_birth": "1988-03-14",
  "document": {
    "type": "curp",
    "number": "GARC850615HDFRRL09"
  },
  "address": {
    "line_1": "Av. Paseo de la Reforma 505",
    "line_2": "Piso 12, Col. Cuauhtémoc",
    "city": "Ciudad de México",
    "state": "CDMX",
    "postal_code": "06500"
  },
  "idv_consent_recorded_at": "2025-01-30T14:00:00Z",
  "financial_accounts": [
    {
      "type": "crypto_wallet",
      "details": {
        "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
        "network": "base"
      }
    }
  ]
}
```

#### Philippines (PH)

**Request:**

```http
POST /v1/individuals HTTP/1.1
Content-Type: application/json

{
  "given_name": "Maria",
  "family_name": "Santos Reyes",
  "country": "PH",
  "product_types": ["on_ramp"],
  "email": "maria.santos@ejemplo.com",
  "phone": "+639171234567",
  "date_of_birth": "1988-03-14",
  "document": {
    "type": "tin",
    "number": "123-456-789-000"
  },
  "address": {
    "line_1": "123 Rizal Avenue",
    "line_2": "Barangay San Antonio",
    "city": "Manila",
    "state": "NCR",
    "postal_code": "1000"
  },
  "idv_consent_recorded_at": "2025-01-30T14:00:00Z",
  "financial_accounts": [
    {
      "type": "crypto_wallet",
      "details": {
        "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
        "network": "base"
      }
    }
  ]
}
```

The `crypto_wallet` is the account that receives USDC from each conversion. You can supply it here or add it later with `POST /v1/financial_accounts`, but it must exist before you create the conversion account in Step 5, which references it by `id`.

`idv_consent_recorded_at` is required for on-ramp: timestamp when the end user accepted identity verification consent (ISO 8601).

**Response:**

```http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "ind_7a3b2c1d",
  "status": "action_required",
  "status_details": [
    {
      "code": "verification_front_image_needed",
      "message": "Please provide a photo of the front of your government-issued ID."
    },
    {
      "code": "verification_back_image_needed",
      "message": "Please provide a photo of the back of your government-issued ID."
    }
  ],
  "given_name": "María",
  "family_name": "González Hernández",
  "email": "maria.gonzalez@ejemplo.com",
  "phone": "+525512345678",
  "date_of_birth": "1988-03-14",
  "country": "MX",
  "document": {
    "type": "curp",
    "number": "GARC850615HDFRRL09"
  },
  "address": {
    "line_1": "Av. Paseo de la Reforma 505",
    "line_2": "Piso 12, Col. Cuauhtémoc",
    "city": "Ciudad de México",
    "state": "CDMX",
    "postal_code": "06500",
    "country": "MX"
  },
  "financial_accounts": [
    {
      "id": "fa_5e6f7g8h",
      "type": "crypto_wallet",
      "details": {
        "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
        "network": "base"
      },
      "created_at": "2025-01-30T14:32:00Z",
      "updated_at": "2025-01-30T14:32:00Z"
    }
  ],
  "product_types": ["on_ramp"],
  "created_at": "2025-01-30T14:32:00Z",
  "updated_at": "2025-01-30T14:32:00Z"
}
```

The response includes `status` and `status_details`. For on-ramp individuals, the initial status is `action_required` because document images are needed for KYC verification. The `status_details` array contains objects with a machine-readable `code` and a human-readable `message` explaining what's needed.

#### Individual Statuses

| Status            | Meaning                              | Next Steps                                                                            |
| ----------------- | ------------------------------------ | ------------------------------------------------------------------------------------- |
| `action_required` | You and/or end user must take action | Check `status_details[].code` and take the appropriate action                         |
| `pending`         | Awaiting processing                  | Wait for an `individual.status_changed` webhook                                       |
| `under_review`    | Latitude is performing manual review | Wait for an `individual.status_changed` webhook                                       |
| `active`          | Passed all checks                    | Proceed to create a `conversion_account`                                              |
| `rejected`        | Failed permanently                   | Can't move forward with this individual; check `status_details[].code` for the reason |

When the status is `action_required`, `status_details` is an array of objects with `code` and `message`. A `rejected` individual also carries `status_details`, explaining the terminal reason. For `active`, `pending`, and `under_review`, the `status_details` key is omitted from the payload entirely rather than being returned as `null` or an empty array.

> **Note:** `status_details[].code` is a machine-readable string you can use for programmatic branching. `status_details[].message` is a human-readable string suitable for manual review. New codes may be added over time without changing the set of statuses.

### Step 3: Upload Verification Images

> **Note:** Latitude supports other options for completing KYC. Contact us to discuss alternatives.

After creating the individual, upload photos of the front and back (if applicable) of the end user's government ID.

**Request:**

```http
POST /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
Content-Type: application/json

{
  "images": [
    {
      "type": "document-front",
      "content": "data:image/jpeg;base64,/9j/4AAQ..."
    },
    {
      "type": "document-back",
      "content": "data:image/jpeg;base64,/9j/4AAQ..."
    }
  ]
}
```

The `type` field identifies what the image represents. The accepted types are `document-front` and `document-back`. The `content` field is the image as a base64-encoded data URI.

Constraints:

* The individual must be in `action_required` status; uploading in any other status returns a `422`.
* 1–10 images per request.
* Accepted MIME types: `image/jpeg`, `image/png`, `image/heic`, `image/heif`, `image/tiff`, and `application/pdf`.
* Each decoded file must be between 10 KB and 15 MB.

**Response:**

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "individual_id": "ind_7a3b2c1d",
  "images": [
    {
      "id": "vimg_xyz789",
      "type": "document-front",
      "content_type": "image/jpeg",
      "created_at": "2025-01-30T14:33:00Z",
      "updated_at": "2025-01-30T14:33:00Z"
    },
    {
      "id": "vimg_def456",
      "type": "document-back",
      "content_type": "image/jpeg",
      "created_at": "2025-01-30T14:33:00Z",
      "updated_at": "2025-01-30T14:33:00Z"
    }
  ]
}
```

#### Replacing an Image

The endpoint uses merge-by-side semantics: only the sides included in the request are replaced. Existing images for other sides are preserved. For example, to replace just the front image:

```http
POST /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
Content-Type: application/json

{
  "images": [
    {
      "type": "document-front",
      "content": "data:image/jpeg;base64,/9j/NEW..."
    }
  ]
}
```

#### Retrieving Image Metadata

```http
GET /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "individual_id": "ind_7a3b2c1d",
  "images": [
    {
      "id": "vimg_xyz789",
      "type": "document-front",
      "content_type": "image/jpeg",
      "created_at": "2025-01-30T14:33:00Z",
      "updated_at": "2025-01-30T14:33:00Z"
    },
    {
      "id": "vimg_def456",
      "type": "document-back",
      "content_type": "image/jpeg",
      "created_at": "2025-01-30T14:33:00Z",
      "updated_at": "2025-01-30T14:33:00Z"
    }
  ]
}
```

#### Sandbox Image Testing

In sandbox, any valid image will generate an approved outcome by default. Use the following magic strings as the base64-encoded image content to trigger specific outcomes:

| `content` value                                       | Outcome                                                                                                           |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `data:image/jpeg;base64,YXBwcm92ZWQ=`                 | KYC approved — individual moves to `active`                                                                       |
| `data:image/jpeg;base64,cmVqZWN0ZWQtYmx1cnJ5`         | Image rejected as blurry — individual moves to `action_required` with a `verification_image_blurry` status detail |
| `data:image/jpeg;base64,cmVqZWN0ZWQtYWdlLW1pc21hdGNo` | Terminal rejection — individual moves to `rejected` with a `verification_age_mismatch` status detail              |

**Sample request to simulate one image being blurry:**

```http
POST /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
Content-Type: application/json

{
  "images": [
    {
      "type": "document-front",
      "content": "data:image/jpeg;base64,cmVqZWN0ZWQtYmx1cnJ5"
    },
    {
      "type": "document-back",
      "content": "data:image/jpeg;base64,YXBwcm92ZWQ="
    }
  ]
}
```

### Step 4: Wait for KYC Result

After uploading document images, the individual's status moves to `pending` while KYC verification is processed. You will receive an `individual.status_changed` webhook when the status changes.

* If the status changes to `active`, proceed to create a `conversion_account`.
* If the status changes to `action_required`, check `status_details[].code` to determine what's needed.

| `status_details[].code`                  | Meaning                                                               |
| ---------------------------------------- | --------------------------------------------------------------------- |
| `verification_front_image_needed`        | A photo of the front of the government ID is needed                   |
| `verification_back_image_needed`         | A photo of the back of the government ID is needed                    |
| `verification_image_blurry`              | An image was too blurry to process                                    |
| `verification_image_glare`               | An image had too much glare                                           |
| `verification_portrait_unclear`          | The portrait on the ID was unclear                                    |
| `verification_portrait_missing`          | No portrait was detected on the ID                                    |
| `verification_document_not_detected`     | No ID document was detected in the photo                              |
| `verification_image_unprocessable`       | The image could not be processed                                      |
| `verification_document_damaged`          | The ID document appears damaged                                       |
| `verification_document_expired`          | The ID document has expired                                           |
| `verification_unsupported_document_type` | That type of ID document is not accepted                              |
| `verification_country_not_determined`    | The country of the ID could not be determined                         |
| `verification_electronic_replica`        | The image appears to be a photo of a screen rather than a physical ID |
| `verification_attribute_mismatch`        | The details on the ID do not match the information provided           |
| `verification_unknown_error`             | The ID document could not be identified in the photo                  |

The following codes are **terminal** — they accompany a `rejected` status and cannot be resolved by re-uploading:

| `status_details[].code`            | Meaning                                                              |
| ---------------------------------- | -------------------------------------------------------------------- |
| `verification_disallowed_country`  | The end user's jurisdiction is not supported                         |
| `verification_age_mismatch`        | The end user does not meet the minimum age requirement               |
| `verification_identity_unverified` | The identity could not be verified; do not resubmit the same details |

After taking the requested action (e.g., uploading clearer photos), the individual returns to `pending` and KYC verification is re-processed.

**Sample `individual.status_changed` webhook (KYC passed):**

```json
{
  "event_type": "individual.status_changed",
  "event_payload": {
    "id": "ind_7a3b2c1d",
    "status": "active",
    "previous_status": "pending",
    "updated_at": "2025-01-30T14:35:00Z"
  }
}
```

**Sample `individual.status_changed` webhook (action needed):**

```json
{
  "event_type": "individual.status_changed",
  "event_payload": {
    "id": "ind_7a3b2c1d",
    "status": "action_required",
    "status_details": [
      {
        "code": "verification_image_blurry",
        "message": "Your ID photo was too blurry. Please provide a clearer photo."
      }
    ],
    "previous_status": "pending",
    "updated_at": "2025-01-30T14:35:00Z"
  }
}
```

### Step 5: Create a Conversion Account

The individual must have `active` status before a conversion account can be created.

The request is the same shape in both corridors; set `source_currency` to `mxn` for MX or `php` for PH. `payout_account` references an existing `crypto_wallet` financial account on the individual by `id` — you cannot define one inline here. The `deposit_instructions` returned in the response differ by country — see the tabs below the request.

The endpoint returns `201 Created` when deposit instructions are ready. If provisioning with the upstream provider is still in flight, it returns `202 Accepted` with the conversion account in `pending` status and no `deposit_instructions`; poll `GET /v1/conversion_accounts/{id}` until the status is `active`.

**Request:**

```http
POST /v1/conversion_accounts HTTP/1.1
Content-Type: application/json

{
  "individual_id": "ind_7a3b2c1d",
  "payout_account": { "id": "fa_5e6f7g8h" },
  "source_currency": "mxn",
  "destination_currency": "usdc"
}
```

**Response:**

#### Mexico (MX) — virtual CLABE

```http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "cnva_4e5f6a7b",
  "individual_id": "ind_7a3b2c1d",
  "payout_account": {
    "id": "fa_5e6f7g8h",
    "type": "crypto_wallet",
    "details": {
      "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
      "network": "base"
    },
    "created_at": "2025-01-30T14:32:00Z",
    "updated_at": "2025-01-30T14:32:00Z"
  },
  "deposit_instructions": {
    "type": "mx_virtual_account",
    "currency": "mxn",
    "payment_methods": ["spei"],
    "details": {
      "clabe": "646180294817365024"
    }
  },
  "source_currency": "mxn",
  "destination_currency": "usdc",
  "status": "active",
  "created_at": "2025-01-30T14:33:15Z",
  "updated_at": "2025-01-30T14:33:15Z"
}
```

#### Philippines (PH) — QR Ph

```http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "cnva_4e5f6a7b",
  "individual_id": "ind_7a3b2c1d",
  "payout_account": {
    "id": "fa_5e6f7g8h",
    "type": "crypto_wallet",
    "details": {
      "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
      "network": "base"
    },
    "created_at": "2025-01-30T14:32:00Z",
    "updated_at": "2025-01-30T14:32:00Z"
  },
  "deposit_instructions": {
    "type": "ph_qr_code",
    "currency": "php",
    "payment_methods": ["instapay"],
    "details": {
      "qrph_payload": "00020101021128530016ph.ppmi.p2m.qrph0118SANDBOXPHPMERCHANT5204601253036085802PH5922COINS SANDBOX MERCHANT6006MANILA62230519coins_ref_xyz6304SBOX"
    }
  },
  "source_currency": "php",
  "destination_currency": "usdc",
  "status": "active",
  "created_at": "2025-01-30T14:33:15Z",
  "updated_at": "2025-01-30T14:33:15Z"
}
```

`deposit_instructions` is a description of where the end user pays, not a financial account on the individual — it has no `id` of its own and does not appear in `GET /v1/individuals/{id}`. Re-read it at any time from `GET /v1/conversion_accounts/{id}`.

#### Sharing deposit instructions with the end user

* **MX:** share the `clabe` string with the end user. They can then send a SPEI transfer to that CLABE from any Mexican bank.
* **PH:** the `qrph_payload` is an [EMV QR Ph](https://www.bsp.gov.ph/SitePages/PaymentAndSettlement/QRPh.aspx) payload string conforming to the `ph.ppmi.p2m.qrph` standard. Render it as a scannable QR code in your UI using any standard QR code library (e.g. `qrcode.js`, `zxing`). The end user scans it with their bank or e-wallet app to pay via InstaPay.

### Step 6: User Deposits Fiat

At this point, the end user may send funds to the deposit account at any time — via SPEI to the CLABE (MX), or by scanning the QR Ph code (PH). Each time a deposit is received, a new conversion will be created.

#### Simulate Deposit (Sandbox Only)

In sandbox, a deposit can be simulated using the sandbox-only simulation endpoint. Provide the `conversion_account_id` of the conversion account and the amount of fiat currency to deposit. This endpoint works the same way for both corridors; just vary `currency` and the amount.

**Request:**

```http
POST /v1/deposits/simulate HTTP/1.1
Content-Type: application/json

{
  "conversion_account_id": "cnva_4e5f6a7b",
  "amount": "50.00",
  "currency": "MXN"
}
```

**Response:**

```http
HTTP/1.1 201 Created
Content-Type: application/json

{
  "status": "ok"
}
```

`amount` is required for conversion account deposits, and `currency` must match the conversion account's `source_currency`. You may optionally pass `sender_name` to control the sender name on the simulated fiat deposit; omit it to default to the individual's name, or pass an empty string to simulate a missing sender name.

> **Note:** Sandbox deposits are capped at a small amount per corridor (50 MXN for MX, 100 PHP for PH). The endpoint returns `403` outside sandbox.

### Step 7: Receive Webhooks

> **Note:** Polling is also available as an alternative to webhooks. You can poll for updates on the [list conversions endpoint](api-reference/conversions/get-v-1-conversions).

The webhook event types and payload shapes are identical across corridors — only the `currency` fields in `deposit` and `payout`, and the numeric values, differ by country.

A conversion's `status` is one of `pending`, `awaiting_funds`, `started`, `completed`, `cancelled`, `failed`, or `expired`. A conversion held for manual review is reported as `pending`.

#### a) When an individual's status changes

```json
{
  "event_type": "individual.status_changed",
  "event_payload": {
    "id": "ind_7a3b2c1d",
    "status": "active",
    "previous_status": "pending",
    "updated_at": "2025-01-30T14:35:00Z"
  }
}
```

#### b) When a deposit is received into a virtual account

```json
{
  "event_type": "conversion.created",
  "event_payload": {
    "id": "cnv_abc123",
    "conversion_account_id": "cnva_4e5f6a7b",
    "individual_id": "ind_7a3b2c1d",
    "status": "pending",
    "deposit": {
      "amount": "10000.00",
      "currency": "mxn",
      "received_at": "2025-01-30T14:40:00Z"
    },
    "payout": {
      "amount": "495.00",
      "currency": "usdc",
      "payout_account_id": "fa_5e6f7g8h",
      "tx_hash": null,
      "initiated_at": null,
      "completed_at": null
    },
    "total_fee": "100.00",
    "fee_currency": "mxn",
    "exchange_rate": "0.0495",
    "created_at": "2025-01-30T14:40:00Z",
    "updated_at": "2025-01-30T14:40:00Z"
  }
}
```

#### c) When a blockchain transfer is initiated

```json
{
  "event_type": "conversion.payout_initiated",
  "event_payload": {
    "id": "cnv_abc123",
    "conversion_account_id": "cnva_4e5f6a7b",
    "individual_id": "ind_7a3b2c1d",
    "status": "started",
    "deposit": {
      "amount": "10000.00",
      "currency": "mxn",
      "received_at": "2025-01-30T14:40:00Z"
    },
    "payout": {
      "amount": "495.00",
      "currency": "usdc",
      "payout_account_id": "fa_5e6f7g8h",
      "initiated_at": "2025-01-30T14:41:00Z",
      "completed_at": null,
      "tx_hash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
    },
    "total_fee": "100.00",
    "fee_currency": "mxn",
    "exchange_rate": "0.0495",
    "created_at": "2025-01-30T14:40:00Z",
    "updated_at": "2025-01-30T14:41:00Z"
  }
}
```

#### d) When a blockchain transfer is completed

```json
{
  "event_type": "conversion.completed",
  "event_payload": {
    "id": "cnv_abc123",
    "conversion_account_id": "cnva_4e5f6a7b",
    "individual_id": "ind_7a3b2c1d",
    "status": "completed",
    "deposit": {
      "amount": "10000.00",
      "currency": "mxn",
      "received_at": "2025-01-30T14:40:00Z"
    },
    "payout": {
      "amount": "495.00",
      "currency": "usdc",
      "payout_account_id": "fa_5e6f7g8h",
      "initiated_at": "2025-01-30T14:41:00Z",
      "completed_at": "2025-01-30T14:45:00Z",
      "tx_hash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
    },
    "total_fee": "100.00",
    "fee_currency": "mxn",
    "exchange_rate": "0.0495",
    "created_at": "2025-01-30T14:40:00Z",
    "updated_at": "2025-01-30T14:45:00Z"
  }
}
```