Skip to navigation

On-ramp integration guide

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 — 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 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.

Request:

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": "[email protected]",
"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"
}
}
]
}

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/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": "[email protected]",
"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

StatusMeaningNext Steps
action_requiredYou and/or end user must take actionCheck status_details[].code and take the appropriate action
pendingAwaiting processingWait for an individual.status_changed webhook
under_reviewLatitude is performing manual reviewWait for an individual.status_changed webhook
activePassed all checksProceed to create a conversion_account
rejectedFailed permanentlyCan’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:

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

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

GET /v1/individuals/ind_7a3b2c1d/verification_images HTTP/1.1
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 valueOutcome
data:image/jpeg;base64,YXBwcm92ZWQ=KYC approved — individual moves to active
data:image/jpeg;base64,cmVqZWN0ZWQtYmx1cnJ5Image rejected as blurry — individual moves to action_required with a verification_image_blurry status detail
data:image/jpeg;base64,cmVqZWN0ZWQtYWdlLW1pc21hdGNoTerminal rejection — individual moves to rejected with a verification_age_mismatch status detail

Sample request to simulate one image being blurry:

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[].codeMeaning
verification_front_image_neededA photo of the front of the government ID is needed
verification_back_image_neededA photo of the back of the government ID is needed
verification_image_blurryAn image was too blurry to process
verification_image_glareAn image had too much glare
verification_portrait_unclearThe portrait on the ID was unclear
verification_portrait_missingNo portrait was detected on the ID
verification_document_not_detectedNo ID document was detected in the photo
verification_image_unprocessableThe image could not be processed
verification_document_damagedThe ID document appears damaged
verification_document_expiredThe ID document has expired
verification_unsupported_document_typeThat type of ID document is not accepted
verification_country_not_determinedThe country of the ID could not be determined
verification_electronic_replicaThe image appears to be a photo of a screen rather than a physical ID
verification_attribute_mismatchThe details on the ID do not match the information provided
verification_unknown_errorThe 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[].codeMeaning
verification_disallowed_countryThe end user’s jurisdiction is not supported
verification_age_mismatchThe end user does not meet the minimum age requirement
verification_identity_unverifiedThe 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):

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

{
"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:

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:

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"
}

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

POST /v1/deposits/simulate HTTP/1.1
Content-Type: application/json
{
"conversion_account_id": "cnva_4e5f6a7b",
"amount": "50.00",
"currency": "MXN"
}

Response:

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.

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

{
"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

{
"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

{
"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

{
"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"
}
}