# Hosted crypto checkout: send the customer, get them back

> You don't have to build a payment screen. Create an invoice, send the customer to the page Payaries hosts, and they come back to your store when they've paid, the same shape as a card processor's hosted checkout. The one rule that matters: the return is a convenience, and the webhook is what fulfils the order.

Updated 2026-10-10 · Payaries · https://www.payaries.com/guides/hosted-crypto-checkout

## The flow

1. Your server creates the order, then `POST /v1/invoices` with the order id as `referenceId` and a `successUrl` and `cancelUrl`. The response carries a `checkoutId`.
2. You redirect the customer to `<checkout host>/pay/<checkoutId>`. The API returns the `checkoutId`, not a full URL: the checkout host is the origin your Payaries deployment serves the payment page from.
3. The page shows the exact amount, the address and the live status. When the payment is confirmed it sends the customer to your `successUrl` (or shows a "Back to store" button) with a signed result appended.
4. Whether or not they come back, an `invoice.status_changed` webhook reaches your server. That marks the order paid.

## Set up once: redirect domains

A redirect off a payment page is an obvious phishing vector, so a `successUrl` or `cancelUrl` is accepted only if its host is one you registered. Registering is a dashboard-session action (Settings → Invoices → Redirect domains, or `PATCH /v1/merchants/me/redirect-settings` with a Bearer session token): an API key gets `403`, so a leaked key can't add its own redirect target. It needs a session but no fresh authenticator code, so protect your dashboard session.

**Request**

```
{ "redirectDomains": ["shop.example.com", "*.myshop.org"] }
```

- Each entry is a bare hostname or `*.example.com` for any subdomain of it. The wildcard does not cover the bare `example.com`. No scheme, path, port or IP address; up to 20 entries.
- Prefer exact hostnames. A wildcard on a shared suffix such as `*.co.uk` would authorise every site under it.
- The URLs must be `https`, with no credentials or private host, up to 2048 characters. A host that isn't registered is refused with `400` and `code: "REDIRECT_URL_NOT_ALLOWED"` (plus `field`) and nothing is created. Fix the setting or the URL; don't retry.
- The allow-list is checked again every time the payment page loads, so removing a domain switches off the redirects on invoices already issued.
- Replaying a `referenceId` returns the original invoice and ignores the new URLs.

## A working Express example

**Node.js**

```
// 1. Start a payment: create YOUR order first, then the invoice for it.
app.post('/checkout', async (req, res) => {
  const order = await orders.create({ ...req.body, status: 'awaiting_payment' });
  const invoice = await client.invoices.create({
    referenceId: order.id,                                   // also your idempotency key
    fiat: { amount: order.total, currency: order.currency },
    crypto: { asset: 'USDT_TRC20', chainId: 'tron-mainnet' },
    successUrl: 'https://shop.example.com/orders/' + order.id + '/return',
    cancelUrl:  'https://shop.example.com/orders/' + order.id + '/cart',
  });
  res.redirect(303, CHECKOUT_BASE_URL + '/pay/' + invoice.checkoutId);
});

// 2. The customer comes back. Show a result, but do NOT fulfil here.
app.get('/orders/:id/return', async (req, res) => {
  const order = await orders.get(req.params.id);
  const r = client.webhooks.verifyReturn('https://shop.example.com' + req.originalUrl);
  if (r.ok && r.referenceId === order.id) return res.render('thanks', { order });
  return res.render('confirming', { order });   // unverifiable, replayed or still catching up
});

// 3. The webhook is what fulfils. Verify the RAW body; every branch is idempotent.
app.post('/webhooks/payaries', express.raw({ type: 'application/json' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  const ok = client.webhooks.verify(rawBody, req.header('X-Gateway-Signature'), Number(req.header('X-Gateway-Timestamp')));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(rawBody);
  if (event.event !== 'invoice.status_changed') return res.sendStatus(200);
  const order = await orders.get(event.referenceId);
  if (!order) return res.sendStatus(200);

  switch (event.status) {
    case 'confirmed': case 'forwarding': case 'settled':      // credited at 'confirmed'
      if (order.status === 'awaiting_payment' || order.status === 'underpaid') await orders.markPaid(order.id);
      else if (order.status === 'cancelled') await orders.flagForReview(order.id, 'paid after cancellation');
      break;
    case 'underpaid':                                          // they sent less than due
      if (order.status === 'awaiting_payment') await orders.setStatus(order.id, 'underpaid');
      break;
    case 'expired':
      if (order.status === 'awaiting_payment') await orders.cancel(order.id);   // release stock
      else if (order.status === 'underpaid') await orders.flagForReview(order.id, 'partial payment, then expired');
      break;
    case 'failed':
      await orders.flagForReview(order.id, 'invoice failed');
      break;
  }
  res.sendStatus(200);
});
```

Don't put secrets in either URL: anyone holding the checkout link can read them. Use a plain order page and confirm payment from the webhook.

## The signed result on the return

When the customer is sent to `successUrl` after paying, these query parameters are appended and yours are kept: `payaries_invoice_id`, `payaries_reference_id`, `payaries_status` (`confirmed`, `forwarding` or `settled`), `payaries_ts` (unix milliseconds) and `payaries_sig`, an [HMAC](https://www.payaries.com/guides/sign-crypto-payment-api-requests)-SHA256 keyed with your webhook secret. With the Node SDK, `client.webhooks.verifyReturn(fullUrl)` returns `{ ok: true, invoiceId, referenceId, status }` or `{ ok: false, reason }`, with a 15-minute window by default. It is produced only once the money is confirmed on-chain, never for a pending invoice. If you have no webhook secret configured the parameters are sent unsigned and can't be verified. If your success route rejects unknown query parameters, allow these.

## Rules that keep it safe

- **The return is a convenience, never the trigger for fulfilment.** The success URL is a plain browser GET: anyone can open it or replay it. The signed result shows that Payaries reported a status for that invoice at that moment; it says nothing about the amount. Mark the order paid from the signed webhook (or `GET /v1/invoices/:id`), and always check that `referenceId` is the order the URL belongs to.
- **The customer may never return**: they closed the tab or lost connection. Nothing is lost, the webhook still fires. They may also return *before* the webhook arrives, so show "confirming" rather than failing.
- **Handle every outcome.** `expired`: the order wasn't paid in time, release stock. `underpaid`: they sent less, wait for a top-up or contact them, and don't auto-cancel an order that has received money. A payment mined after the deadline is held for review and sends no webhook ([late payments](https://www.payaries.com/guides/handle-underpaid-and-late-crypto-payments)). `cancelUrl` is only a way back for a customer who left; it does not cancel the invoice.
- **The redirect is performed by the hosted page, not the API.** The API supplies `successUrl`, `cancelUrl` and a ready-signed `returnUrl` on the public invoice endpoint, and the page navigates to it: a 5-second countdown when the payment completes during the visit (a "Return to store" button goes immediately), no automatic redirect for a customer who reopens an already-paid link, and a fresh signed result read just before navigating.

## Questions

**Can I redirect the customer back to my store after they pay?**
Yes. Pass a successUrl and cancelUrl when you create the invoice, with their hosts registered as redirect domains in your dashboard. After a confirmed payment the hosted page sends the customer to successUrl with a signed result appended.

**Is reaching my success URL proof that the customer paid?**
No. It is a plain browser request that anyone can open or replay, and the signed result says nothing about the amount. Fulfil only from the signed webhook or by reading the invoice, and check the referenceId matches your order.

**Why does invoice creation return REDIRECT_URL_NOT_ALLOWED?**
The host of your successUrl or cancelUrl isn't in your redirect domains. Register the exact hostname with PATCH /v1/merchants/me/redirect-settings using a dashboard session, or fix the URL. It isn't retryable.

## Try it

Create a test key and run a real invoice: [Payaries — www.payaries.com](https://www.payaries.com/signup). Payaries is a multi-chain crypto payment gateway — https://www.payaries.com

## Keep reading

- https://www.payaries.com/guides/crypto-payment-api-quickstart — Crypto payment API quickstart: from signup to your first payout
- https://www.payaries.com/guides/verify-crypto-webhook-signatures — How to verify crypto payment webhook signatures
- https://www.payaries.com/guides/handle-underpaid-and-late-crypto-payments — Handling underpaid, late and unmatched crypto payments
- https://www.payaries.com/guides/crypto-payment-api-errors-and-retries — Crypto payment API errors, rate limits and safe retries
- https://www.payaries.com/guides/accept-crypto-on-shopify — Accept crypto on Shopify without payments-partner approval
