On-ramp integration guide

This guide walks through using Latitude’s on-ramp product. The examples here describe on-ramping MXN into USDC via virtual accounts utilizing virtual CLABEs.

Before you start, make sure you’re familiar with the 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
    • The response contains a virtual CLABE
    • Share the virtual CLABE with the end user
    • The user can now deposit MXN into the virtual CLABE at any time
  6. User deposits MXN
  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 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

Request:

1POST /v1/individuals HTTP/1.1
2Content-Type: application/json
3
4{
5 "given_name": "María",
6 "family_name": "González Hernández",
7 "country": "MX",
8 "product_types": ["on_ramp"],
9 "email": "[email protected]",
10 "phone": "+525512345678",
11 "date_of_birth": "1988-03-14",
12 "document": {
13 "type": "curp",
14 "number": "GARC850615HDFRRL09"
15 },
16 "address": {
17 "line_1": "Av. Paseo de la Reforma 505",
18 "line_2": "Piso 12, Col. Cuauhtémoc",
19 "city": "Ciudad de México",
20 "state": "CDMX",
21 "postal_code": "06500"
22 }
23}

Response:

1HTTP/1.1 201 Created
2Content-Type: application/json
3
4{
5 "id": "ind_7a3b2c1d",
6 "status": "action_required",
7 "status_details": [
8 {
9 "code": "verification_front_image_needed",
10 "message": "Please upload a photo of the front of your government-issued ID."
11 },
12 {
13 "code": "verification_back_image_needed",
14 "message": "Please upload a photo of the back of your government-issued ID."
15 }
16 ],
17 "first_name": "María",
18 "last_name": "González Hernández",
19 "email": "[email protected]",
20 "phone": "+525512345678",
21 "date_of_birth": "1988-03-14",
22 "country": "MX",
23 "document": {
24 "type": "curp",
25 "number": "GARC850615HDFRRL09"
26 },
27 "address": {
28 "line_1": "Av. Paseo de la Reforma 505",
29 "line_2": "Piso 12, Col. Cuauhtémoc",
30 "city": "Ciudad de México",
31 "state": "CDMX",
32 "postal_code": "06500",
33 "country": "MX"
34 },
35 "financial_accounts": [],
36 "product_types": ["on_ramp"],
37 "created_at": "2025-01-30T14:32:00Z",
38 "updated_at": "2025-01-30T14:32:00Z"
39}

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

StatusMeaningNext Steps
action_requiredYou and/or end user must take actionCheck status_details[].code and take the appropriate action
pendingUnder review or awaiting processingWait for an individual.status_changed webhook
activePassed all checksProceed to create a conversion_account
rejectedFailed permanentlyCan’t move forward with this individual

When the status is action_required, status_details is an array of objects with code and message. When status is active, pending, or rejected, status_details is null.

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:

1POST /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
2Content-Type: application/json
3
4{
5 "images": [
6 {
7 "type": "document-front",
8 "content": "data:image/jpeg;base64,/9j/4AAQ..."
9 },
10 {
11 "type": "document-back",
12 "content": "data:image/jpeg;base64,/9j/4AAQ..."
13 }
14 ]
15}

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.

Response:

1HTTP/1.1 200 OK
2Content-Type: application/json
3
4{
5 "individual_id": "ind_7a3b2c1d",
6 "images": [
7 {
8 "id": "vimg_xyz789",
9 "type": "document-front",
10 "content_type": "image/jpeg",
11 "created_at": "2025-01-30T14:33:00Z"
12 },
13 {
14 "id": "vimg_def456",
15 "type": "document-back",
16 "content_type": "image/jpeg",
17 "created_at": "2025-01-30T14:33:00Z"
18 }
19 ]
20}

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:

1POST /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
2Content-Type: application/json
3
4{
5 "images": [
6 {
7 "type": "document-front",
8 "content": "data:image/jpeg;base64,/9j/NEW..."
9 }
10 ]
11}

Retrieving Image Metadata

1GET /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
1HTTP/1.1 200 OK
2Content-Type: application/json
3
4{
5 "individual_id": "ind_7a3b2c1d",
6 "images": [
7 {
8 "id": "vimg_xyz789",
9 "type": "document-front",
10 "content_type": "image/jpeg",
11 "created_at": "2025-01-30T14:33:00Z"
12 },
13 {
14 "id": "vimg_def456",
15 "type": "document-back",
16 "content_type": "image/jpeg",
17 "created_at": "2025-01-30T14:33:00Z"
18 }
19 ]
20}

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 valueOutcome
data:image/jpeg;base64,YXBwcm92ZWQ=KYC approved — individual moves to active
data:image/jpeg;base64,cmVqZWN0ZWQtYmx1cnJ5KYC rejected — individual moves to action_required with blurry image status details

Sample request to simulate one image being blurry:

1POST /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
2Content-Type: application/json
3
4{
5 "images": [
6 {
7 "type": "document-front",
8 "content": "data:image/jpeg;base64,cmVqZWN0ZWQtYmx1cnJ5"
9 },
10 {
11 "type": "document-back",
12 "content": "data:image/jpeg;base64,YXBwcm92ZWQ="
13 }
14 ]
15}

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[].codeMeaning
verification_{front/back}_image_neededUpload front/back government ID photos
verification_{front/back}_image_blurryOne or more images were too blurry
document_mismatchDocument doesn’t match provided information

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):

1{
2 "event_type": "individual.status_changed",
3 "event_payload": {
4 "id": "ind_7a3b2c1d",
5 "status": "active",
6 "status_detail": null,
7 "previous_status": "pending",
8 "updated_at": "2025-01-30T14:35:00Z"
9 }
10}

Sample individual.status_changed webhook (action needed):

1{
2 "event_type": "individual.status_changed",
3 "event_payload": {
4 "id": "ind_7a3b2c1d",
5 "status": "action_required",
6 "status_details": [
7 {
8 "code": "verification_front_image_blurry",
9 "message": "The front image of the ID was too blurry. Please upload a clearer photo."
10 }
11 ],
12 "previous_status": "pending",
13 "updated_at": "2025-01-30T14:35:00Z"
14 }
15}

Step 5: Create a Conversion Account

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

Request:

1POST /v1/conversion_accounts HTTP/1.1
2Content-Type: application/json
3
4{
5 "individual_id": "ind_7a3b2c1d",
6 "payout_account": {
7 "type": "crypto_wallet",
8 "details": {
9 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
10 "network": "base"
11 }
12 },
13 "source_currency": "mxn",
14 "destination_currency": "usdc"
15}

Response:

1HTTP/1.1 201 Created
2Content-Type: application/json
3
4{
5 "id": "cacc_4e5f6a7b",
6 "individual_id": "ind_7a3b2c1d",
7 "payout_account": {
8 "id": "fa_5e6f7g8h",
9 "type": "crypto_wallet",
10 "details": {
11 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
12 "network": "base"
13 },
14 "created_at": "2025-01-30T14:33:15Z",
15 "updated_at": "2025-01-30T14:33:15Z"
16 },
17 "deposit_account": {
18 "id": "fa_9i0j1k2l",
19 "type": "virtual_account",
20 "details": {
21 "clabe": "646180294817365024"
22 },
23 "created_at": "2025-01-30T14:33:15Z",
24 "updated_at": "2025-01-30T14:33:15Z"
25 },
26 "source_currency": "mxn",
27 "destination_currency": "usdc",
28 "status": "active",
29 "created_at": "2025-01-30T14:33:15Z",
30 "updated_at": "2025-01-30T14:33:15Z"
31}

The deposit_account contains the virtual CLABE that you share with the end user. These financial accounts are also associated with the individual and will appear in GET /v1/individuals/{id}.

Step 6: User Deposits Fiat

At this point, the end user may send funds to the virtual CLABE via SPEI at any point. 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 financial_account_id of the virtual account and the amount of fiat currency to deposit.

Request:

1POST /v1/deposits/simulate HTTP/1.1
2Content-Type: application/json
3
4{
5 "financial_account_id": "fa_9i0j1k2l",
6 "amount": "50.00",
7 "currency": "MXN"
8}

Note: There is a 50 MXN limit for sandbox deposits.

Step 7: Receive Webhooks

Note: Polling is also available as an alternative to webhooks. You can poll for updates on the list conversions endpoint.

a) When an individual’s status changes

1{
2 "event_type": "individual.status_changed",
3 "event_payload": {
4 "id": "ind_7a3b2c1d",
5 "status": "active",
6 "status_detail": null,
7 "previous_status": "pending",
8 "updated_at": "2025-01-30T14:35:00Z"
9 }
10}

b) When a deposit is received into a virtual account

1{
2 "event_type": "conversion.created",
3 "event_payload": {
4 "id": "cnv_abc123",
5 "conversion_account_id": "cacc_4e5f6a7b",
6 "individual_id": "ind_7a3b2c1d",
7 "status": "processing",
8 "deposit": {
9 "amount": "10000.00",
10 "currency": "mxn",
11 "funding_account_id": "fa_9i0j1k2l",
12 "received_at": "2025-01-30T14:40:00Z"
13 },
14 "payout": null,
15 "total_fee": null,
16 "fee_currency": null,
17 "exchange_rate": null,
18 "created_at": "2025-01-30T14:40:00Z",
19 "updated_at": "2025-01-30T14:40:00Z"
20 }
21}

c) When a blockchain transfer is initiated

1{
2 "event_type": "conversion.payout_initiated",
3 "event_payload": {
4 "id": "cnv_abc123",
5 "conversion_account_id": "cacc_4e5f6a7b",
6 "individual_id": "ind_7a3b2c1d",
7 "status": "processing",
8 "deposit": {
9 "amount": "10000.00",
10 "currency": "mxn",
11 "funding_account_id": "fa_9i0j1k2l",
12 "received_at": "2025-01-30T14:40:00Z",
13 "tx_hash": null
14 },
15 "payout": {
16 "amount": "495.00",
17 "currency": "usdc",
18 "payout_account_id": "fa_5e6f7g8h",
19 "initiated_at": "2025-01-30T14:41:00Z",
20 "completed_at": null,
21 "tx_hash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
22 },
23 "total_fee": "100.00",
24 "fee_currency": "mxn",
25 "exchange_rate": "0.0495",
26 "created_at": "2025-01-30T14:40:00Z",
27 "updated_at": "2025-01-30T14:41:00Z"
28 }
29}

d) When a blockchain transfer is completed

1{
2 "event_type": "conversion.completed",
3 "event_payload": {
4 "id": "cnv_abc123",
5 "conversion_account_id": "cacc_4e5f6a7b",
6 "individual_id": "ind_7a3b2c1d",
7 "status": "completed",
8 "deposit": {
9 "amount": "10000.00",
10 "currency": "mxn",
11 "funding_account_id": "fa_9i0j1k2l",
12 "received_at": "2025-01-30T14:40:00Z"
13 },
14 "payout": {
15 "amount": "495.00",
16 "currency": "usdc",
17 "payout_account_id": "fa_5e6f7g8h",
18 "initiated_at": "2025-01-30T14:41:00Z",
19 "completed_at": "2025-01-30T14:45:00Z",
20 "tx_hash": "0x7a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a"
21 },
22 "total_fee": "100.00",
23 "fee_currency": "mxn",
24 "exchange_rate": "0.0495",
25 "created_at": "2025-01-30T14:40:00Z",
26 "updated_at": "2025-01-30T14:45:00Z"
27 }
28}