Manage splits
All four endpoints on this page authenticate a signed-in SharePay session
(Authorization: Bearer <supabase token>). None of them accepts
x-merchant-api-key. Your API key opens hosted checkout sessions; it
authenticates nothing on this page, so a reconciliation job on your own server
gets
401 Missing or invalid Authorization header from every call here.
These endpoints back the SharePay dashboard. Treat them as dashboard operations, and see Webhooks for what your server can actually poll.
| Endpoint | Who may call it |
|---|---|
GET /api/checkout-splits | The signed-in owner of the merchant. Returns that merchant's splits, newest first |
GET /api/checkout-splits/:id | The split's creator or the merchant's owner. There is a participant branch in the check too, but nothing can pass it. See below |
POST /api/checkout-splits/:id/cancel | The split's creator or the merchant's owner |
POST /api/checkout-splits/:id/refund | The split's creator or the merchant's owner |
GET /api/checkout-splits is scoped to the merchant the signed-in account
owns. A signed-in user with no merchant gets [] rather than an error.
The participant branch of that check compares the signed-in account against
each participant's user_id, and user_id is never populated: both ways of
creating a split store the participant's email and nothing else. So the branch
never matches, and a participant who is neither the creator nor the merchant's
owner gets 403 Forbidden.
What a participant does have is their own pay link and
GET /api/checkout-splits/mine, which matches on email as well as user_id.
Do not build anything that expects a participant to read the detail endpoint.
GET /api/checkout-splits/mine is a different thingIt exists, and it is the buyer's view: splits the caller created or is a
participant in, with other people's pay_token values redacted to null. It
is not your merchant list. Use GET /api/checkout-splits for that.
Cancelling and refunding a hosted-checkout split
Cancel and refund accept either the account that created the split or the account that owns the merchant the split was taken against.
- For a split you created yourself, in the dashboard or via
POST /api/checkout-splits, the creator is you. - For a split created through hosted checkout, the creator is the buyer, because the buyer is the one who sets the split up on the hosted page. You are still the merchant that split was taken against, so both calls work for you.
Either way, both work from a signed-in dashboard session, and the dashboard's
Cancel and Refund buttons work on hosted-checkout splits. Those buttons are
drawn from the split's status alone: Cancel shows on a pending split, Refund
on a captured one.
The permission is on the merchant record, not on the API key. You still need a signed-in session, from the SharePay account that owns the merchant. Your API key authenticates nothing here, so this is not something your server can do on its own.
Nobody else gets in. A caller who is neither the creator nor the merchant's
owner gets 403 Only the creator or the merchant can cancel or
403 Only the creator or the merchant can refund, and nothing is cancelled or
refunded. Being a participant in the split does not qualify: a participant
who is neither of those two may only view their own share, and even that is via
their pay link rather than these endpoints.
Cancelling a split as the merchant sends you a
checkout_split.canceled webhook, because that event
goes to the merchant the split belongs to. Your own handler will see the
cancellation you just made, so make it idempotent rather than treating every
delivery as news.
You can also refund one participant's charge directly in your own Stripe
dashboard, which is the only way to return part of a split: each participant's
stripe_payment_intent_id on GET /api/checkout-splits/:id is a charge on
your own connected account. When Stripe reports that refund back to us, the
participant moves to refunded while the split's own status stays
captured, because SharePay did not initiate it. So a split reading captured
is not proof that its money is all still with you. Read the participants.
Be aware of what you are refunding there. Each of those charges is the share plus the customer's 20p service fee, so refunding one in full returns all of it to the customer out of your balance.
You are not left funding our fee on a full refund. When you refund one of these charges in full, SharePay reverses its own 20p, so the fee comes back out of our balance rather than yours. You give back the share, we give back the fee, exactly as on the API refund below. The reversal happens when Stripe reports the refund to us, so it follows a moment behind the refund itself.
A partial refund keeps the fee. If you refund only part of a charge, the customer still used SharePay to pay, so the 20p stays with us and nothing is reversed. This includes a run of partial refunds while it is still partial; once they add up to the whole charge, the fee is reversed once. Contact support if you need the fee reversed on an order you refunded only in part.
The created_by field on the detail response still tells you which kind of
split you are looking at, and who the buyer was.
Refunds through the API
POST /api/checkout-splits/:id/refund refunds the whole split. Every
captured share is refunded in full as its own Stripe refund, because each share
was its own charge, and the split moves to refunded. There is no partial or
per-participant refund, and you cannot pass an amount.
The service fee comes back too. A participant is returned the whole amount their card was charged, share and 20p, so a refunded customer is made whole. SharePay reverses its own fee on the same call, so returning it does not come out of your balance: you give back the share, we give back the fee.
The call fails with 400 Only a captured split can be refunded unless the
split is captured. To stop a split before that, cancel it while it is still
pending.
Cancelling through the API
POST /api/checkout-splits/:id/cancel takes the creator or the merchant's
owner in the same way, and it acts only on a split that is still pending. On
a pending split it releases the holds. It never captures anything, so this is
not a path that can take money.
200 from cancel does not mean anything was cancelledThe engine claims the split with a conditional pending to canceling update
and returns silently when that claim does not land. The route never looks at
the result, so it answers 200 {"status":"canceled"} either way.
Cancelling a split in any status other than pending therefore gives you a
success body while nothing at all happens: capturing, captured,
canceling, capture_failed, creation_failed, canceled and refunded all
behave that way. On a captured split that is worth being blunt about, because
the money stays taken and the 200 is not a refund.
Treat the response as "the request was accepted", not as confirmation. Re-read
the split with GET /api/checkout-splits/:id and check that status actually
moved to canceled.
One more thing to know about the cancel that does run: each participant row is
updated to canceled whether or not the Stripe call releasing that hold
succeeded, because the failure is logged and stepped over. A participant
reading canceled is our record of the intent rather than proof the hold is
gone. A hold we failed to release is not a charge, and it lapses on its own at
authorization_expires_at.
A share you captured yourself in your own Stripe dashboard is skipped by this
loop rather than reversed, because a captured charge can only be refunded, and
the split still ends at canceled. That is the one way a canceled split can
contain money that was actually taken, and it takes an action of yours to get
there.
Reading a split
The list endpoint returns a summary of each split: id, order_reference,
currency, status, total_amount, created_at, and its participants
(id, email, amount, status, pay_token).
Detail response fields
GET /api/checkout-splits/:id returns the stored record in full. On the split:
| Field | Notes |
|---|---|
id | The split id, the same value webhooks carry as data.split_id |
merchant_id | Your merchant |
created_by | The account that created the split. For hosted checkout this is the buyer, who set the split up on the hosted page |
order_reference | As supplied, or the hosted session's reference |
currency | gbp |
total_amount | The order total, the sum of the shares. It is what you receive before your own Stripe fees, and it excludes the service fees your customers paid on top |
application_fee_total | The SharePay service fee for the whole split, collected from your customers on top of their shares. It is not deducted from your payout, so do not subtract it when you reconcile. See what SharePay charges |
status | The lifecycle status, below |
stripe_account_id | The connected account the charges live on |
authorization_expires_at | Creation plus 7 days. The exact deadline the holds are good until |
disputed_at | null until a chargeback lands |
created_at, updated_at | Timestamps |
participants | The array below |
On each participant:
| Field | Notes |
|---|---|
id | Participant row id |
user_id | Always null in practice. Neither create path populates it, which is why the participant branch of the detail endpoint's permission check never matches |
email | As supplied |
amount | This person's share |
application_fee | The SharePay service fee this person paid on top of their share, normally 0.20. Their card was charged amount plus this |
status | This person's own status, below |
stripe_payment_intent_id | The charge on your own Stripe account. This is what you reconcile against, and what you refund by hand when you want to return one share rather than the whole split. Its Stripe amount is amount plus application_fee, not amount |
pay_token | Builds this person's pay link |
disputed_at | null until this share is disputed |
created_at, updated_at | Timestamps |
The list summary and the webhook payload give
amounts as decimal strings in pounds, fixed to two places. The create
response gives amountMinor as an integer number of pence. The detail
endpoint returns the stored decimal columns as they are, without that
two-decimal formatting applied. Parse the detail values as decimals rather than
assuming a fixed shape, and never compare an amountMinor against an amount
without converting.
Status flow
status on the split is the order-level lifecycle. The full set of values it
can take:
| Status | Terminal | Meaning |
|---|---|---|
pending | No | Holds are placed and we are waiting on approvals |
capturing | No | Capture is in flight |
captured | Yes | Every share has been taken |
canceling | No | Cancellation is in flight |
canceled | Yes | SharePay took nothing: this path releases holds rather than capturing them. A hold Stripe refuses to release still leaves the share canceled, since nothing was or will be captured; that hold lapses at authorization_expires_at instead. A share you had already captured yourself is the exception and ends captured, not canceled — see below. Covers an explicit cancel and the expiry job |
refunded | Yes | A captured split was refunded through the API |
creation_failed | Yes | The split could not be opened at all. Nothing was captured, so nobody was charged. The holds already placed are released on a best-effort basis, and one we fail to release lapses at authorization_expires_at |
capture_failed | Yes | Capture broke part-way. See below |
status on each participant is that person's own share: pending,
authorised, captured, canceled, or refunded. A split is only captured
once every participant is authorised.
The stored status is canceled, one l. Some code paths also accept and
render cancelled, so an exact status === "canceled" comparison is not a
safe test on its own.
capturing and canceling are real persisted states, not just values you
observe in passing. A split that stops in one of them is stuck: both the cancel
path and the expiry job act only on splits that are still pending, so neither
will pick it up and it will not resolve itself. Raise it with support.
What has happened to the money by then depends on which of the two it stopped in, and the two are not the same.
Stuck in canceling. Cancelling only releases holds. It never captures
anything, so nothing has been taken on this path. Holds the run had already
released are gone; holds it had not reached are still live and lapse at
authorization_expires_at.
Stuck in capturing. Capture runs share by share, taking one participant's
money at a time, so a split that stopped part-way through can contain shares
that were genuinely captured. Those participants have been charged, that money
is on your connected account, and it does not lapse or come back on its own.
Shares the run never reached are uncaptured holds, and those do lapse.
So do not read capturing as "nothing happened". Read each participant's own
status, and check their stripe_payment_intent_id values in your own Stripe
dashboard, before you decide whether the order took any money or whether
anything needs refunding by hand.
When capture fails part-way
Capture runs share by share. If it breaks in the middle, SharePay tries to
refund the shares it had already captured and to cancel the holds it had not,
then leaves the split at capture_failed.
Both of those rollback loops are best-effort. Each Stripe call sits in its own
try/catch, and a call that fails is logged and stepped over rather than
retried, so the outcomes below are the intended ones rather than guaranteed
ones.
Two consequences worth designing for:
- Money moved and came back for some participants. You can legitimately see one
split whose participants are a mix of
refundedandcanceled. This is the one path where "nobody is charged" is not literally true. A rollback refund that lands returns the service fee along with the share, but see above: it is best-effort, so do not assume it landed. - No webhook is sent. Neither
checkout_split.paidnorcheckout_split.canceledfires on this path, and nothing fires forcreation_failedeither. A split that emittedcheckout_split.participant_authorisedfor every participant and then went silent needs checking rather than fulfilling.
If a rollback refund itself fails, that participant is left captured while
the split reads capture_failed: their money was taken and has not come back.
If a rollback cancel fails, that participant is left authorised with a live
hold, which is not a charge and lapses at authorization_expires_at. Either
way the participant's own status is the honest one, not the split's. Check
both before you fulfil, and contact support if they disagree.
Chargebacks
There is no disputed status. A chargeback does not overwrite status,
deliberately, because doing so would erase a prior refunded or canceled.
Polling for status === "disputed" will never match.
A chargeback instead sets a disputed_at timestamp, on both the disputed
participant and the split as a whole. It is null until then, so
disputed_at !== null is the test.
disputed_at is returned by GET /api/checkout-splits/:id only. The list
endpoint's summary does not carry it, so check the detail endpoint for any
split you want to confirm is clean.
We also email when a chargeback comes in, so you do not have to poll for it. It goes to the login email of the SharePay account that owns the merchant. There is no separate notifications address and it cannot be redirected. No webhook is sent for chargebacks or for refunds.
Responding to the dispute is yours. The payment settled directly to your Stripe account, so the dispute is between you and the cardholder: you submit the evidence, in your own Stripe dashboard, and you carry the outcome. SharePay does not manage disputes on your behalf.
Errors
| Status | Body | Cause |
|---|---|---|
401 | Missing or invalid Authorization header | No Bearer token, including every call made with an API key |
401 | Invalid token | Expired or bad session token |
403 | {"code":"consent_required","error":"Consent required", ...} | The signed-in account has not accepted the current terms or privacy policy |
404 | Not found | No split with that id |
403 | Forbidden | On detail: you are not the creator or the merchant's owner. Being a participant does not help, because user_id is never set |
403 | Only the creator or the merchant can cancel | Cancel on a split you neither created nor took through your own merchant account |
403 | Only the creator or the merchant can refund | Refund on a split you neither created nor took through your own merchant account |
400 | Only a captured split can be refunded | Refund on a split in any other status |
400 | Checkout split not found | The split disappeared between the lookup and the refund |
There is no error for cancelling a split that cannot be cancelled. Cancel
answers 200 {"status":"canceled"} on any status the caller is allowed to
touch, including the ones where it does nothing at all, so it is the one
response on this page you cannot take at face value. See
Cancelling through the API.