Guide · Developers

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 · 3 min read

01The flow

  1. 01Your 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. 02You 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. 03The 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. 04Whether or not they come back, an invoice.status_changed webhook reaches your server. That marks the order paid.

02Set 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.

03A 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.

04The 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-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.

05Rules 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). 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.

Next

Create a test key and run a real invoice

Test keys work immediately, with no approval step. Payaries runs on real mainnet networks, so use a small real payment to test.