Quick Start
Six steps take a cart from "can I offer layaway on this?" to a signed agreement that pays itself off. This page uses a sandbox key, so nothing here charges anyone.
You need a client id and an API key — see Installation.
export SHOTPAY_CLIENT_ID="mch_test_7fL2qXn4WbTzR9kD1sVyH6mCgA8eUpJ3"
export SHOTPAY_API_KEY="sk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
export SHOTPAY_URL="https://api.shotpay.com/api"
1. Read the merchant's terms
Call this once at install and cache what it returns. terms is the range the
merchant may actually sell in.
curl "$SHOTPAY_URL/v1/config" \
-H "X-Client-Id: $SHOTPAY_CLIENT_ID" \
-H "X-Api-Key: $SHOTPAY_API_KEY"
$config = Http::withHeaders([
'X-Client-Id' => config('shotpay.client_id'),
'X-Api-Key' => config('shotpay.api_key'),
])->get(config('shotpay.url').'/v1/config')->json('data');
cache()->put('shotpay.terms', $config['terms'], now()->addHour());
{
"data": {
"merchant": { "client_id": "mch_test_7fL2…", "legal_name": "Frontier Firearms LLC", "dba": "Frontier Range" },
"mode": "sandbox",
"layaway": { "available": true, "reason": null, "message": null },
"terms": {
"min_order_amount": 10000,
"max_order_amount": 300000,
"down_payment_percentage": 30
},
"session_ttl_minutes": 60
}
}
This endpoint answers 200 even when layaway is unavailable — read
layaway.available, not the status code.
2. Decide whether to show the option
Show the layaway button when layaway.available is true and the cart total sits
inside terms. Both bounds are in cents, so compare against the cart total in
cents rather than dollars. Otherwise show nothing, or show layaway.message,
which is written to be safe to put in front of a customer.
3. Open a checkout session
When the customer picks layaway, your backend opens a session against the
cart. return_url and cancel_url must be https://.
curl -X POST "$SHOTPAY_URL/v1/sessions" \
-H "X-Client-Id: $SHOTPAY_CLIENT_ID" \
-H "X-Api-Key: $SHOTPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"order_reference": "ORD-1001",
"total_amount": 129900,
"customer_email": "customer@example.com",
"customer_state": "ID",
"return_url": "https://store.example.com/checkout/complete",
"cancel_url": "https://store.example.com/cart"
}'
$session = Http::withHeaders([
'X-Client-Id' => config('shotpay.client_id'),
'X-Api-Key' => config('shotpay.api_key'),
])->post(config('shotpay.url').'/v1/sessions', [
'order_reference' => $order->number,
'total_amount' => $order->total_cents,
'customer_email' => $order->email,
'customer_state' => $order->shipping_state,
'return_url' => route('checkout.complete'),
'cancel_url' => route('cart'),
])->json('data');
A 201 comes back with the session:
{
"data": {
"id": "cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgKtNu",
"status": "pending",
"order_reference": "ORD-1001",
"total_amount": 129900,
"down_payment_amount": 38970,
"down_payment_percentage": 30,
"checkout_url": "https://secure.shotpay.com/checkout/cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgKtNu",
"return_url": "https://store.example.com/checkout/complete",
"cancel_url": "https://store.example.com/cart",
"expires_at": "2026-08-12T15:30:00+00:00",
"created_at": "2026-08-12T14:30:00+00:00"
}
}
Store id against your order. It is the session token — it identifies the
session everywhere afterwards, and it is what the customer's browser carries.
customer_state is worth sending: layaway is allow-listed per state, and
passing the customer's state gets the refusal at session-open rather than on
the checkout page.
4. Send the customer to checkout_url
Redirect the browser there, as a full-page navigation. The customer reads the
quoted plan, enters a name and an email, and accepts. ShotPay collects the down
payment and returns them to your return_url; if they back out, to cancel_url.
return redirect()->away($session['checkout_url']);
The checkout is a hosted page and cannot be embedded — a storefront framing it will be refused by the browser. The redirect is the supported delivery, and the one that works in every in-app browser.
The session expires after session_ttl_minutes — 60 by default — if nobody acts
on it. An expired session cannot be revived; open a fresh one.
5. Verify on your return page
The customer comes back to return_url with two parameters appended:
https://store.example.com/checkout/complete?session_id=cs_9Xk2…&status=completed
Do not trust the redirect
A return to return_url means the browser came back, not that the plan was
signed — and status is a query parameter anyone can type. Both are hints
about which page to render. Ask ShotPay for the fact.
curl "$SHOTPAY_URL/v1/checkout-sessions/cs_9Xk2mQpR7vLzT4hB1nWsY6dF3jCgKtNu" \
-H "X-Client-Id: $SHOTPAY_CLIENT_ID" \
-H "X-Api-Key: $SHOTPAY_API_KEY"
Route::get('/checkout/complete', function (Request $request) {
fulfil($request->query('session_id'));
return view('checkout.complete');
});
fulfil is the one function that decides whether an order is paid. It reads the
session with your key, checks status, and marks the order — doing nothing if it
has already been marked, because step 6 calls it too.
6. Handle the webhook
session.completed fires when the plan is confirmed and the down payment has
landed. It is the caller that matters most: it arrives whether or not the
customer's browser survives the trip back.
Route::post('/shotpay/webhook', function (Request $request) {
// Verify first — see Signatures.
$event = $request->json();
if ($event['type'] === 'session.completed') {
fulfil($event['data']['object']['id']);
}
if ($event['type'] === 'session.cancelled') {
Order::where('number', $event['data']['object']['order_reference'])->first()?->release();
}
return response()->noContent();
});
Answer 2xx as soon as you have stored the event, and deduplicate on the event
id — see Webhooks.
From here ShotPay collects the remaining installments and sends
contract.paid for each one, then contract.completed when the plan settles.