A hosted session is a payment page that Paayed hosts for you. Your server creates the session, you send the cardholder's browser to its url, and they enter their card and complete 3-D Secure on Paayed's page. Afterwards they come back to your site, and a webhook tells your server the outcome.
Use a hosted session when you sell from a website or app and don't want card numbers passing through your servers. Your systems only ever see identifiers, never card data. That usually means the shortest PCI DSS self-assessment (SAQ A), not the one you'd need to send card numbers to POST /v1/payments yourself.
| Hosted session | POST /v1/payments with a card | |
|---|---|---|
| Who collects the card | Paayed's page | Your own form and server |
| 3-D Secure | Handled on the page | You POST the challenge fields; call /confirm if your integration receives cres |
| PCI DSS scope | Typically SAQ A | Your server is in scope, and the payments.card.raw scope needs a PCI attestation |
| Subscriptions | Add recurring to the session | Store the card, then create a subscription |
Your server creates a session
POST /v1/hosted_sessions with the amount, currency and the URLs to send the cardholder back to. This uses your secret key, so it must happen on your server, never in the browser.
Redirect the cardholder
Send the cardholder's browser to the session's url. They pay on Paayed's page, including any 3-D Secure challenge.
The cardholder comes back
After paying they return to your success_url. If they give up they go to your cancel_url, and after a decline to your failure_url. Identifiers are added to the URL, never the outcome.
Your server confirms the outcome
Fulfill the order when the hosted_session.completed webhook arrives, or after you retrieve the session and see status: "complete". Never fulfill from the return URL alone: anyone can type it.
Only a secret key (pyd_secret_test_… for the sandbox, pyd_secret_live_… for live) with the payments.write and payments.read scopes. You don't need a publishable key: nothing runs in the browser except Paayed's own page. See Authentication.
curl -X POST 'https://gateway.uk.paayed.com/v1/hosted_sessions' \
-H 'Authorization: Bearer pyd_secret_test_your_key_here' \
-H 'Idempotency-Key: 8c2d4e1a-7b3f-4c9d-a1e2-3f4b5c6d7e8f' \
-H 'Content-Type: application/json' \
-d '{
"amount": 25.00,
"currency": "GBP",
"reference": "ORDER-10024",
"description": "Two tickets, Saturday matinee",
"success_url": "https://shop.example.com/checkout/complete",
"cancel_url": "https://shop.example.com/basket",
"failure_url": "https://shop.example.com/checkout/failed",
"metadata": { "order_id": "10024" }
}'| Field | Required | Notes |
|---|---|---|
amount | Yes | Decimal major units, as everywhere in the API (see Money). |
currency | Yes | Three-letter code, for example GBP. |
success_url | Yes | Where the cardholder goes after paying. Must be https://. |
cancel_url | Yes | Where the cardholder goes if they leave without paying. Must be https://. |
failure_url | No, but recommended | Where the cardholder goes after a declined payment. Without it, they aren't sent back to your site after a decline. Must be https://. |
reference | No | Your order reference, 6 to 20 characters. One is generated if you leave it out. |
description | No | Shown to the cardholder on the page. Up to 255 characters. |
customer | No | An existing customer (cust_…) to attach the payment to. |
save_card | No | off (default) or optional. With optional the page offers to keep the card, and it's stored only if the cardholder ticks the box. |
recurring | No | Start a subscription. See Subscriptions. |
metadata | No | Up to 50 keys of your own. Returned on the session and in its events. |
processing_channel | No | Only if your account has more than one. |
The response is 201 with the session:
{
"id": "hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F",
"object": "hosted_session",
"status": "open",
"url": "https://uk.paayed.com/pay/5067fb2e73d440aa5ea5b4f4305dcaae",
"amount": 25.00,
"currency": "GBP",
"reference": "ORDER-10024",
"description": "Two tickets, Saturday matinee",
"customer": null,
"save_card": "off",
"recurring": null,
"payment": null,
"subscription": null,
"success_url": "https://shop.example.com/checkout/complete",
"cancel_url": "https://shop.example.com/basket",
"failure_url": "https://shop.example.com/checkout/failed",
"expires_at": "2026-10-21T10:15:00Z",
"created_at": "2026-10-20T10:15:00Z",
"metadata": { "order_id": "10024" },
"livemode": false
}Store the session id against your order. You'll use it to match the webhook and the return.
Redirect the cardholder's browser to url, for example with an HTTP 303 from your checkout handler:
// Express. The session was created server-side a moment ago.
res.redirect(303, session.url);The page shows the amount and your description. It collects the card and runs 3-D Secure when the issuer asks for it. A session can be paid once. It stays open while a payment on it is in progress.
A full-page redirect is the simplest option and works everywhere, including mobile webviews. If you'd rather keep the cardholder on your page, you can open the page in a popup or an iframe.
When the cardholder finishes, their browser goes to one of your URLs with these query parameters added:
| Parameter | Value |
|---|---|
session_id | The session's id, for example hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F |
reference | The session's reference |
https://shop.example.com/checkout/complete?reference=ORDER-10024&session_id=hs_01JC4M8ZQ5T2X7RKD3VW9N6B1FThe URL says which session, never whether it was paid. On your return page, retrieve the session from your server and show the result:
curl 'https://gateway.uk.paayed.com/v1/hosted_sessions/hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F' \
-H 'Authorization: Bearer pyd_secret_test_your_key_here'Check that the session_id belongs to the order in the cardholder's own session on your site before you show anything.
The cardholder may never come back to your site: they close the tab, or their connection drops after paying. Don't depend on the return to fulfill orders. The hosted_session.completed webhook is what counts.
Subscribe your endpoint to the hosted session events (see Webhooks for setup and signature checks):
| Event | When |
|---|---|
hosted_session.completed | The payment on the session succeeded. data.object is the session, with payment set to the pay_… id. |
hosted_session.expired | The session lapsed unpaid after 24 hours, or you expired it. |
The payment itself also sends its usual events (payment.succeeded, payment.declined, and later payment.settled) like any other payment.
hosted_session.completed carries the session, not the fee. To see what you'll receive, retrieve the payment named in payment (GET /v1/payments/{id}). Until the payment settles, its fee is 0.00 and net is the full amount, with fee.settled: false: the fee isn't known yet, so treat these as placeholders, not as what you'll receive. When the acquirer settles the payment, Paayed sends payment.settled with the real fee and net and fee.settled: true. If the fee is later restated, payment.settled is sent again with the corrected figures. Reconcile your accounts from payment.settled, not from the estimate.
{
"id": "evnt_01JC4MB6R2…",
"object": "event",
"type": "hosted_session.completed",
"livemode": false,
"data": {
"object": {
"id": "hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F",
"object": "hosted_session",
"status": "complete",
"payment": "pay_01JC4MA9X3…",
"reference": "ORDER-10024",
"metadata": { "order_id": "10024" }
}
}
}When it arrives, look up your order by data.object.id (or your own metadata), check the amount and currency, and fulfill it. Fulfill only once per session, because webhooks can be delivered more than once.
Instead of redirecting, you can open the session's url in a popup window or an iframe on your checkout page. The page and the payment work the same, including 3-D Secure, which appears inside the page. The difference is what happens at the end: an embedded page doesn't navigate to your return URLs. It sends your page a message instead, and your page decides what to do next.
An iframe must be on your own site. The page only shows its card form in an iframe when the page framing it is on the same origin as one of your return URLs (success_url, cancel_url or failure_url). Anywhere else, the cardholder sees "This payment page can't be shown here" and a link to open it in a new tab. This stops another site from framing your payment page to trick a cardholder. If you nest frames, every frame above the page must be on that origin too. A popup isn't affected.
In Firefox the page learns who framed it from the referrer, so don't set Referrer-Policy: no-referrer on your checkout page. The browser default works.
<!-- In an iframe -->
<iframe id="paayed-checkout" src="SESSION_URL" title="Pay securely"
style="width: 100%; max-width: 480px; height: 640px; border: 0;"></iframe>// Or in a popup. Open it straight from the click handler, before any await,
// or the browser's popup blocker will stop it.
payButton.addEventListener('click', async () => {
const popup = window.open('', 'paayed-checkout', 'width=480,height=720');
const session = await createSessionOnYourServer(); // calls POST /v1/hosted_sessions
popup.location.href = session.url;
});The page sends messages with window.postMessage to its opener (a popup) or its parent (an iframe):
type | When | Other fields |
|---|---|---|
resizePopup | Iframe only. The page's content height changed. Resize the iframe to fit. | height in pixels |
paymentSuccess | The cardholder clicked Return to merchant after paying. A popup closes itself straight after. | sessionId |
paymentError | The payment was declined or failed. | sessionId, error (a message you can show the cardholder) |
closePopup | The cardholder closed or cancelled the page. | sessionId |
sessionId is the session's id (hs_…), the same one you created.
const checkoutOrigin = new URL(session.url).origin;
window.addEventListener('message', async (event) => {
// Only trust messages from Paayed's page.
if (event.origin !== checkoutOrigin) return;
const { type } = event.data ?? {};
if (type === 'resizePopup') {
document.getElementById('paayed-checkout').style.height = `${event.data.height}px`;
} else if (['paymentSuccess', 'paymentError', 'closePopup'].includes(type)
&& event.data.sessionId === session.id) {
// A message is a hint, not proof. Ask your server for the session's real status.
const status = await fetchSessionStatusFromYourServer(session.id); // GET /v1/hosted_sessions/{id}
showResult(status); // complete, failed, open or expired
}
});Two things to get right:
paymentSuccess to your success_url, paymentError to your failure_url and closePopup to your cancel_url, each falling back to whichever of the three is set. If your checkout page is on https://shop.example.com, those URLs must be on https://shop.example.com too, or your page never receives the messages.hosted_session.completed webhook as usual. The cardholder can also close a popup without clicking anything, in which case no paymentSuccess arrives even though they've paid.To close an open popup from your page, for example when the cardholder cancels on your side, send it { type: 'closePopup' }:
popup.postMessage({ type: 'closePopup' }, checkoutOrigin);To close an iframe, remove it from your page. In both cases, expire the session if you don't want it paid later.
| Status | Meaning |
|---|---|
open | Waiting for the cardholder, or a payment is in progress. |
complete | Paid. payment holds the payment id. |
failed | The payment was declined. The session can't be paid again, so create a new one if the cardholder wants to retry. |
expired | Not paid within 24 hours, or you expired it. |
Sessions last 24 hours. To close one early, for example when the cardholder empties their basket, expire it:
curl -X POST 'https://gateway.uk.paayed.com/v1/hosted_sessions/hs_01JC4M8ZQ5T2X7RKD3VW9N6B1F/expire' \
-H 'Authorization: Bearer pyd_secret_test_your_key_here' \
-H 'Idempotency-Key: 0b9e3c52-…'You can only expire a session nobody has started paying. If a payment is in progress, or the session has already finished, you'll get a 409. Expiring an already-expired session does nothing and returns it.
To list sessions, newest first, use GET /v1/hosted_sessions, optionally filtered with status. It's paginated like every list (see Pagination).
Add recurring to start a subscription without ever handling the card. The session's amount is charged now and again on each interval:
{
"amount": 9.99,
"currency": "GBP",
"success_url": "https://app.example.com/billing/done",
"cancel_url": "https://app.example.com/billing",
"recurring": { "interval": "month", "total_payments": 12 }
}| Field | Notes |
|---|---|
interval | day, week or month. There's no yearly interval for hosted sessions. |
total_payments | Stop after this many payments, including the first (2 to 10,000). |
end_date | Stop after this date (YYYY-MM-DD). |
Set total_payments or end_date, not both. Leave both out to charge until the subscription is cancelled.
The cardholder pays the first installment on the page and agrees to the schedule. When that payment succeeds, the session's subscription is set to the new subs_… id. From then on it's an ordinary subscription: you get subscription.charged or subscription.charge_failed for each later payment, and you can pause, resume or cancel it with the Subscriptions API.
Use a test key (pyd_secret_test_…) and the sandbox test cards on the hosted page. They behave the same as with POST /v1/payments: 4242 4242 4242 4242 succeeds, 4000 0000 0000 3220 runs a 3-D Secure challenge, and 4000 0000 0000 0002 is declined, so you can check your failure_url handling.
Your return URLs must be https://, even in the sandbox. To test locally, use a tunnel such as ngrok to give your machine a public HTTPS address. The same address works for your webhook endpoint.
hosted_session.completed (or a server-side retrieve showing complete), never from the return URL.failed and expired as well as complete.pyd_secret_live_…). Nothing else changes.