Skip to main content

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_email": "[email protected]",
"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.

FieldRequiredNotes
amountYesPounds, not pence. Must be between 1 and 5000 inclusive. Decimals are fine, so £120.50 is 120.50.
order_referenceNoYour own reference, shown to the buyer. Defaults to Order.
return_urlYesWhere the buyer is sent once the split is set up.
cancel_urlNoWhere 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.
currencyNoDefaults to gbp. gbp is the only accepted value.
line_itemsNoWhat the order is made of, shown to everybody who is asked to pay. See below.
customer_emailNoThe 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_nameNoUp to 120 characters. Stored and echoed back to you, never shown to the group.
metadataNoYour 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 200 with the original token and checkout_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.

FieldRequiredNotes
descriptionYesUp to 120 characters. Shown as written.
amountYesPounds, not pence, like the order amount. Negative for a discount.
quantityNoA whole number of 1 or more.
image_urlNoAn 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 exists

When 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.

This endpoint takes pounds, not pence

"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, both https://your-shop.example/order/complete and https://Your-Shop.example:8443/anything?x=1 pass.
  • Subdomains are not covered. https://shop.your-shop.example/... does not match an allowed domain of your-shop.example.

cancel_url is only checked when you send one.

When the call returns 400​

ResponseCause
Only gbp is supportedcurrency was set to anything other than gbp.
amount must be between £1 and £5000amount was outside that range, or was not a number. Remember it is in pounds.
return_url must be https and match your allowed return domainNo allowed_domain set, or the URL failed the hostname check above.
cancel_url must be https and match your allowed return domainSame check, on the cancel_url you sent.
Finish Stripe onboarding before accepting checkoutsYour 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.

If the request times out, do not retry blindly

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_failure with 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.