# 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 on-chain risk screening**. Funds are not sent on chain, so there is no wallet to screen. `risk_status` remains `unchecked` and no `payment.risk_updated` webhook fires. 
- **No balance reservation.** Likewise, because no Tesser-held balance is reserved, `balance_status` stays `null` and no `payment.balance_updated` webhook fires.

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

A cross-ramp payment progresses through these phases:

1. **Create the payment and request an FX rate quote** — `POST /v1/payments` with the currency pair. Receive a payment ID synchronously. Tesser requests a cross-ramp quote from the provider and fires a single `payment.quote_created` webhook carrying the end-to-end FX rate and the two planned `steps[]`.
2. **Submit accounts and lock the rate** — `PATCH /v1/payments/{paymentId}` with the originator's source-currency account (`desired.from.account_id` and `funding_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 a `payment.updated` webhook. This PATCH must happen before the quote expires.
3. **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.
4. **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).
5. **Reach terminal state** — the payment's `actual` overlay populates and a final `payment.updated` webhook fires alongside the last `step.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.

<table className="wrap-first-col">
  <thead>
    <tr>
      <th>Step</th>
      <th>Description</th>
      <th>Step type</th>
      <th>Initiated by</th>
      <th>You sign?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Fiat transfer of the source currency from the originator's virtual account to the cross-ramp provider's source-currency virtual account</td>
      <td><code>transfer</code></td>
      <td>Originator (pushes source fiat via the local push-payment rail)</td>
      <td>No</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Fiat payout of the destination currency from the cross-ramp provider's destination-currency virtual account to the beneficiary's bank account</td>
      <td><code>transfer</code></td>
      <td>Cross-ramp provider</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

:::note
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](/overviews/funds-movement-lifecycle-and-data-model#steps).
:::

## Example Scenario

The remainder of this guide walks through a concrete example: a cross-ramp payment **from Mexico to the United States**.

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

```json title="POST /v1/payments — request body"
{
  "desired": {
    "from": {
      "currency": "MXN",
      "network": null
    },
    "to": {
      "amount": "1000",
      "currency": "USD",
      "network": null
    }
  },
  "organization_reference_id": "pmt_2026_05_18_001"
}
```

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.

```json title="POST /v1/payments — synchronous response"
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440020",
    "workspace_id": "550e8400-e29b-41d4-a716-446655440001",
    "organization_reference_id": "pmt_2026_05_18_001",
    "direction": "outbound",
    "funding_account_id": null,
    "desired": {
      "from": {
        "account_id": null,
        "amount": null,
        "currency": "MXN",
        "network": null
      },
      "to": {
        "account_id": null,
        "amount": "1000",
        "currency": "USD",
        "network": null
      }
    },
    "estimated": {
      "from": {
        "account_id": null,
        "amount": null,
        "currency": null,
        "network": null
      },
      "to": {
        "account_id": null,
        "amount": null,
        "currency": null,
        "network": null
      }
    },
    "actual": {
      "from": {
        "account_id": null,
        "amount": null,
        "currency": null,
        "network": null
      },
      "to": {
        "account_id": null,
        "amount": null,
        "currency": null,
        "network": null
      }
    },
    "risk_status": "unchecked",
    "risk_status_reasons": [],
    "participants": [],
    "provider_metadata": [],
    "risk_reviewed_by": null,
    "risk_reviewed_at": null,
    "balance_status": null,
    "balance_reserved_at": null,
    "steps": [],
    "created_at": "2026-05-18T15:00:00.000Z",
    "updated_at": "2026-05-18T15:00:00.000Z",
    "expires_at": "2026-05-18T19:30:00.000Z"
  }
}
```

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 accounts that the cross-ramp provider has established for the originator are known at planning time, so step 1's `to.account_id` (the provider's MXN virtual account) and step 2's `from.account_id` (the provider's USD virtual account) are populated on the quote. The originator's and beneficiary's account IDs remain `null` until you attach them in Phase 2.

```json title="Webhook — payment.quote_created (abridged)"
{
  "id": "7c9e6a1b-2d3f-4a5b-8c9d-0e1f2a3b4c5d",
  "type": "payment.quote_created",
  "data": {
    "object": {
      "id": "550e8400-e29b-41d4-a716-446655440020",
      "organization_reference_id": "pmt_2026_05_18_001",
      "direction": "outbound",
      "funding_account_id": null,
      "desired": {
        "from": {
          "account_id": null,
          "amount": null,
          "currency": "MXN",
          "network": null
        },
        "to": {
          "account_id": null,
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "estimated": {
        "from": {
          "account_id": null,
          "amount": "17953.64",
          "currency": "MXN",
          "network": null
        },
        "to": {
          "account_id": null,
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "risk_status": "unchecked",
      "balance_status": null,
      "steps": [
        {
          "id": "550e8400-e29b-41d4-a716-446655440101",
          "payment_id": "550e8400-e29b-41d4-a716-446655440020",
          "step_sequence": 1,
          "step_type": "transfer",
          "estimated": {
            "from": {
              "account_id": null,
              "amount": "17953.64",
              "currency": "MXN",
              "network": null
            },
            "to": {
              "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
              "amount": "17953.64",
              "currency": "MXN",
              "network": null
            }
          },
          "actual": {
            "from": {
              "account_id": null,
              "amount": null,
              "currency": null,
              "network": null
            },
            "to": {
              "account_id": null,
              "amount": null,
              "currency": null,
              "network": null
            }
          },
          "transaction_hash": null,
          "fees": [],
          "provider_key": "capa",
          "status": "created",
          "status_reasons": [],
          "created_at": "2026-05-18T15:00:05.000Z",
          "updated_at": "2026-05-18T15:00:05.000Z",
          "completed_at": null,
          "failed_at": null
          // ...timestamp fields omitted for brevity
        },
        {
          "id": "550e8400-e29b-41d4-a716-446655440102",
          "payment_id": "550e8400-e29b-41d4-a716-446655440020",
          "step_sequence": 2,
          "step_type": "transfer",
          "estimated": {
            "from": {
              "account_id": "d2b3c4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
              "amount": "1000",
              "currency": "USD",
              "network": null
            },
            "to": {
              "account_id": null,
              "amount": "1000",
              "currency": "USD",
              "network": null
            }
          },
          "actual": {
            "from": {
              "account_id": null,
              "amount": null,
              "currency": null,
              "network": null
            },
            "to": {
              "account_id": null,
              "amount": null,
              "currency": null,
              "network": null
            }
          },
          "transaction_hash": null,
          "fees": [],
          "provider_key": "capa",
          "status": "created",
          "status_reasons": [],
          "created_at": "2026-05-18T15:00:05.000Z",
          "updated_at": "2026-05-18T15:00:05.000Z",
          "completed_at": null,
          "failed_at": null
          // ...timestamp fields omitted for brevity
        }
      ],
      "created_at": "2026-05-18T15:00:00.000Z",
      "updated_at": "2026-05-18T15:00:05.000Z",
      "expires_at": "2026-05-18T19:30:00.000Z"
    }
  }
}
```

### 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 full `desired.to.amount` in the `desired.to.currency`.
- Cross-ramp payments can be created 24/7/365. However, open market FX rates from the provider are available only Monday to Friday, 8:00 AM – 3:00 PM (CST). Transactions created outside open market hours will have an additional cost.
- **Tesser's fee is separate.** For cross-ramp payments, Tesser's fee is invoiced separately and does not appear as a step `fees[]` line item in this flow.

:::note
The provider holds a quoted rate for only about **10 seconds** — the window in which you must **lock** it by executing Phase 2 (`PATCH`). This is not the funding deadline: if the 10-second window lapses before you `PATCH`, request a fresh quote by creating a new payment. The payment's `expires_at` is a separate, later deadline — the cutoff by which the originator must **fund** the cross-ramp once the rate is locked (see [Phase 3](#phase-3-fund-the-cross-ramp-step-1)).
:::

## 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_id` and `funding_account_id` both 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_id` is the beneficiary's US bank account, where the provider delivers the USD in step 2.
- The `payment_info` object carries optional detail about **why the originator is sending this payment**. Both of its fields are optional:
  - `payment_description_wire` — free text to appear on the beneficiary's bank account statement (e.g. an invoice number).
  - `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.

```json title="PATCH /v1/payments/{paymentId} — request body"
{
  "funding_account_id": "550e8400-e29b-41d4-a716-446655440010",
  "desired": {
    "from": {
      "account_id": "550e8400-e29b-41d4-a716-446655440010"
    },
    "to": {
      "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047"
    }
  },
  "payment_info": {
    "payment_description_wire": "Invoice 2026-0518"
  }
}
```

The PATCH succeeds synchronously and Tesser publishes a `payment.updated` webhook reflecting the populated account IDs on the `desired` overlay and on the affected steps. The provider's own virtual-account IDs were already populated at quote time.

```json title="Webhook — payment.updated (accounts attached, abridged)"
{
  "id": "8d0f7b2c-3e4a-4b6c-9d0e-1f2a3b4c5d6e",
  "type": "payment.updated",
  "data": {
    "object": {
      "id": "550e8400-e29b-41d4-a716-446655440020",
      "direction": "outbound",
      "funding_account_id": "550e8400-e29b-41d4-a716-446655440010",
      "payment_info": {
        "payment_description_wire": "Invoice 2026-0518"
      },
      "desired": {
        "from": {
          "account_id": "550e8400-e29b-41d4-a716-446655440010",
          "amount": null,
          "currency": "MXN",
          "network": null
        },
        "to": {
          "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "estimated": {
        "from": {
          "account_id": "550e8400-e29b-41d4-a716-446655440010",
          "amount": "17953.64",
          "currency": "MXN",
          "network": null
        },
        "to": {
          "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "risk_status": "unchecked",
      "balance_status": null,
      "steps": [
        {
          "id": "550e8400-e29b-41d4-a716-446655440101",
          "step_sequence": 1,
          "step_type": "transfer",
          "estimated": {
            "from": {
              "account_id": "550e8400-e29b-41d4-a716-446655440010",
              "amount": "17953.64",
              "currency": "MXN",
              "network": null
            },
            "to": {
              "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
              "amount": "17953.64",
              "currency": "MXN",
              "network": null
            }
          },
          "provider_key": "capa",
          "status": "created"
          // ...remaining fields as in payment.quote_created
        },
        {
          "id": "550e8400-e29b-41d4-a716-446655440102",
          "step_sequence": 2,
          "step_type": "transfer",
          "estimated": {
            "from": {
              "account_id": "d2b3c4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
              "amount": "1000",
              "currency": "USD",
              "network": null
            },
            "to": {
              "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
              "amount": "1000",
              "currency": "USD",
              "network": null
            }
          },
          "provider_key": "capa",
          "status": "created"
          // ...remaining fields as in payment.quote_created
        }
      ],
      "expires_at": "2026-05-18T19:30:00.000Z"
    }
  }
}
```

## 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](#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.

For payments created during business hours (after 8am CST Monday through Friday), Tesser sets the funding cutoff (`expires_at`) as 1:30pm CST to ensure same-day delivery of funds to the beneficiary.

Payments created after 1:30pm CST are treated as though they were created the next working day. 

### Look up the provider's deposit instructions

Retrieve the provider's MXN virtual-account details from Tesser. Step 1's `estimated.to.account_id` — carried on both the `payment.quote_created` and `payment.updated` webhooks — holds the Tesser identifier for the provider's MXN virtual account. Call [Get an account by ID](/api/accounts#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](/api/accounts#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.

```json title="Webhook — step.completed (step 1, abridged)"
{
  "id": "9e1a8c3d-4f5b-4c7d-a0e1-2f3b4c5d6e7f",
  "type": "step.completed",
  "data": {
    "object": {
      "id": "550e8400-e29b-41d4-a716-446655440101",
      "payment_id": "550e8400-e29b-41d4-a716-446655440020",
      "step_sequence": 1,
      "step_type": "transfer",
      "estimated": {
        "from": {
          "account_id": "550e8400-e29b-41d4-a716-446655440010",
          "amount": "17953.64",
          "currency": "MXN",
          "network": null
        },
        "to": {
          "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
          "amount": "17953.64",
          "currency": "MXN",
          "network": null
        }
      },
      "actual": {
        "from": {
          "account_id": "550e8400-e29b-41d4-a716-446655440010",
          "amount": "17953.64",
          "currency": "MXN",
          "network": null
        },
        "to": {
          "account_id": "c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
          "amount": "17953.64",
          "currency": "MXN",
          "network": null
        }
      },
      "transaction_hash": null,
      "fees": [],
      "provider_key": "capa",
      "status": "completed",
      "status_reasons": [],
      "submitted_at": null,
      "confirmed_at": "2026-05-18T16:04:50.000Z",
      "completed_at": "2026-05-18T16:05:00.000Z",
      "failed_at": null
    }
  }
}
```

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

```text title="Lifecycle"
created → submitted → confirmed → completed
                                ↘ failed
```

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

```json title="Webhook — step.completed (step 2, abridged)"
{
  "id": "b0f2c9d4-5a6c-4d8e-b1f2-3a4c5d6e7f80",
  "type": "step.completed",
  "data": {
    "object": {
      "id": "550e8400-e29b-41d4-a716-446655440102",
      "payment_id": "550e8400-e29b-41d4-a716-446655440020",
      "step_sequence": 2,
      "step_type": "transfer",
      "estimated": {
        "from": {
          "account_id": "d2b3c4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
          "amount": "1000",
          "currency": "USD",
          "network": null
        },
        "to": {
          "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "actual": {
        "from": {
          "account_id": "d2b3c4e5-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
          "amount": "1000",
          "currency": "USD",
          "network": null
        },
        "to": {
          "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "transaction_hash": null,
      "fees": [],
      "provider_key": "capa",
      "status": "completed",
      "status_reasons": [],
      "submitted_at": "2026-05-18T16:05:10.000Z",
      "confirmed_at": "2026-05-18T16:06:00.000Z",
      "completed_at": "2026-05-18T16:12:00.000Z",
      "failed_at": null
    }
  }
}
```

## 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 `actual` overlay reflects the amount the originator actually sent, and step 1 remains in `created` while 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.

:::note
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 expired state is reflected in the payment's terminal `actual` overlay.

### 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`. 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.

<table>
  <thead>
    <tr>
      <th>Phase</th>
      <th>Webhook event</th>
      <th>Carries</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Quote</td>
      <td><code>payment.quote_created</code></td>
      <td>End-to-end FX rate via <code>estimated.from.amount</code>, plus the two planned <code>steps[]</code> (both <code>status: "created"</code>); the provider's virtual-account IDs populated, originator/beneficiary accounts <code>null</code></td>
    </tr>
    <tr>
      <td>PATCH</td>
      <td><code>payment.updated</code></td>
      <td>Account IDs populated on the <code>desired</code> overlay and attached to step 1 (<code>from</code>) and step 2 (<code>to</code>); the cross-ramp is created with the provider and the rate is locked</td>
    </tr>
    <tr>
      <td>Step 1</td>
      <td><code>step.completed</code></td>
      <td>The provider notified Tesser that the originator's MXN push landed in the provider's MXN virtual account</td>
    </tr>
    <tr>
      <td>Step 2</td>
      <td><code>step.submitted</code> → <code>step.confirmed</code> → <code>step.completed</code></td>
      <td>The provider converted the MXN and paid out USD: <code>submitted_at</code> when Tesser marked the payout step submitted, <code>confirmed_at</code> when the provider confirmed the payout underway, <code>completed_at</code> when the USD settled to the beneficiary's bank</td>
    </tr>
    <tr>
      <td>Terminal</td>
      <td><code>payment.updated</code></td>
      <td>Top-level <code>actual</code> overlay populated; payment in terminal state</td>
    </tr>
  </tbody>
</table>
