# 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_status` is `null` 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

Once the originator is onboarded (see [Prerequisites](#prerequisites)), 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**.

## 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](#phase-2-submit-payment-details-and-lock-the-rate) 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.

```csv title="originators.csv"
business_legal_name,email,country,tos_ip,tos_accepted_at
"Bodegas del Norte, S.A. de C.V.",ops@bodegas-del-norte.example.com,MX,203.0.113.10,2026-08-14T09:21:00Z
Textiles Monterrey S. de R.L. de C.V.,ops@textiles-monterrey.example.com,MX,203.0.113.77,2026-08-27T11:48:05Z
```

:::warning{title="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.

:::note
`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](#phase-3-fund-the-cross-ramp-step-1) 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](/how-tos/create-a-counterparty).
- The beneficiary's destination-currency bank account, owned by that beneficiary counterparty. For more information, see [Create an Account](/how-tos/create-an-account).

Beneficiaries need no provider onboarding, because nothing is funded from their accounts.

```json title="POST /v1/accounts/banks: the originator's MXN account"
{
  "counterparty_id": "6f1c4a8e-2d7b-4930-b5e2-9a3f0c7d1b6e",
  "name": "Bodegas del Norte MXN operating",
  "bank_name": "STP",
  "bank_code_type": "CLABE",
  "bank_identifier_code": "646180111812345678",
  "bank_account_number": "646180111812345678"
}
```

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](#phase-2-submit-payment-details-and-lock-the-rate).

### 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](#originator-funds-never-arrive)).

:::warning{title="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](#phase-2-submit-payment-details-and-lock-the-rate). If both carry it, the PATCH value is used.

```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",
  "payment_info": {
    "payment_description_wire": "Invoice 2026-0518"
  }
}
```

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,
    "payment_info": {
      "payment_description_wire": "Invoice 2026-0518"
    },
    "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": null,
    "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": null
  }
}
```

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.

```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": null,
      "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": null,
              "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": null,
              "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-18T15:00:15.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. 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's `fees[]` when that step completes, so you can reconcile them.

:::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`). `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](#phase-3-fund-the-cross-ramp-step-1)). 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_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 (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.

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

```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": null,
      "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": null,
              "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"
    }
  }
}
```

:::warning{title="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](#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](/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": null,
      "completed_at": "2026-05-18T16:05:00.000Z",
      "failed_at": null
    }
  }
}
```

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.

```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": null,
          "amount": "1000",
          "currency": "USD",
          "network": null
        },
        "to": {
          "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "actual": {
        "from": {
          "account_id": null,
          "amount": "1000",
          "currency": "USD",
          "network": null
        },
        "to": {
          "account_id": "a3f7c891-bd42-4e19-9c5a-2d8b6f3e1047",
          "amount": "1000",
          "currency": "USD",
          "network": null
        }
      },
      "transaction_hash": null,
      "fees": [
        { "fee_amount": "265.32", "fee_currency": "MXN", "fee_type": "fixed_fee" },
        { "fee_amount": "17.69", "fee_currency": "MXN", "fee_type": "spread_amount" }
      ],
      "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
    }
  }
}
```

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

<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>); no account IDs yet (the provider's deposit account is provisioned at lock), 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 the steps: originator → step 1 <code>from</code>, provider MXN deposit account → step 1 <code>to</code>, beneficiary → 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>
