Skip to content

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.