Refunds, disputes and order status
Everything that happens to an order after the buyer has set it up: refunding it, reading what actually happened to the money, and dealing with a chargeback.
Apart from refunding and
cancelling from your own server, every call
on this page needs a person signed in to SharePay: these are what the
dashboard itself does when you click something. A script on your own server
calling the /api/checkout-splits routes gets 401 Missing or invalid Authorization header every time.
If you only want your server to know when an order is paid, you want webhooks, not these. Who may call what is in the API reference.
Refunding from your own server
With your API key, and for any order that came through hosted checkout:
POST /api/merchants/checkout/splits/<split_id>/refund
x-merchant-api-key: sp_prod_...
| Field | |
|---|---|
amount | Optional, pounds as a decimal string or number. Absent means everything still outstanding. More than what is outstanding is 400 over_refund. |
reason | Optional, up to 500 characters. Stored and shown on the order; not sent to Stripe. |
idempotency_key | Optional, up to 255 characters, unique per refund you intend. A repeated call with the same key returns the stored result; it never refunds twice. |
A 200 returns the refund and the order after it:
{
"refund_id": "c0a8…",
"split_id": "3fa8…",
"amount": "12.50",
"refunded": "12.50",
"total_refunded": "12.50",
"total_amount": "120.00",
"status": "partially_refunded",
"reason": "One item out of stock",
"participants": [
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "4.17", "status": "captured" },
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "4.17", "status": "captured" },
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "4.16", "status": "captured" }
]
}
How a partial amount is spread. Across the participants who paid, in
proportion to what each still has outstanding, in whole pence, so the slices
add up exactly (£12.50 over three £40 shares is £4.17, £4.17, £4.16). Each
slice is its own Stripe refund on your account, and a participant's status
stays captured until their whole share has gone back.
The fee. Each customer's card was charged their share plus the 20p service fee. The slice that completes a share is refunded together with that customer's 20p, in the same Stripe refund, and SharePay's fee is reversed on that call, so a fully refunded customer is made whole and you never fund our fee. A slice that leaves part of a share outstanding returns only that slice; the fee stays until the share is complete, exactly as when you refund in Stripe directly. See Who pays the 20p.
A failure part-way. Refunds are issued one participant at a time and each
is real the moment Stripe accepts it. If Stripe refuses one, the call returns
409 { "code": "partial_failure", "refund": {...} } where refund.refunded is
what went through and refund.failed_participant says who failed and why.
Nothing is unwound. Call again for the remainder (with a new
idempotency_key); the same key replays the same partial result.
Other errors. 404 for a split that is not yours or does not exist.
409 not_refundable for an order that has not been paid, was cancelled, or is
already fully refunded. 409 refund_in_progress while another refund on the
same order is still being processed, or when the order changed between your
read and your call; read it again and retry. 409 idempotency_key_reused if
the key was already used with a different amount or reason.
History. Every refund on an order, yours or one made in your own Stripe
dashboard, is listed under split.refunds on the
status endpoint, each as
{ refund_id, amount, refunded, status, reason, actor, created_at }, where
actor is api_key, dashboard:<user> or stripe and status is
in_progress, complete or partial. Every refund, from any of the three,
sends a checkout_split.refunded webhook.
Cancelling from your own server
With your API key, for an order that came through hosted checkout and is still collecting:
POST /api/merchants/checkout/splits/<split_id>/cancel
x-merchant-api-key: sp_prod_...
No body. Only a split whose status is pending can be cancelled. When it is,
the call answers 200 {"status":"canceled"} and:
- every payer's hold is released, so nobody is charged;
- every payer is emailed that the order was cancelled;
- you get a
checkout_split.canceledwebhook, so your own handler will see the cancellation you just made.
Calling it again on an order that is already cancelled also answers
200 {"status":"canceled"}, so retrying after a timeout is safe.
Errors. 404 for a split that is not yours, belongs to the other mode
(a test key cannot cancel a live order), or does not exist.
409 { "code": "not_pending", "status": "captured" } for any other status,
naming the one it is in. A captured order has already been paid, so
refund it instead. Unlike the dashboard
route below, this one never answers 200 for a cancel that did not happen.
Refunding an order from the dashboard
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. It takes no amount; partial
refunds are an API-key operation.
It fails with 400 Only a captured split can be refunded unless the split is
captured. To stop an order before it captures, cancel
it while it is still pending.
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.
Refund needs the Owner, Admin or Finance role, or the account that created the split. A Member cannot refund. See Team access and roles.
Refund and cancel both accept the account that created the split or an account in the business it was taken against.
For a split you made yourself, in the dashboard or via
POST /api/checkout-splits, the creator is you. For one
created through hosted checkout, the creator is the
buyer, because the buyer is who sets the split up on the hosted page. You
are still the business it was taken against, so both calls work for you either
way, and the dashboard's Cancel and Refund buttons work on hosted-checkout
orders.
Those buttons are drawn from the split's status alone: Cancel shows on a
pending split, Refund on a captured one. The created_by field on the
detail response tells you which kind of split you are looking at, and who the
buyer was.
Returning one person's share
To return one particular person's share rather than a pro-rata slice, refund
their charge in your own Stripe dashboard. Each participant's
stripe_payment_intent_id, on
GET /api/checkout-splits/:id, is a charge on your
own Stripe account.
When Stripe reports that refund back to us we record it as if it were our own:
the participant's refunded_amount follows what Stripe has returned, their
status moves to refunded once the whole charge is back, the split moves to
partially_refunded (or refunded once every share is back), and a history
row with actor: "stripe" appears under split.refunds.
Who pays the 20p
Be aware of what you are refunding in Stripe. Each of those charges is the share plus the customer's 20p service fee.
You are not left funding our fee on a full refund. Refund one of these charges in full and SharePay reverses its own 20p, so the fee comes back out of our balance rather than yours — exactly as on the API refund above. 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.
Chargebacks
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.
There is no disputed status, so do not look for one. A chargeback leaves
status alone on purpose — overwriting it would wipe out the fact that an
order was already refunded or cancelled. Checking for status === "disputed"
will never find anything.
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
order 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 business. There is no separate notifications address and it cannot be redirected. No webhook is sent for chargebacks or for refunds.
Reading a split
GET /api/checkout-splits returns your orders, newest first, as summaries:
id, order_reference, currency, status, total_amount, created_at,
and their participants (id, email, amount, status, pay_token). Any
role may read it. A signed-in user in no business gets [] rather than an
error.
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 business |
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 and sum of the shares, before SharePay's merchant fee and Stripe's processing fees |
application_fee_total | The combined customer and merchant fees collected by SharePay |
customer_fee_total | The flat fees added to customer charges for this split |
merchant_fee_total | The fees deducted from the merchant shares for this split |
merchant_fee_bps | Your fee rate the day this split was created, in hundredths of a percent (300 = 3%). It never changes afterwards |
status | The lifecycle status, below |
stripe_account_id | Your Stripe account, where the charges live |
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 way of creating a split fills it in, which is why a participant can never open this endpoint — see below |
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 order. 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.
In theory a participant can open it. In practice none ever can. We match them
by user_id, and user_id is never filled in — both ways of creating a split
record only the participant's email address. So a participant who is neither
the creator nor in your business always 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 order list. Use GET /api/checkout-splits for that.
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.
We store canceled, with one l. But cancelled with two also turns up in
places, so do not rely on an exact status === "canceled" match — accept both
spellings.
capturing and canceling are meant to be brief, but an order can get stuck
in one. Nothing will rescue it: both cancelling and our automatic clean-up only
ever touch orders still marked pending, so a stuck one stays stuck until
somebody looks at it. Contact 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 nobody has been charged 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 in your Stripe account, and it does not expire 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.
That tidy-up is attempted, not guaranteed. Each refund and each release is tried once; one that fails is recorded and skipped rather than retried. So treat everything below as what we aim for, not what is certain.
Two consequences worth designing for:
- Money moved and came back for some participants. You can legitimately see one
order 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. An order 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.
Cancelling an order
POST /api/checkout-splits/:id/cancel acts only on a split that is still
pending, where it releases the holds. It never captures anything, so this is
not a path that can take money.
Cancel needs the Owner, Admin or Member role, or the account that created the split. A Finance colleague cannot cancel. That is the mirror image of refund above: cancel can only release holds, so it is safe to leave with the people running orders, while refund moves settled money and sits with the people who own the books.
200 from cancel does not mean anything was cancelledCancel answers 200 {"status":"canceled"} whether or not it cancelled
anything. It only ever acts on a pending order. On any other status it
quietly does nothing and still reports success — that covers capturing,
captured, canceling, capture_failed, creation_failed, canceled and
refunded.
The case worth being blunt about is captured. You get a cheerful
200 {"status":"canceled"}, the money stays taken, and no refund has
happened.
So treat the response as "we received your request", not as "it worked". Read
the order back with GET /api/checkout-splits/:id and check status actually
says 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.
Cancelling an order yourself sends you a checkout_split.canceled
webhook, because that event goes to the business the
split belongs to. Your own handler will see the cancellation you just made,
so make sure it copes with that rather than treating every delivery as news.
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 and not in the business. Being a participant does not help, because user_id is never set |
403 | Only the creator or the merchant can cancel | Cancel on an order you neither created nor took through your own business — or you are in it as Finance, which cannot cancel |
403 | Only the creator or the merchant can refund | Refund on an order you neither created nor took through your own business — or you are in it as a Member, which cannot refund |
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 |
409 | {"code":"business_selection_required", ...} | On the list endpoint, when your login is in more than one business. See Being in more than one business |
On the dashboard route there is no error for cancelling an order that cannot
be cancelled (the API-key route does
answer 409 not_pending). 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 an
order.