# Take payments without the hosted checkout

The hosted checkout, its [modal](/sdk/typescript/browser#open-the-checkout-in-a-modal) and the [React Native sheet](/sdk/typescript/react-native) are the fast way to take a payment. This page is the other way\: your own screen, with Mayarin as the API behind it. Use it when the payment has to look like part of your product, when there is no screen at all (a kiosk, a bot, a backend job), or when you already have a checkout and only need the clearing layer.

You give up nothing on safety. Your backend holds the secret key, the payer sends funds to an address Mayarin allocated for this one payment, and your backend learns the result from Mayarin, not from the payer's device.

```mermaid
sequenceDiagram
    autonumber
    participant App as Your app
    participant API as Your backend
    participant M as Mayarin
    participant C as Chain

    App->>API: choose a rail
    API->>M: paymentLinks.rails(link.id)
    M-->>API: rails you can be paid on
    App->>API: tap Pay
    API->>M: createIntent + confirmIntent
    M-->>API: price locked, deposit address
    API-->>App: deposit (JSON): address, amount, QR uri
    App->>C: payer sends funds to the address
    C-->>M: watcher matches the transfer
    M->>API: webhook payment.state_changed (signed)
    API->>M: payment.get (read it back)
    M-->>API: status COMPLETED
    API-->>App: paid
```

## 1. Choose a rail

A rail is one chain and one asset, such as USDC on Base. List the ones a merchant can be paid on from any [payment link](/sdk/typescript/commerce#payment-links), and let the payer choose\:

```ts
const { rails, settlementAsset } = await mayarin.commerce.paymentLinks.rails(
  link.id,
);

rails[0]; // { chain: "base-sepolia", asset: "USDC", contract: "0x…", settlementAsset: "USDC" }
```

`settlementAsset` is what the merchant receives on that chain. If the payer's asset differs, Mayarin prices the swap when the price is locked. The [chains and assets](/concepts/rails) page lists what a deployment runs.

## 2. Create and confirm the payment

Both calls run on your backend with the secret key. `deposit-match` is the path for a plain transfer\: the payer needs no special wallet, only a way to send tokens.

```ts
const intent = await mayarin.payment.createIntent({
  merchant: {
    id: MERCHANT_ID,
    name: "Toko Maju",
    city: "Jakarta",
    countryCode: "ID",
  },
  amount: { amount: "150000.00", asset: "IDR" },
  payment: { asset: rail.asset, chain: rail.chain },
  executionPath: "deposit-match",
  merchantReference: orderId, // your order id, echoed in every webhook
  ttlSeconds: 900,
});

const payment = await mayarin.payment.confirmIntent(intent.id);
```

Confirming locks the price and allocates the deposit address. Send your own client the JSON below, not the secret key and not `checkoutUrl`.

```ts
const deposit = payment.deposit;
if (deposit !== null) {
  deposit.address; // where to send, unique to this payment
  deposit.amount.display; // "Rp 150.000,00" — for people
  deposit.amount.formatted; // "9.123456" — the exact token amount, for a wallet
  deposit.uri; // an EIP-681 URI to render as a QR code, or null
  deposit.received.amount; // what has arrived so far, in minor units
  deposit.required; // confirmations needed
}
```

Show `deposit.address` and the token amount, and a QR code from `deposit.uri`. Put these on the screen as machine values (`formatted`), never the localized `display` form\: its comma decimal is not something a wallet accepts. See [money](/concepts/money).

> **Warning\:** the payer must send the exact asset on the exact chain, to this address, before the payment expires. Say so on your screen. A transfer on another network does not arrive.

An intent made with `mayarin.commerce.paymentLinks.checkout(link.id, { amount, payment })` is the same thing minted from a link, which is how an `open` link takes a typed amount. Confirm it the same way.

## 3. Learn the result

**Use a webhook.** Mayarin calls your endpoint at each step, signed. Verify it, read the payment back, and only then mark your order paid\:

```ts
import { constructWebhook } from "@mayarin/sdk";

const event = await constructWebhook({
  payload: await request.text(),
  signature: request.headers.get("webhook-signature")!,
  secret: process.env.MAYARIN_WEBHOOK_SECRET!,
});

const payment = await mayarin.payment.get(event.data.paymentIntentId);
if (payment.paymentIntent.status === "COMPLETED") {
  await markOrderPaid(payment.paymentIntent.merchantReference);
}
return new Response(null, { status: 202 });
```

A delivery carries ids and a state, never amounts you could book against. See [webhooks](/guides/webhooks) for the events, retries and secret rotation.

**Or wait for it.** When a job has nothing to listen on, `payment.waitFor` reads the payment with a growing pause until it settles\:

```ts
try {
  const settled = await mayarin.payment.waitFor(intent.id, {
    timeoutMs: 10 * 60_000,
  });
  settled.paymentIntent.status; // "COMPLETED" | "FAILED" | "EXPIRED"
} catch (error) {
  if (isMayarinApiError(error) && error.code === "WAIT_TIMEOUT") {
    // The payer has not paid yet. That is not a failure: wait again or give up.
  }
}
```

Pass `until` to stop on another condition, such as `PROCESSING` to say "we have your payment" before it settles, and `signal` to cancel. Polling suits a script; a server with many payments should use webhooks.

## 4. Show progress on the payer's screen

The payer's screen is yours, so tell it what your backend learned. The usual shape is a small endpoint your app polls, or a push (a WebSocket, server-sent events) that your webhook handler triggers\:

The intent becomes `PROCESSING` as soon as it is confirmed, so the status alone cannot tell "waiting for the payer" from "funds arrived". Key the screen off the status and the clearing state together\:

| Intent status | `clearing.state`                  | What the payer's screen shows                                                        |
| ------------- | --------------------------------- | ------------------------------------------------------------------------------------ |
| `PROCESSING`  | `PAYMENT_PENDING`                 | The address, the amount and the QR code, with a countdown to expiry.                 |
| `PROCESSING`  | `ASSET_RECEIVED` and later states | "We received your payment, confirming." `deposit.received` is above zero.            |
| `COMPLETED`   |                                   | The receipt. Fulfil the order from your backend, not from this screen.               |
| `FAILED`      |                                   | The reason, and a way to start a new payment. See below for a payment with funds in. |
| `EXPIRED`     |                                   | "This payment expired." Start a new one\: a new price needs the payer's consent.      |

Transfers to the same address add up, so a payer who sent too little can send the rest before the timer ends. Do not send from an exchange that deducts its fee from the amount.

If a payment `FAILED` after the funds arrived, the money is not lost\: fix the cause and [retry](/concepts/payment-lifecycle#retrying-a-failed-payment) it.

## Things your backend must keep

* **One order, one payment.** Pass an idempotency key derived from your order id, so a double tap returns the same payment instead of creating a second one.
* **The secret key stays on your server.** The payer's device never needs it. If you want to mint from the browser, that is [browser mode](/sdk/typescript/react#two-modes-and-who-decides-the-price) with a publishable key, where the payer chooses the price.
* **`merchantReference` is your join key.** It comes back on the payment and in every webhook, so you never need a lookup table from payment ids to orders.

For the on-chain contract path, where the payer's wallet submits the transaction itself, see [execution paths](/concepts/execution-paths) and `payment.getContractCall` in the [payments module](/sdk/typescript/payments#on-chain-contract).
