Checkout session expired? The full lifecycle of a payment session, from redirect to webhook
Checkout session expired, customer closed the page, return URLs: the timeline of a session, its 72-hour default lifetime, cancellation and codes 404 to 410.
Checkout session expired? Then it can no longer be paid: by default, a session expires 72 hours after it is created, or at the time you give in expiresAt, and a new session has to be created. It is consumed only once, can be canceled before payment, and a customer who closes the page does not close the session: nothing is confirmed without a webhook, and the session stays valid until it expires. This guide follows a session from creation to webhook, then covers expiry, the closed page, return pages, cancellation, reading the status, codes 404, 409 and 410, and reconciliation after an outage.
The timeline of a session, from creation to webhook
ChariPay is a Moroccan payment platform, a PSP operated by Chari Money, a payment institution licensed by Bank Al-Maghrib. On a merchant website, an order becomes a session: you create the session server-side, redirect the buyer to the hosted checkout — card with 3-D Secure — and receive the result through a signed webhook. Step by step:
- 1Creation, server-side. Your back office calls
POST /v1/payment-sessionswith the amount, yourorderId, the customer and your URLs, then stores the returnedsessionIdnext to the order, in an "awaiting payment" state. - 2Redirect. You send the buyer to
checkoutUrl, exactly as the response returns it, as a full page, never in an iframe. - 3Payment. The buyer enters their card on the hosted page and authenticates with their bank through 3-D Secure.
- 4Return. The browser comes back to your
acceptURL after a successful payment, todeclineafter a failure. - 5Webhook. Your server receives the signed event —
payment.succeededfor a successful payment — and that event is what counts: you confirm the order at that moment.
The creation call, with your return URLs:
curl -X POST 'https://api-psp.charipay.ma/v1/payment-sessions' \
-H 'X-CHARI-PAY-API-KEY: chari_sk_test_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-2026-0421-session' \
-d '{
"amount": 250.00,
"orderId": "ORD-2026-0421",
"externalId": "order-2026-0421",
"config": {
"customer": {
"email": "amine.bennani@example.com",
"firstName": "Amine",
"lastName": "Bennani",
"phone": "+212600000000"
},
"urls": {
"accept": "https://votre-boutique.ma/paiement/succes",
"decline": "https://votre-boutique.ma/paiement/echec",
"notification": "https://votre-boutique.ma/webhooks/charipay"
}
},
"metadata": { "cartId": "c_987", "source": "web" }
}'The response — 201, or 200 if the same externalId has already been used — contains these five fields and nothing else:
{
"sessionId": "ps_5Kd0Rn",
"sessionToken": "st_…",
"verifyKey": "vk_5Kd0Rn",
"expiresAt": "2026-10-05T10:15:00Z",
"checkoutUrl": "https://…/checkout/ps_5Kd0Rn"
}Status and amount are then read with GET /v1/payment-sessions/{sessionId}. The verifyKey only matters for direct checkout: with the hosted page, checkoutUrl is all you need. The session's four endpoints are described in the Checkout sessions module of the API docs. Finally, keep the rule that governs everything else: confirm the order when the webhook arrives, never on the redirect alone — the redirect says the buyer came back, not that the payment is secured. The receiver itself is built with getting your webhook integration right.
You can watch each of these steps for yourself in the sandbox: create a session, pay it, then let another one expire. Start in test mode: three fields — name, work email, company — then an activation link received by email to choose your password, then the portal, where you create your own test key after re-authenticating, before paying with the test card 4918 9141 0719 5005 and the 3-D Secure code 555. Free, with no time limit, and no waiting for your business verification.
Checkout session expired: 72 hours by default, expiresAt to shorten it
Each session has a limited lifetime — 72 hours by default, expiresAt to shorten it — and is consumed only once.
| Parameter | Default value | What it controls |
|---|---|---|
expiresAt | 72 hours after creation | the moment after which the session can no longer be paid; ISO-8601 UTC, in the future, or you get a 400 |
singleUse | true | one session serves one payment |
expiresAt is expressed in UTC; since Morocco is UTC+1, convert local time before sending it. Shortening the lifetime makes sense when the order holds something — an item in limited stock, a seat, a time slot: the session expires at the same time as the hold, and your system releases the resource. The business setup for that case, with its cancellation policy, is covered in taking payment for online bookings. This guide assumes you have already chosen the session; if you are still weighing it against the payment link, the comparison is in payment link or checkout.
An expired session can no longer be paid: in direct checkout, POST /checkout/verify and POST /checkout/submit return 410 SESSION_EXPIRED. None of the twenty documented events carries a session's expiry: it is your system that compares expiresAt with the current time, or reads the status again, to move the order to the state you planned — "expired", "to follow up", or released.
The customer closed the payment page: what happens
A customer who closes the tab — before entering their card, during authentication or right after — does not trigger anything final on your side:
- Nothing is confirmed without a webhook. Until the signed event arrives, the order stays "awaiting payment".
- The session stays valid until it expires, or until you cancel it.
- If the payment went through before the tab was closed,
payment.succeededstill arrives: the customer will never see youracceptpage, but the money is there. - To find out where the session stands, read its status with
GET /v1/payment-sessions/{sessionId}. - `payment.failed` only arrives if you asked for it when creating the session (
notifyOnFailure): do not build any flow on it.
What should you do with the order? Nothing urgent. Leave it pending until the session expires. If the customer comes back to pay — by phone, by message — cancel the old session first, then create a new one: two valid sessions for the same order mean two possible payments. That is the precaution not to forget.
The accept and decline return pages
| URL | Role | If you do not set it |
|---|---|---|
config.urls.accept | page the buyer returns to after a successful payment | your account's default value, then the platform's |
config.urls.decline | page the buyer returns to after a failure | your account's default value, then the platform's |
config.urls.notification | URL that receives this session's signed webhooks | your account's default value only, with no platform fallback |
All of them must be public https:// URLs: an http:// or private URL gets a 400 at creation.
The accept page is not a success page. It shows "we are checking your payment" until the webhook arrives, then switches to the confirmation. In practice, it queries your own server — which knows whether the event has arrived — and never the payment API directly: the API key never leaves your server. If the event is slow to come, your server can read the session's status again; the customer, meanwhile, sees an honest message rather than a premature confirmation.
The decline page explains, without jargon, that the payment did not go through, and offers to start the order again — with a new session. Do not promise a specific cause: the failure may come from abandoned authentication, a decline from the issuing bank or wrong card details, and many Moroccan cards are, by default, closed to online payments. A sentence inviting the buyer to check their card with their bank is more helpful than a technical code.
Canceling a session before payment
POST /v1/payment-sessions/{sessionId}/cancel cancels the session: it can no longer be paid, and the response returns its updated status. Three situations call for it:
- the cart changed — an item removed, a quantity modified — and the amount is no longer right;
- the stock ran out before payment;
- the order was withdrawn by the seller, or replaced by another one.
Always cancel the old session before creating a new one for the same order. Create that new session with a new externalId: a create with an externalId that has already been used returns the existing session with 200, not a new one. Change the Idempotency-Key header value too if you send one: the same key with a different body returns 409 IDEMPOTENCY_CONFLICT. The orderId, on the other hand, can stay the same, since it carries no uniqueness constraint. These mechanisms are detailed in payment idempotency. Once the payment has succeeded, you are no longer talking about a cancellation. Canceling a session that has already been paid returns 409 SESSION_ALREADY_CONSUMED: issue a refund instead.
Reading a session's status
GET /v1/payment-sessions/{sessionId} returns a session's current state: its status, its amount and its expiry. GET /v1/payment-sessions lists the sessions in your key's environment, most recent first, with the pagination shared by all lists — page starting at zero and size, to walk until totalPages — and an origin filter, origin=API or origin=DASHBOARD.
The status is read, not guessed. Do not hard-code its list of values, and tolerate new ones: that is how the API evolves without breaking your integration. Above all, polling is a fallback, not the source of truth. Check the status at three useful moments — when the customer lands on accept before the event, as expiresAt approaches for an order still pending, and during a reconciliation — not every second while you wait for the customer: calls that are too frequent end in 429 RATE_LIMITED.
A session's 404, 409 and 410 codes
Four codes go with the life of a session. They answer, among others, the calls that open and pay the session — the hosted page's calls or, in direct checkout, your server's:
| Code | Point in the lifecycle | What to do |
|---|---|---|
404 SESSION_NOT_FOUND | the session is unknown | check the sessionId you sent |
409 SESSION_ALREADY_CONSUMED | the session has already been paid | do not submit it again; for a new payment, create a new session, and to give money back, issue a refund |
409 SESSION_NOT_ACTIVE | the session was canceled | create a new one if the order still has to be paid |
410 SESSION_EXPIRED | the session is past its expiresAt | create a new session, with a new externalId and a new Idempotency-Key |
An invalid or already-used vk verify key, for its part, returns 401 INVALID_TOKEN, not a 409. None of these codes justifies retrying as is. The full list, including codes that do not concern sessions, is in payment API error codes.
After an outage: reconcile with GET /v1/transactions
Webhook deliveries make up to 16 attempts over about 72 hours. If your server was down longer than that, stop waiting for the event: reconcile with GET /v1/transactions.
- 1Bound the outage window with
fromandto. - 2Walk the transactions with cursor pagination:
limit, thencursor = nextCursorwhilehasMoreis true. It neither skips nor repeats a row when new transactions arrive during the walk. - 3Read each session again whose order stayed "awaiting payment" during the window, with
GET /v1/payment-sessions/{sessionId}— which is why you stored thesessionIdnext to the order at creation. - 4Confirm the paid orders, and release those whose session has expired.
- 5If you then replay failed deliveries from the portal, make sure your handler recognizes an order that is already confirmed and does not confirm it twice.
Tying a payment to its order — metadata, orderId, sessionId, operationId — is detailed in matching a payment to its order.
Frequently asked questions
How long does a checkout session last?
72 hours after creation by default. You can shorten it with expiresAt, an ISO-8601 date in UTC set in the future. A session is consumed only once, and an expired session can no longer be paid: to collect the same order after expiry, create a new session with a new externalId and a new Idempotency-Key.
What happens if the customer closes the payment page?
Nothing final. Until the signed event arrives, the order stays pending, and the session stays valid until it expires or is canceled. If the payment had gone through before the page was closed, payment.succeeded still arrives. To find out where the session stands, read its status with GET /v1/payment-sessions/{sessionId}.
What should the return page show after payment?
On the accept page, a message along the lines of "we are checking your payment", until your server has received the webhook; the confirmation comes next. The page queries your server, never the API directly. On the decline page, a jargon-free explanation and a way to start the order again, with a new session.
How do I cancel a checkout session?
With POST /v1/payment-sessions/{sessionId}/cancel, before payment: the session is canceled and can no longer be paid, and the response returns its updated status. Always cancel the old session before creating a new one for the same order, and give the new one a new externalId and a new Idempotency-Key.
What does the 410 SESSION_EXPIRED error mean?
That the session is past its expiresAt — 72 hours after creation by default — and can no longer be paid. Do not replay the call: create a new session, with a new externalId and a new Idempotency-Key, and redirect the buyer to its checkoutUrl. The orderId can stay the same, since it carries no uniqueness constraint.
Can I reuse a checkout session that has already been paid?
No. A session is consumed only once — singleUse defaults to true — and a new payment attempt on a session that has already been paid gets 409 SESSION_ALREADY_CONSUMED. For a new payment, create a new session. To give money back on a successful payment, you need a refund, not a session.
What happens if I do not set a return URL?
For accept and decline, your account's configured default applies, then the platform's. For notification, only your account's default applies, with no platform fallback; your registered endpoints, for their part, receive the events they are subscribed to. Any URL you do set must be public https://, or you get a 400.
How do I know whether a session was paid when the webhook does not arrive?
Read its status with GET /v1/payment-sessions/{sessionId}, and check the delivery log: it tells you whether the event went out and what your server answered. Deliveries make up to 16 attempts over about 72 hours; after a longer outage, reconcile with GET /v1/transactions.
Next step
Replay a session's three outcomes in the sandbox — successful payment, closed page, short expiry — before wiring up your return pages: Start in test mode. Then build the receiver with getting your webhook integration right, and tie each payment to its order with matching a payment to its order.
Written by ChariPay team.