How your store finds out
A customer finishes a ShotPay checkout. Something has to tell your storefront, or the order never ships.
This page is the whole of that contract. Skipping the webhook half is how stores ship orders that were never paid for.
One function, two callers
Write a single function on your server — this page calls it fulfil.
It takes a session id, asks ShotPay what happened, and acts. Nothing else in your codebase decides whether a layaway order is paid.
function fulfil(string $sessionId): void
{
$session = Http::withHeaders([
'X-Client-Id' => config('shotpay.client_id'),
'X-Api-Key' => config('shotpay.api_key'),
])->get(config('shotpay.url')."/v1/checkout-sessions/{$sessionId}")->json('data');
if ($session['status'] !== 'completed') {
return;
}
$order = Order::where('reference', $session['order_reference'])->firstOrFail();
// Both callers can reach this, sometimes at once. Doing nothing on an
// order already marked paid is what makes that safe.
if ($order->isPaid()) {
return;
}
$order->markPaid();
}
import { ShotPayClient } from '@shotpay/node';
const shotpay = new ShotPayClient({
clientId: process.env.SHOTPAY_CLIENT_ID,
apiKey: process.env.SHOTPAY_API_KEY,
});
export async function fulfil(sessionId) {
const session = await shotpay.checkoutSessions.retrieve(sessionId);
if (session.status !== 'completed') return;
const order = await orders.findByReference(session.order_reference);
if (order.paid) return;
await orders.markPaid(order.id);
}
Two things call it:
| Caller | When it fires | Why you need it |
|---|---|---|
Your session.completed webhook handler |
Server to server, as soon as the plan is signed | Required. It is the only one that fires whether or not the customer's browser survives the trip. |
Your return_url page |
When the customer lands back on your store | Recommended. It settles the order while the customer is still watching, instead of leaving them on a page that says "processing". |
If you only build one, build the webhook. A customer can complete a checkout and lose their connection before your success page loads; the webhook still arrives, and retries five times if your server is down.
Both callers can run at once
A webhook and a returning customer race routinely. fulfil has to be safe to
run twice on the same session — check whether the order is already paid
before you touch it. This is the same rule as
deduplicating on the event id, stated for
the other caller.
What the redirect carries
When the checkout resolves, ShotPay sends the customer to the URL you named at
session create — return_url if they
completed, cancel_url if they did not — with two query parameters appended:
| Parameter | Example | What it is |
|---|---|---|
session_id |
cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgA8eU |
The session to pass to fulfil. |
status |
completed, cancelled, expired |
Which page to render while fulfil runs. |
https://store.example/checkout/complete?session_id=cs_9Xk2…&status=completed
status is a hint, never evidence
Both parameters arrive over a browser redirect. Anyone can type that URL. They tell you which page to draw and which session to ask about — they are not an answer about the order.
The answer is what
GET /v1/checkout-sessions/{id}
returns to your server, holding your API key. That is the call inside
fulfil.
The three ways a checkout ends
Every one of them raises an event and produces a status:
status |
Event | What happened |
|---|---|---|
completed |
session.completed |
The customer accepted the plan and the down payment was collected. Ship the order. |
cancelled |
session.cancelled |
The customer walked away from the quote. Release the stock now. |
expired |
session.expired |
Nobody acted before the deadline. |
cancelled arrives the moment a customer leaves the checkout, which is what
makes it worth handling: without it an abandoned cart is invisible until the
session lapses, up to a full TTL later.
Delivery: a top-level redirect
Your server opens the session and sends the customer to checkout_url as a
full-page navigation.
return redirect()->away($session['checkout_url']);
That is the whole integration. It works in every browser, including the in-app browsers inside social and messaging apps, where popups and embedded frames fail — often silently.
ShotPay checkout cannot be framed
The hosted checkout is served with frame-ancestors 'self'. A storefront
embedding it in an <iframe> will be refused by the browser. Customers
arrive by full-page redirect, and come back to the same URL later to pay
off the plan.