Skip to main content

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.

These are dashboard actions, not API-key ones

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
amountOptional, pounds as a decimal string or number. Absent means everything still outstanding. More than what is outstanding is 400 over_refund.
reasonOptional, up to 500 characters. Stored and shown on the order; not sent to Stripe.
idempotency_keyOptional, 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.canceled webhook, 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.

Who "the creator" is on a hosted checkout

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:

FieldNotes
idThe split id, the same value webhooks carry as data.split_id
merchant_idYour business
created_byThe account that created the split. For hosted checkout this is the buyer, who set the split up on the hosted page
order_referenceAs supplied, or the hosted session's reference
currencygbp
total_amountThe order total and sum of the shares, before SharePay's merchant fee and Stripe's processing fees
application_fee_totalThe combined customer and merchant fees collected by SharePay
customer_fee_totalThe flat fees added to customer charges for this split
merchant_fee_totalThe fees deducted from the merchant shares for this split
merchant_fee_bpsYour fee rate the day this split was created, in hundredths of a percent (300 = 3%). It never changes afterwards
statusThe lifecycle status, below
stripe_account_idYour Stripe account, where the charges live
authorization_expires_atCreation plus 7 days. The exact deadline the holds are good until
disputed_atnull until a chargeback lands
created_at, updated_atTimestamps
participantsThe array below

On each participant:

FieldNotes
idParticipant row id
user_idAlways null in practice. Neither way of creating a split fills it in, which is why a participant can never open this endpoint — see below
emailAs supplied
amountThis person's share
application_feeThe SharePay service fee this person paid on top of their share, normally 0.20. Their card was charged amount plus this
statusThis person's own status, below
stripe_payment_intent_idThe 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_tokenBuilds this person's pay link
disputed_atnull until this share is disputed
created_at, updated_atTimestamps
Money formats are not consistent across responses

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.

A participant cannot open the detail endpoint

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 thing

It 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:

StatusTerminalMeaning
pendingNoHolds are placed and we are waiting on approvals
capturingNoCapture is in flight
capturedYesEvery share has been taken
cancelingNoCancellation is in flight
canceledYesSharePay 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
refundedYesA captured split was refunded through the API
creation_failedYesThe 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_failedYesCapture 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.

Two spellings of cancelled

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 refunded and canceled. 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.paid nor checkout_split.canceled fires on this path, and nothing fires for creation_failed either. An order that emitted checkout_split.participant_authorised for 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.

A 200 from cancel does not mean anything was cancelled

Cancel 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​

StatusBodyCause
401Missing or invalid Authorization headerNo Bearer token, including every call made with an API key
401Invalid tokenExpired or bad session token
403{"code":"consent_required","error":"Consent required", ...}The signed-in account has not accepted the current terms or privacy policy
404Not foundNo split with that id
403ForbiddenOn detail: you are not the creator and not in the business. Being a participant does not help, because user_id is never set
403Only the creator or the merchant can cancelCancel on an order you neither created nor took through your own business — or you are in it as Finance, which cannot cancel
403Only the creator or the merchant can refundRefund on an order you neither created nor took through your own business — or you are in it as a Member, which cannot refund
400Only a captured split can be refundedRefund on a split in any other status
400Checkout split not foundThe 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.