Journey 3: Expire a payment request
You prove that when a checkout is not paid in time, the payer sees that it has expired, your webhook receives payment_request.expired, and your app closes the order or offers a new payment request.
Only HOSTED_PAGE and EMBEDDED requests have an expiresAt. A PAYMENT_LINK request never expires and never sends payment_request.expired.
You need
- Before you start done.
- A new payment request with
"executionMode": "HOSTED_PAGE"or"EMBEDDED". Check that the create response hasexpiresAt. SetcancelRedirectUrlso the payer can return to you.
How expiry works
In production a hosted or embedded request lives for 60 minutes. The payer can start a payment for the first 45 minutes. The last 15 minutes are the closing window: no new payment can start, but a payment already sent can finish. In sandbox, simulate-expiry moves these clocks so you do not wait.
Step 1: Open checkout in your app
- Hosted page
- Embedded
Open the paymentUrl in a browser and stay on the payment method screen. You do not need to start a payment.
Show the checkout inside your website or app using the paymentUrl, as described in Embedded checkout. Your developer must set it up to receive checkout events. You are going to watch for the hubpay:expired event.
Step 2: Expire the request
Send POST /v1/collections/simulate-expiry:
{
"paymentRequestReference": "ORDER-10045",
"target": "PAYMENT_REQUEST"
}
Expect paymentRequestStatus: "EXPIRED" and paymentRequestExpiresAt in the past. Your webhook receives payment_request.expired with amountPaid: 0.
Step 3: Prove it in your app
- Hosted page
- Embedded
Refresh the hosted page. It shows the payer that the request is no longer open. If you set cancelRedirectUrl, the page returns the payer there after a short countdown, with a button to go at once. Arriving at cancelRedirectUrl is not proof of expiry. Confirm from the webhook or the API before acting.
Your page receives one hubpay:expired event with paid and remaining. No hubpay:step follows it. The frame shows the payer an expired message with no way forward. Close the checkout and, because the request is final, create a new payment request and show its checkout straight away if you want to collect again.
hubpay:expired is a browser signal for the payer journey. Your backend should act on payment_request.expired or the API status.
| Check | Expected |
|---|---|
GET /v1/collections/payment-requests/{id} | status: "EXPIRED" |
| Your webhook URL | payment_request.expired with amountPaid and currency |
| Your app | Closes the order, or creates a new payment request and shows its checkout |
Variations
The closing window
Test the point where checkout stops new payments but a started payment can still finish. Send simulate-expiry with "target": "PAYMENT_DEADLINE" instead. Expect newPaymentsDeadline in the past and paymentRequestExpiresAt still in the future. While the request stays open, no expiry webhook is sent. If the payer never started a payment, this same call can already make the request EXPIRED, and then payment_request.expired is sent at once. Always read paymentRequestStatus in the response.
- Hosted page: a payer who has not started a payment sees that the request no longer accepts new payments, with a Return to merchant button.
- Embedded: your page receives
hubpay:expiredfor the closed state. The request is still open for up to 15 minutes, so do not create a new payment request until the webhook or the API saysEXPIRED. Otherwise you may collect twice.
If the request is still open, send "target": "PAYMENT_REQUEST" to finish.
Expired after a part payment
Run Journey 2 step 1 with a crypto tab, then send "target": "PAYMENT_REQUEST". Expect EXPIRED and payment_request.expired with amountPaid of the part received. The money is not lost: expire the quote with "target": "CRYPTO_QUOTE", then send simulate-payment-completion with the saved paymentId. Expect payment.completed while the request stays EXPIRED.
A deposit is still confirming at expiry
In production a crypto transfer sent in time but not yet confirmed holds the request open past expiresAt. To test it, use the crypto no-wallet path: send simulate-crypto-deposit with "status": "PENDING", then simulate-expiry with "target": "PAYMENT_REQUEST". The response shows paymentRequestExpiresAt in the past but the status still UNPAID, and no expired webhook is sent. Then send the deposit again with the same depositId and "status": "CONFIRMED". The request becomes PAID and your webhook receives payment_request.paid.
If it does not work
| Problem | Cause | Fix |
|---|---|---|
400 on simulate-expiry | The request is a PAYMENT_LINK or has no expiresAt | Create a new request with HOSTED_PAGE or EMBEDDED |
400 on simulate-expiry | The request is already PAID or CANCELLED | A paid or cancelled request cannot expire |
Status is not EXPIRED | A deposit is still PENDING | See the variation above, or confirm the deposit |
No hubpay:expired in the embedded page | The checkout was not set up with a valid origin, or your page is not served over HTTPS | See Embedded checkout |
Good to know
- An expired request never reopens. Sending the call again changes nothing.
- Neither expiry target changes the crypto quote's own timer.
CRYPTO_QUOTEis a separate target used in Journey 2. - The production timing rules are in Payment request events.