Return URL handling

Build the page customers return to after checkout, and confirm the result on your server before you fulfil.

After checkout, Teyra Pay redirects the customer to your return_url with the payment ID and a status:

https://shop.example.com/thanks?payment_id=pay_01JBX4T9W2&status=succeeded
ParameterValues
payment_idThe pay_… ID of the payment.
statussucceeded, failed or processing. failed also covers payments that were canceled or expired.

Never trust the redirect alone

The query string is not proof of payment

Anyone can open https://shop.example.com/thanks?payment_id=pay_123&status=succeeded in a browser. Never fulfil an order, ship goods or unlock content because of the redirect parameters. Always confirm with the API or a verified webhook.

There are two reliable sources of truth:

  1. GET /v1/payments/:id with your secret key, from your server.
  2. A webhook with a valid Teyra-Signature.

Use both. The return page gives the customer an immediate answer; the webhook covers customers who close the browser, lose connection or never come back.

A correct return handler

On your return page:

  1. Read payment_id from the query string.
  2. Fetch the payment from your server with GET /v1/payments/:id.
  3. Check that the payment belongs to this order (compare reference or the id you stored) and that amount and currency match.
  4. Act on the fetched status, not the query parameter.
app.get('/orders/:id/return', async (req, res) => {
  const order = await db.orders.find(req.params.id);
  const paymentId = String(req.query.payment_id || '');

  // The payment must be the one we created for this order.
  if (!paymentId || paymentId !== order.paymentId) return res.status(400).render('payment-error');

  const response = await fetch(`https://api.teyra.co/v1/payments/${encodeURIComponent(paymentId)}`, {
    headers: { Authorization: `Bearer ${process.env.TEYRA_SECRET_KEY}` },
  });
  const payment = await response.json();

  if (payment.status === 'succeeded' && payment.amount === order.totalMinor && payment.currency === order.currency) {
    await markOrderPaid(order.id, payment.id); // must be idempotent: the webhook may get there first
    return res.render('thank-you', { order });
  }
  if (payment.status === 'processing' || payment.status === 'requires_payment') {
    return res.render('payment-pending', { order }); // the webhook will finish the job
  }
  return res.render('payment-failed', { order, reason: payment.failure_message });
});

On the failure page, offer a Try again button that sends the customer back to the payment's checkout_url: a failed or canceled payment can still be paid there until expires_at.

When the status is processing

Some methods confirm a few seconds after the customer returns. Show a "confirming your payment" page, and either refresh it every few seconds (by asking your server, which calls GET /v1/payments/:id) or tell the customer you'll email them. The payment.succeeded or payment.failed webhook arrives as soon as the result is final.

Make fulfilment idempotent

The return page and the webhook can arrive in any order, and webhooks can be delivered more than once. Write markOrderPaid so that calling it twice has no extra effect, for example by updating the order only WHERE status = 'pending'.