Hosted checkout
For taking splits from your own checkout, rather than the SharePay dashboard.
1. Create a checkout session
Send your API key as x-merchant-api-key on:
POST /api/merchants/checkout/sessions
Call this from your server, never from a web page. The API key must never reach a browser — and it would not work there anyway, because browsers are not allowed to send that header to our API.
{
"amount": 120.00,
"order_reference": "ORDER-123",
"return_url": "https://your-shop.example/order/complete",
"cancel_url": "https://your-shop.example/basket",
"customer_name": "Alex Morgan",
"metadata": { "order_id": "1042", "channel": "web" },
"line_items": [
{ "description": "Aeroflow helmet, matte black", "quantity": 1, "amount": 108.00,
"image_url": "https://cdn.your-shop.example/helmet.jpg" },
{ "description": "Rear light", "quantity": 2, "amount": 8.00 },
{ "description": "Delivery", "amount": 4.00 }
]
}
Returns 201 with { token, checkout_url }. The session is valid for 2 hours.
| Field | Required | Notes |
|---|---|---|
amount | Yes | Pounds, not pence. Must be between 1 and 5000 inclusive. Decimals are fine, so £120.50 is 120.50. |
order_reference | No | Your own reference, shown to the buyer. Defaults to Order. |
return_url | Yes | Where the buyer is sent once the split is set up. |
cancel_url | No | Where a buyer who backs out is sent. The hosted page renders a link to it, but only before the split is set up. See below. |
currency | No | Defaults to gbp. gbp is the only accepted value. |
line_items | No | What the order is made of, shown to everybody who is asked to pay. See below. |
customer_email | No | The buyer's address, if you know it. Prefills the email step on the hosted page; they can still change it and it is still verified by code. |
customer_name | No | Up to 120 characters. Stored and echoed back to you, never shown to the group. |
metadata | No | Your own ids, as a flat object of strings: up to 20 keys, keys up to 40 characters, values up to 500, 4 KB in all (the same limits as Stripe). Echoed back on the status endpoint and in every webhook for the order. Never shown to anyone paying. |
Retrying safely
Send an Idempotency-Key header (any string up to 255 characters, your order
id is the obvious choice) and a retry cannot create a second checkout for the
same order:
POST /api/merchants/checkout/sessions
x-merchant-api-key: sp_prod_...
Idempotency-Key: order-1042
- The same key with the same body inside 24 hours returns
200with the originaltokenandcheckout_url. Nothing new is created. - The same key with a different body returns
409 { "code": "idempotency_key_reused" }. That is a bug on your side: pick a new key for a new order. - After 24 hours the key is forgotten and may be reused.
Without the header every call creates a new session, as before.
What your customer sees
They choose how to pay, and none of the routes require a SharePay account: split it with other people, ask one person to pay the whole thing, or pay it themselves. Signing in is offered but never required.
This is worth knowing if you integrated earlier: a login wall used to be the first thing your customer met on that page, and it is gone.
The flat SharePay fee applies only when a payment is genuinely split between two or more people. One payer, whichever route they took, pays the order total and nothing more.
If your account cannot take payments when they try, they read "This shop can't
take payments right now." and nothing about why. The response carries the
reason as a code: billing_card_required (add a card in Settings, Billing),
billing_paused (pay the unpaid SharePay bill there) or connector_not_live.
We are alerted when this happens to a live customer.
Telling payers what they are buying
Send line_items and every payer sees the order itemised: the buyer on the
hosted page, and anyone they invite to pay a share. Leave it out and nothing
changes from before.
This matters most for the people you never meet. Somebody sent a payment link has no basket and no account, so without line items all they see is a reference code and an amount, and they are being asked for their card on that basis.
| Field | Required | Notes |
|---|---|---|
description | Yes | Up to 120 characters. Shown as written. |
amount | Yes | Pounds, not pence, like the order amount. Negative for a discount. |
quantity | No | A whole number of 1 or more. |
image_url | No | An https URL we fetch once and re-serve. See below. |
The lines must add up to amount, or the call fails with 400. Delivery,
tax and discounts are simply more lines, and a discount is a negative one. This
is deliberate: a payer has no way to check an itemisation against the amount
being taken from their card, so we check it for you.
There is no limit on how many lines you send. The request as a whole is capped in size, so a very large basket may need shortening; a long list is displayed with the first few lines and a count of the rest.
Everything you send here is shown to strangers on a page that asks for card details, so it is rendered as plain text. Markup, links and scripts in a description appear as the characters you typed, and are never followed.
Pictures
We do not hot-link your image. When you create the session we fetch
image_url once, check it really is an image, and serve our own copy from then
on. Your server therefore never sees the people paying, which matters because
some of them are people your customer invited and who have no relationship with
you at all.
Send a publicly reachable https URL, 2 MB or smaller, as PNG, JPEG, GIF,
WebP or AVIF. SVG is not accepted. Up to a dozen pictures per order are taken,
and we give up on a slow host quickly.
A picture we cannot fetch is simply left out. The line keeps its description and price and the checkout works normally: a thumbnail is never a reason to fail somebody's purchase. If your images are behind a signed URL or a firewall, expect them not to appear.
cancel_url is only offered before the split existsWhen you send one, the hosted page renders a Cancel and return to <your business name> link below the card, and the session lookup returns the value
as cancel_url. Omit the field and no link is rendered at all: the page does not
fall back to return_url, because this flow only ever sends a buyer to
return_url on success and a merchant handler reading that hit could book an
order that was never placed.
The link only appears while the buyer is still signing in or choosing shares. It disappears the moment the split is created. From that point every participant has a card hold against them, and a link that merely walks back to your shop would read as "this releases the holds" when it does not. Unwinding a live split is cancel or refund, not navigation.
So a hit on your cancel_url means "the buyer walked away before placing
anything", and it is the only case you will hear about. A buyer who
authorises their share and then abandons the split sends you nothing, and
neither does one who closes the tab. Do not treat cancel_url as an
abandonment feed. Reconcile on the token instead: a session that never produces
a split, or a split that never reaches checkout_split.paid, is the real
signal. See
Reconciling without a webhook.
"amount": 12000 is read as £12,000 and rejected, because it is over the
£5000 ceiling. A £120 order is "amount": 120.
The direct POST /api/checkout-splits endpoint is the
other way round: it takes each participant's amountMinor in pence. The
two endpoints do not share a unit.
Set your allowed return domain first
allowed_domain must be set on your account before this call will succeed. Set
it on the Developers page in your dashboard, under "Allowed return domain".
While it is unset, every return_url is rejected.
The check, for both return_url and cancel_url, is:
- The URL must parse, and its scheme must be
https:. - Its hostname must equal
allowed_domain, compared case-insensitively. - The path, port and query string are not checked. With an allowed domain
of
your-shop.example, bothhttps://your-shop.example/order/completeandhttps://Your-Shop.example:8443/anything?x=1pass. - Subdomains are not covered.
https://shop.your-shop.example/...does not match an allowed domain ofyour-shop.example.
cancel_url is only checked when you send one.
When the call returns 400
| Response | Cause |
|---|---|
Only gbp is supported | currency was set to anything other than gbp. |
amount must be between £1 and £5000 | amount was outside that range, or was not a number. Remember it is in pounds. |
return_url must be https and match your allowed return domain | No allowed_domain set, or the URL failed the hostname check above. |
cancel_url must be https and match your allowed return domain | Same check, on the cancel_url you sent. |
Finish Stripe onboarding before accepting checkouts | Your account is not stripe_charges_enabled yet. See Become a merchant. |
An invalid or missing API key returns 401 Invalid API Key.
This endpoint is limited to 120 requests a minute, keyed by your API key,
and returns 429 Too many checkout session requests. Please slow down. beyond
that. RateLimit-* headers are on every response.
This endpoint cannot tell a retry apart from a new request, and there is no key you can send to make it. Send it twice and you get two sessions for one basket.
A session on its own is harmless, since it holds no money. The damage is that
a buyer handed two live sessions can set up two splits, and place two sets of
card holds on their friends. So save the token against your order as soon
as you get it, and if one is already there, reuse it instead of making
another.
2. Redirect the buyer
Redirect the buyer to checkout_url. SharePay hosts the buyer-facing page,
where they sign in and set the split up: inviting participants and choosing
amounts. The split itself is created there, not by your API-key call. The
buyer returns to your return_url when they are done.
On that page the buyer may split with at most 20 people, and their chosen
shares must add up exactly to the session amount. A split can only be set up
once per session: a second attempt gets
409 This checkout already has a split.
Each share must be at least £1. A buyer who gives someone less gets
400 Each share must be at least £1. on the hosted page and nothing is
created. That also caps how many ways a small order can go: a £15 order cannot
be split more than 15 ways, whatever the 20-person limit says.
Each participant is then charged their share plus a flat 20p SharePay service
fee, shown to them as three lines before they authorise. Your amount is
the order share before SharePay's merchant fee and Stripe's processing fees. See
what SharePay charges.
Until they create the split, the page also offers a way back to your
cancel_url, if you sent one. Once the split exists that link is gone, and so
is any signal to you that the buyer changed their mind.
3. Store the token
token is not just an ingredient of checkout_url. It is the only handle your
server has on the order afterwards:
GET /api/merchants/checkout/sessions/<token>/status
x-merchant-api-key: sp_prod_...
returns the whole order as your server sees it: the session, your
customer_email, customer_name and metadata, and once the buyer has set
the split up, a split block with its status, who has approved, and each
participant. The full response, what each status means and how to poll it are
in Reconciling without a webhook.
Save token against your own order record at creation time, and treat it as a
secret: the credential-less lookup the hosted page uses
(GET /api/merchants/checkout/sessions/<token>, no /status) exposes the
order amount, your business name and the buyer's email to anyone holding it.
4. Nudge the group
A group order dies quietly when one friend forgets. From your server:
POST /api/merchants/checkout/sessions/<token>/remind
x-merchant-api-key: sp_prod_...
emails everyone who has not yet approved their share, with their own pay link.
Returns { reminded, skipped, next_allowed_at }.
- Each participant hears at most once every 12 hours, whoever asks.
- Each order gets three reminder calls in all; after that the call returns
429 { "code": "session_cap_reached" }. - When there is nobody left to remind (no split yet, everyone has approved,
the order is paid, or the holds have lapsed) it returns
409 { "code": "nothing_to_remind" }. - Anyone who has asked us to stop emailing them is skipped, and counted in
skipped.
If your thank-you page has a "nudge the group" button, have it call your own server, which calls this with your API key. There is no buyer-side credential for it: the hosted page's guest session never reaches your page, and it lapses two hours after checkout in any case.
Separately, once a day we remind anyone still pending on an order whose card holds lapse within the next 24 hours, whether or not you called this.
Refunds on hosted checkout
Your server can refund an order it took through hosted checkout, in full or in part, with your API key:
POST /api/merchants/checkout/splits/<split_id>/refund
x-merchant-api-key: sp_prod_...
Content-Type: application/json
{ "amount": "12.50", "reason": "One item out of stock", "idempotency_key": "refund-1042-1" }
split_id is the split.id on the status endpoint and
the data.split_id of every webhook for the order. Send no amount to
refund everything still outstanding.
- A partial amount is spread across the people who paid, in proportion to what each still has outstanding, to the penny. Each becomes its own Stripe refund on your account.
- A customer's 20p service fee comes back with the slice that completes their share, in the same Stripe refund, and never out of your balance. Until a share is complete its fee stays with SharePay.
- A failure part-way is reported as exactly that. Refunds are issued one
participant at a time and are real the moment Stripe accepts them. If Stripe
refuses one, the response is
409 partial_failurewith what went through and who failed; call again for the remainder. - Send
idempotency_key(any string up to 255 characters, unique per refund you intend) and a retried call returns the stored result instead of refunding twice.
The full response, the history on the status endpoint and the
checkout_split.refunded event are in Refunds, disputes and order
status.
Cancelling before capture is still a dashboard operation:
POST /api/checkout-splits/:id/cancel takes a signed-in session, not an API
key. The dashboard's own Refund button refunds the whole order only.
A worked example
A minimal server-side handler that turns a basket into a SharePay checkout and redirects the customer:
// POST /checkout/sharepay on your own server
app.post("/checkout/sharepay", async (req, res) => {
const basket = await loadBasket(req.session.basketId);
const response = await fetch(
"https://api.share-pay.co.uk/api/merchants/checkout/sessions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-merchant-api-key": process.env.SHAREPAY_API_KEY,
},
body: JSON.stringify({
// Pounds, not pence, on this endpoint. £1 to £5000.
amount: basket.totalPence / 100,
order_reference: basket.orderReference,
return_url: "https://your-shop.example/order/complete",
// Where the "Cancel and return" link on the hosted page goes. Only
// offered before the buyer sets the split up, so a hit here means an
// untouched basket, never an abandoned split.
cancel_url: "https://your-shop.example/basket",
}),
},
);
if (!response.ok) {
const { error } = await response.json().catch(() => ({}));
return res.status(502).send(error ?? "Could not start SharePay checkout");
}
const { checkout_url, token } = await response.json();
// Keep the token: it is how you look this order up later, and the only
// split-state surface reachable with an API key.
await saveSharePayToken(basket.orderReference, token);
res.redirect(303, checkout_url);
});
When the buyer lands back on your return_url, treat it as "the split has been
set up", not "the money has arrived". Wait for the
checkout_split.paid webhook before you fulfil the order, and
reconcile on the token for any
order that never produces one.
A complete integration, including signature verification and fulfilment, is published as a reference storefront. See Testing.