Create a Cross-Ramp Payment
For certain payment corridors, you can deliver a fiat currency to a provider and have that provider pay out directly to a beneficiary in a different local currency. This guide describes how to create and execute a cross-ramp payment.
How the cross-ramp differs
The provider handles conversion from the "from" fiat currency to the "to" fiat currency. As a result:
- No step is signed — there is no on-chain transfer, so your organization never signs a wallet step.
- No risk screening. Cross-ramp payments are not screened:
risk_statusisnulland nopayment.risk_updatedwebhook fires. - No balance reservation. Likewise, because no Tesser-held balance is reserved,
balance_statusstaysnulland nopayment.balance_updatedwebhook fires.
The originator funds the cross-ramp from the source-currency virtual account they hold with you. How that account is itself funded is outside Tesser.
Workflow
Once the originator is onboarded (see Prerequisites), a cross-ramp payment progresses through these phases:
- Create the payment and request an FX rate quote —
POST /v1/paymentswith the currency pair. Receive a payment ID synchronously. Tesser requests a cross-ramp quote from the provider and fires a singlepayment.quote_createdwebhook carrying the end-to-end FX rate and the two plannedsteps[]. - Submit accounts and lock the rate —
PATCH /v1/payments/{paymentId}with the originator's source-currency account (desired.from.account_idandfunding_account_id) and the beneficiary's destination-currency account (desired.to.account_id). This attaches the accounts and instructs Tesser to create the cross-ramp with the provider, locking the quoted rate. Tesser fires apayment.updatedwebhook. This PATCH must happen before the quote expires. - Fund the cross-ramp — the originator pushes the exact source-fiat amount into the provider's source-currency virtual account (step 1). Tesser observes receipt and step 1 completes.
- Provider converts and pays out — the provider converts the source fiat to the destination fiat and pays it out to the beneficiary's bank (step 2).
- Reach terminal state — the payment's
actualoverlay populates and a finalpayment.updatedwebhook fires alongside the laststep.completed.
The Two-Step Model
A cross-ramp payment is one Payment resource whose steps[] array contains two funds-transfer steps. The table below summarizes what your organization sees; the per-step sections later in this guide walk through each in detail.
| Step | Description | Step type | Initiated by | You sign? |
|---|---|---|---|---|
| 1 | Fiat transfer of the source currency from the originator's virtual account to the cross-ramp provider's source-currency virtual account | transfer | Originator (pushes source fiat via the local push-payment rail) | No |
| 2 | Fiat payout of the destination currency from the cross-ramp provider's destination-currency virtual account to the beneficiary's bank account | transfer | Cross-ramp provider | No |
The provider converts the source currency to the destination currency between these two steps — inside the provider, moving funds from its source-currency virtual account to its destination-currency virtual account. That conversion is not surfaced as a swap step, which is why step 1 is denominated entirely in the source currency and step 2 entirely in the destination currency. The end-to-end rate is carried on the payment-level estimated overlay. See Steps.
Example Scenario
The remainder of this guide walks through a concrete example: a cross-ramp payment from Mexico to the United States.
Prerequisites
Originators must be onboarded with the cross-ramp provider before you can lock a payment they fund. Onboarding includes the provider's business verification (KYB). Tesser performs the registration, so you do not call the provider yourself. Onboarding happens once per originator, outside the API.
If you attempt Phase 2 for an originator that has not been onboarded, the request is refused with payments-3089. If the originator is onboarded but verification has not completed, it is refused with payments-3090.
Send Tesser your originator list
Send Tesser a CSV with one row per originator. All five columns are required and the header row must match exactly.
| Column | Description |
|---|---|
business_legal_name | The originator's registered legal name. Tesser matches resubmissions on this value, so keep it stable across files. |
email | Operational contact for the originator. Verification correspondence goes to this address. |
country | ISO 3166-1 alpha-2 country of the originator's business address, e.g., MX. |
tos_ip | The IP address the originator accepted your terms of service from. |
tos_accepted_at | When the originator accepted your terms, as an ISO 8601 instant with a timezone. Both 2026-08-14T09:21:00Z and 2026-08-19T17:04:32-06:00 are accepted. |
Quote any field that contains a comma.
Code
Terms of service acceptance is a compliance record
tos_ip and tos_accepted_at cannot be reconstructed after the fact. Capture them when the originator accepts your terms, and send the values you captured rather than the time you assembled the file.
What Tesser configures
Tesser registers each row with the cross-ramp provider, requests business verification, and creates a counterparty in your workspace for each originator.
Do not create counterparties for your own originators. A counterparty you create yourself carries no provider registration, so payments funded from its accounts are refused in Phase 2.
Results Tesser returns
Tesser returns a CSV with one row per originator you submitted. Rows are processed independently, so one failure does not affect the others.
| Column | Description |
|---|---|
status | ok or error. Rows with error carry the reason in the error column and are the only ones to resubmit. |
counterparty_id | The originator's Tesser counterparty ID. Use this ID for the rest of this guide. |
verification_status, verified | The provider's business verification state. Payments can be locked for the originator once verified is true. |
kyb_link | The originator's hosted verification link. Forward it to the originator while verification is pending. The link is stable, so a resubmission returns the same one. |
deposit_instructions_pending | true when the originator is verified but the provider has not issued a deposit account yet. |
In sandbox, verification completes in seconds. In production it can take days. Those rows come back with verified set to false and a kyb_link to forward, and Tesser records the verification when the provider reports it. Contact Tesser if an originator stays unverified longer than you expect.
deposit_instructions_pending set to true on a newly verified originator does not block anything. The provider issues the originator's deposit account when you lock their first payment, and Phase 3 reads the deposit instructions from the payment.
Register the accounts
Using the counterparty_id Tesser returned, register the accounts this payment will reference.
- The originator's source-currency account, which they push MXN from in step 1.
- The beneficiary counterparty. For more information, see Create a Counterparty.
- The beneficiary's destination-currency bank account, owned by that beneficiary counterparty. For more information, see Create an Account.
Beneficiaries need no provider onboarding, because nothing is funded from their accounts.
Code
Keep the account IDs these calls return. The originator's MXN account is both the funding_account_id and the desired.from.account_id, and the beneficiary's USD account is the desired.to.account_id. You submit all three in Phase 2.
Sandbox test amounts
In sandbox the provider simulates settlement from the amount the originator funds, so a payment funded with one of the amounts below follows a scripted path. This lets you exercise each outcome end to end without moving real money, including the funding step, which the provider simulates rather than waiting for a real deposit.
| Funded amount (MXN) | What the payment does |
|---|---|
2000.01 | Settles. Step 1 completes, step 2 moves through submitted, confirmed, and completed, and the payment reaches its terminal state with the actual overlay populated. |
2000.02 | The payout is rejected because the beneficiary is not a verified account holder. Step 2 fails carrying the reason. Step 1 fails as well, unless it had already completed, in which case it stays completed. |
2000.03 | Stops once the payout is confirmed. Step 1 completes and step 2 reaches confirmed and stays there, which leaves the payout leg open to test against. |
Any other amount matches no rule, so nothing simulates the funding. Those payments lock normally and then sit with step 1 in created until expires_at passes, at which point they expire (see Originator funds never arrive).
These values are the funded amount, which you cannot set directly yet
The rules match on desired.from.amount, the MXN the originator funds. A cross-ramp is requested from desired.to.amount instead, and the provider derives the funded amount from the rate, so today you cannot land on one of these values deliberately. The provider is adding matching on desired.to.amount, after which setting that field to one of the amounts above selects the same outcome. Ask Tesser to confirm the current rule list before you plan a sandbox test.
Phase 1: Request an FX Rate Quote
When the originator initiates a payment request, your organization creates a Payment at Tesser to request an end-to-end FX rate quote. The POST /v1/payments call supplies the currency pair and one side of the amount; account fields are deferred to the PATCH in Phase 2. There is no separate quote API endpoint. Quotes are obtained by creating a payment record.
In this example the originator specifies the USD amount the beneficiary should receive, so the POST body sets desired.to.amount and leaves desired.from.amount null.
The optional payment_info.payment_description_wire — free text (maximum 140 characters) that appears on the beneficiary's bank account statement — can be supplied here or on the PATCH in Phase 2. If both carry it, the PATCH value is used.
Code
Tesser responds synchronously with the new Payment record. At this point the desired overlay echoes what you submitted; estimated.*, actual.*, and steps[] are all empty or null because Tesser has not yet requested the cross-ramp quote from the provider.
Code
Tesser then requests a cross-ramp quote from the provider to compute the end-to-end cost and FX rate, and fires a single payment.quote_created webhook carrying the MXN amount the originator needs to fund (estimated.from.amount) and the two planned steps.
The initial POST carries no accounts, so every account_id on the quote is null. They populate once you attach accounts with the PATCH in Phase 2 — except step 2's from.account_id, which stays null for the life of the payment.
If the provider refuses the quote — for example an amount outside its quotable range (capa-3001) — no payment.quote_created fires; instead the payment carries a single failed step with the provider's reason in status_reasons, and a terminal payment.updated webhook is sent. Transient provider errors are retried internally and do not fail the payment.
Code
Fees and FX rates
The ratio of estimated.from.amount to estimated.to.amount is Tesser's all-in end-to-end FX rate. A few things to keep in mind:
- Surface this rate (with any markup you apply) to the originator in your UI. If the originator accepts, continue to Phase 2 to lock the rate; if they decline, don't execute the payment.
- The provider's costs are embedded in the quoted MXN amount (
estimated.from.amount). This includes the payout rail fee (if applicable). Tesser requests enough destination currency from the provider so that the beneficiary still receives the fulldesired.to.amountin thedesired.to.currency. - Cross-ramp payments can be created 24/7/365. Open-market FX rates apply only Monday to Friday, 8:00 AM – 3:00 PM Mexico City time (
America/Mexico_City); a quote requested outside those hours carries the provider's off-hours pricing, which is already reflected in the quoted rate (there is no separate line item). - Tesser's fee is embedded in the rate, not invoiced separately. For cross-ramp payments, Tesser's fee is the premium spread baked into the quoted
estimated.from.amount; the provider settles it to Tesser in USD, so there is no separate invoice. The provider's own quote fees (a flat fixed fee plus a spread amount) are recorded on the payout step'sfees[]when that step completes, so you can reconcile them.
The provider holds a quoted rate for only about 10 seconds — the window in which you must lock it by executing Phase 2 (PATCH). expires_at is set in two phases: it is null on the synchronous POST response, becomes the end of the quote window on payment.quote_created, and moves to the funding deadline once the rate is locked (see Phase 3). If the quote window lapses before you PATCH, request a fresh quote by creating a new payment.
Phase 2: Submit Payment Details and Lock the Rate
Once the originator accepts the rate, your organization populates the account identifiers via a PATCH call. This finalizes the payment's desired overlay and instructs Tesser to create the cross-ramp with the provider, locking the quoted rate.
desired.from.account_idandfunding_account_idboth reference the originator's MXN virtual account issued by your organization — the account from which the originator pushes the source fiat in step 1.desired.to.account_idis the beneficiary's US bank account, where the provider delivers the USD in step 2.- The
payment_infoobject carries optional detail about why the originator is sending this payment. Both of its fields are optional:payment_description_wire— free text (maximum 140 characters) to appear on the beneficiary's bank account statement (e.g. an invoice number). Tesser forwards it to the provider when the rate is locked, so it must be final by this PATCH. It can also be supplied on the initial POST; if both carry it, the PATCH value is used.payment_reason_code— a short code classifying the purpose of the payment. A standardized set of accepted values will be published in a future release; you can omit it today.
Code
The PATCH succeeds synchronously and Tesser publishes a payment.updated webhook reflecting the populated account IDs on the desired and estimated overlays and on the steps: the originator's account on step 1's from, the provider's MXN virtual account (created at lock) on step 1's to, and the beneficiary's bank on step 2's to.
Code
One locked cross-ramp per originator at a time
An originator can hold only one locked cross-ramp awaiting funds. Locking a second payment for the same originator while the first is unfunded is refused with payments-3088 and HTTP 409. The originator's deposit account is reused across their payments, so two unfunded orders could not be told apart by an incoming deposit. Retry once the payment in flight is funded or expires. To run cross-ramps in parallel while testing, onboard more than one originator.
Phase 3: Fund the Cross-Ramp (Step 1)
With the rate locked, fund the cross-ramp by pushing funds from the MXN virtual account your organization has issued to the MXN virtual account issued by the provider for the originator. The originator must fund before the payment's expires_at — the provider's funding cutoff — or the payment expires (see Failure Modes). Step 1 stays created until the provider observes the incoming funds; step 1 transitions created → completed when the provider notifies Tesser of MXN receipt.
Tesser sets the funding cutoff (expires_at) from the provider's same-day settlement window, as confirmed with the provider: when the rate is locked on a working day before 1:30 PM Mexico City time (America/Mexico_City), the cutoff is 1:30 PM that same day, which is what ensures same-day delivery of funds to the beneficiary.
Locks at or after 1:30 PM — or on a weekend or Mexican bank holiday — roll the cutoff to 1:30 PM on the next working day. (The 8:00 AM market open affects rate availability and off-hours pricing, not the funding cutoff.)
Look up the provider's deposit instructions
Retrieve the provider's MXN virtual-account details from Tesser. Step 1's estimated.to.account_id — first carried on the payment.updated webhook, once the rate is locked — holds the Tesser identifier for the provider's MXN virtual account. Call Get an account by ID with that account ID to retrieve the bank account information needed for the SPEI push. The account number is returned masked by default. Pass include_secure=true on the Get an account by ID request to retrieve the full account number required to complete the push.
Step 1's actual overlay reflects the amount sent to the provider.
Code
Step 1 moves straight from created to completed when the provider reports the funds, so submitted_at and confirmed_at remain null; completed_at is the only lifecycle timestamp stamped on this step.
Phase 4: Provider Converts and Pays Out (Step 2)
Once the MXN lands in the provider's MXN virtual account, the provider converts it to USD and pays the USD out to the beneficiary's US bank account. Step 2 is driven entirely by the provider; your organization does not sign for it or initiate it.
Code
submitted_at populates when Tesser marks the payout step submitted; confirmed_at when the provider confirms the destination-side transfer is underway; completed_at when the provider settles the USD to the beneficiary's bank. Step 2's from side is denominated in USD (out of the provider's USD virtual account) and its to side delivers USD to the beneficiary — the MXN→USD conversion was absorbed inside the provider between step 1 and step 2.
Code
The payout step's fees[] carries the provider's own quote fees — a flat fixed fee (fixed_fee) and a spread amount (spread_amount), in the provider's units — recorded once the step completes so you can reconcile them. Tesser's own fee (the premium spread) is not a line item here; it is embedded in the quoted rate and settled to Tesser by the provider.
Terminal State
When step 2 completes, Tesser populates the payment's top-level actual overlay and fires a final payment.updated webhook. On success, actual.to.amount matches desired.to.amount (the beneficiary receives exactly the USD the originator requested) and actual.from.amount matches the MXN the originator funded against the locked rate.
Failure Modes
When a payment reaches a terminal outcome — both steps completed, or a step failed along the way — Tesser populates the top-level actual overlay once and never overwrites it, and fires a payment.updated webhook carrying it (on success and on failure alike). Failed steps always have all-null actual.*.
Incorrect MXN amount funded
If you send more or less than the quoted estimated.from.amount:
- The payment is not automatically failed.
- Step 1's
actualoverlay reflects the amount the originator actually sent, and step 1 remains increatedwhile the mismatch is reconciled manually between your organization, Tesser, and the provider (for example, refunding an overpayment or collecting a shortfall). - Once the mismatch is resolved, the payout is delivered.
Contact Tesser support if a payment remains in created longer than expected.
Originator funds never arrive
If the originator never pushes the MXN, step 1 cannot complete. If expires_at is reached without funds arriving, the payment expires: both steps transition to failed carrying the transfers-9204 reason (The transfer expired before it could be executed). There is no dedicated payment.expired event; the expiry surfaces as the terminal payment.updated webhook with both steps failed — and since nothing moved, the payment's actual overlay stays all-null.
Payout failure or compliance rejection
If the cross-ramp provider cannot complete the payout — for example, a compliance rejection or a receiving-bank issue — step 2 transitions to failed, its status_reasons carries the provider's reason, and its fees[] records the provider's cancellation fee (fee_type: "cross_ramp_cancellation", 0.5% of the payout amount) charged on a cancelled locked rate. Treat a stalled or failed step 2 as a customer-service event and contact Tesser support.
Webhook Events by Phase
The following table summarizes the webhook events fired through the lifecycle of a successful cross-ramp payment.
| Phase | Webhook event | Carries |
|---|---|---|
| Quote | payment.quote_created | End-to-end FX rate via estimated.from.amount, plus the two planned steps[] (both status: "created"); no account IDs yet (the provider's deposit account is provisioned at lock), originator/beneficiary accounts null |
| PATCH | payment.updated | Account IDs populated on the desired overlay and attached to the steps: originator → step 1 from, provider MXN deposit account → step 1 to, beneficiary → step 2 to; the cross-ramp is created with the provider and the rate is locked |
| Step 1 | step.completed | The provider notified Tesser that the originator's MXN push landed in the provider's MXN virtual account |
| Step 2 | step.submitted → step.confirmed → step.completed | The provider converted the MXN and paid out USD: submitted_at when Tesser marked the payout step submitted, confirmed_at when the provider confirmed the payout underway, completed_at when the USD settled to the beneficiary's bank |
| Terminal | payment.updated | Top-level actual overlay populated; payment in terminal state |