Webhooks
SharePay posts split lifecycle events to an endpoint on your own server. This is how you find out that an order has been paid, without polling.
SharePay has its own arrangement with Stripe for tracking payments. That is ours, not yours: you cannot point it at your server and you do not need to. The only endpoint you set up is your own, below.
1. Set your endpoint URL
Go to the Developers page in your dashboard, then Webhooks, then Endpoint
URL, for example https://yourstore.com/webhooks/sharepay.
The URL must be https://, must not contain credentials, and must resolve to a
public address. Private, loopback, link-local and .local / .internal hosts
are rejected when you save, so a localhost tunnel URL will not work. Use a
public forwarding hostname when testing.
Create your webhook_secret on the same page; none exists until you do. It
signs every delivery, so treat it like a password. Replacing it there
invalidates the old value immediately. See Webhook
secret for the endpoint.
Test and live webhooks go to separate URLs
Test events are sent only to your test webhook URL, never to your live one,
and are signed with a separate test webhook_secret. Switch the dashboard to
test mode and the same field on the Developers page becomes
Test webhook URL. The first time you save one, its signing secret is
created and shown. With no test URL set, test events are simply not sent.
So a test split can never reach your production order system, even one that
does not check livemode. Point the test URL at a staging server, or at a
separate route on the same server with its own secret.
Both this and your allowed return domain are settable over the API, with a
signed-in session, if you would rather manage them as configuration. Send the
X-SharePay-Mode: test header to set the test URL instead of the live one:
PATCH /api/merchants
{ "webhook_url": "https://yourstore.com/webhooks/sharepay" }
It returns { mode, webhook_url, allowed_domain }, where webhook_url is the
URL for that mode. The first time a test URL is set the response also carries
its new webhook_secret, shown this once. Send either field on its own;
omitting a field leaves it untouched. A bad URL returns
400 Invalid webhook_url: <reason> and a bad domain returns
400 Invalid allowed_domain.
Sending null or "" for webhook_url or allowed_domain unsets it. An
unset allowed_domain rejects every hosted checkout
call, and an unset webhook_url silently stops all deliveries. Do not PATCH a
prefilled form field back without checking it is populated.
2. Events
Every delivery is a POST with Content-Type: application/json.
type | When it fires |
|---|---|
checkout_split.participant_authorised | One participant approved their share. Fires once per person, the first time they approve. If Stripe tells us about the same approval twice, you still only get one. |
checkout_split.paid | Every share has been captured to your Stripe account. This is the event to fulfil the order on. |
checkout_split.canceled | The split was cancelled. Covers a buyer cancelling, you cancelling it yourself, and the hold-expiry job — nothing in the payload distinguishes them, so a cancel you just made in the dashboard arrives at your endpoint looking like any other. Every outstanding hold is released as part of the run; one Stripe will not release is left to lapse at authorization_expires_at instead, and we alert ourselves so it can be cleared by hand. Read each participant's own status rather than assuming nobody paid: a share you had already captured yourself is reported captured, since that money did move. |
| checkout_split.refunded | Money went back to one or more participants: a refund from your server, from the dashboard, or one you made in your own Stripe dashboard. data.amount_refunded is this refund; data.total_refunded is the running total. data.status is partially_refunded or refunded. |
| checkout_session.expired | A hosted checkout session lapsed (two hours after creation) with nobody having chosen how to pay. Your order can be marked abandoned. A session where the buyer did set a split up does not get this; that order ends in checkout_split.paid or checkout_split.canceled. |
There is no event for chargebacks. See Refunds, disputes and order status for how those surface.
checkout_split.canceled payloadMost shares in that payload read canceled, but two others are legitimate and
neither means the run went wrong:
captured— that share had already been captured, normally because you took the payment by hand in your own Stripe dashboard. That customer has paid for an order you have just cancelled. They are not sent our "you were not charged" email, and we alert ourselves to reconcile it with you.authorised— Stripe refused to release that hold, and we then could not read the payment back to find out whether the money is still merely ring-fenced or was actually taken. Rather than guess, we recorded nothing and told that customer nothing, and flagged it for a human. Treat it as unresolved, not as approved-and-waiting.
approved_count counts both of these, since both people did approve. Read each
participant's own status before deciding anybody paid or did not.
Those three are the only events. A split that fails during creation
(creation_failed) or breaks part-way through capture (capture_failed) emits
no webhook, so an order can go permanently silent after its
participant_authorised events without ever reaching paid or canceled.
Do not treat "no checkout_split.canceled yet" as "still on track". A split
that has authorised every participant but has not produced
checkout_split.paid within a few minutes needs
checking, not waiting on.
3. Payload
{
"type": "checkout_split.paid",
"created": 1770000000,
"data": {
"livemode": true,
"split_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"order_reference": "ORDER-123",
"currency": "gbp",
"total_amount": "120.00",
"status": "paid",
"approved_count": 3,
"total_count": 3,
"participants": [
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "0.00", "status": "captured" },
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "0.00", "status": "captured" },
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "0.00", "status": "captured" }
],
"session_token": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"customer_name": "Alex Morgan",
"metadata": { "order_id": "1042", "channel": "web" }
}
}
livemode says whether the event is real money. Every event carries it,
inside data. It is false for a test split or session, and for the sample the
Developers page sends. Test events go only to your test webhook
URL, so a live endpoint only ever
sees livemode: true (apart from the sample you send it yourself). Checking it
is still worth doing before you fulfil an order or record a payment. Events
sent before 27 September 2026 do not have the field, and were all live.
session_token, customer_email, customer_name and metadata are the
hosted checkout session the split came from and whatever you sent when you
created it. All four are null for a split you created yourself on the
dashboard or with POST /api/checkout-splits.
checkout_split.refunded is the split payload above plus the refund:
refund_id, amount_refunded (this refund), reason, total_refunded,
status of partially_refunded or refunded, and each participant's
refunded_amount.
checkout_session.expired carries the session instead of a split:
{
"type": "checkout_session.expired",
"created": 1770000000,
"data": {
"livemode": true,
"token": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"order_reference": "ORDER-123",
"amount": "120.00",
"currency": "gbp",
"customer_name": "Alex Morgan",
"metadata": { "order_id": "1042", "channel": "web" },
"created_at": "2026-09-14T10:02:11.000Z",
"expired_at": "2026-09-14T12:02:11.000Z"
}
}
createdis a Unix timestamp in seconds, and matches thetin the signature header.- Amounts are decimal strings in pounds, not pence.
- Amounts are shares before merchant and Stripe fees. Each customer was charged
their share plus a 20p SharePay service fee, so the matching Stripe charge is
20p larger than the
amounthere. Reconciletotal_amountagainst your order value, not against the sum of the Stripe charges. See what SharePay charges. data.statusis a simplified summary:paidonce the money is taken,canceledonce the order is cancelled,partially_refundedonce any of it has gone back,refundedonce all of it has, andpendingfor everything else. It is not the same as the detailedstatusyou get from the API — see Status flow.approved_countcounts participants who have authorised or been captured, out oftotal_count.participants[].statusis one ofpending,authorised,captured,canceled,refunded.
4. Verify the signature
Every request carries:
X-SharePay-Signature: t=1770000000,v1=<hex>
t is when we sent it, and v1 proves it came from us. To check v1, take
the string <t>.<raw request body>, run an HMAC-SHA256 over it using your
webhook_secret as the key, write the result as hex, and compare.
Use the raw bytes of the body. If you parse the JSON and re-encode it, the spacing changes and the signature will never match.
import crypto from "crypto";
import express from "express";
const app = express();
// Raw body, not express.json(), so the signed bytes survive intact.
app.post(
"/webhooks/sharepay",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-SharePay-Signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return res.status(400).end();
// Reject anything older than 5 minutes, to blunt replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).end();
}
const expected = crypto
.createHmac("sha256", process.env.SHAREPAY_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// Handle event.type here, then acknowledge.
res.status(200).end();
},
);
The timestamp check is your side of the contract. SharePay signs the timestamp but does not enforce a freshness window for you.
5. Respond, and what happens if you do not
Return any 2xx within 10 seconds to acknowledge. Anything else, including
a timeout or a connection error, is recorded as a failed delivery and retried.
Retries
A failed delivery is tried again on a fixed schedule, measured from the previous attempt: a first try and eight retries, nine attempts in all.
| Attempt | Waits after the previous one |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 3 hours |
| 7 | 6 hours |
| 8 | 12 hours |
| 9 | 24 hours |
| (stop) | after the 9th failure, about 47 hours after the first |
After the ninth failure we stop, mark the delivery exhausted, and email the business address on your account (or the owner's login email if there is none) with the event, the endpoint, the attempt count and the last error. The event stays in the delivery log and the Resend button on the Developers page gives an exhausted delivery one more attempt whenever you press it, so nothing is lost once your endpoint is back. Pressing it on a delivery that is still on its schedule tries it now and leaves the schedule as it is.
Every attempt carries two headers alongside the signature:
X-SharePay-Signature: t=1770000000,v1=...
X-SharePay-Event-Id: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
X-SharePay-Attempt: 3
X-SharePay-Event-Id is the same on every attempt of one event and is
different for every event. X-SharePay-Attempt is 1 on the first try.
Each attempt sends the same data with a fresh created timestamp and a
fresh signature, so a replay-window check on t keeps working. It updates
the original log row's status and attempt count rather than adding a new one.
So created is the time of that attempt, not of the original event, and the
log row count is a count of events, not of attempts.
Expect to receive the same event twice, and make sure that is harmless. A
retry delivers an event you may already have processed and simply failed to
acknowledge in time. Store X-SharePay-Event-Id with your handling of it and
ignore a delivery whose id you have already seen.
Order is not guaranteed. A retried checkout_split.participant_authorised
can arrive after the checkout_split.paid it preceded. When it matters, the
status endpoint is the source of truth;
a webhook is a prompt to go and read it.
Inspect deliveries from the API
The log is an endpoint too, with a signed-in session:
GET /api/merchants/webhooks/deliveries?limit=50
limit is clamped to 1 to 100. Returns { deliveries: [...] }, newest first,
each row shaped { id, merchant_id, event_type, event_id, payload, url, status, response_status, error, attempts, max_attempts, next_attempt_at, last_attempt_at, mode, created_at, updated_at }. It lists one mode's
deliveries: live, or test with the X-SharePay-Mode: test header. status is pending (an attempt
is in flight), success, failed (will be retried at next_attempt_at) or
exhausted (we have stopped).
POST /api/merchants/webhooks/deliveries/:id/resend resends one, returning
{ delivery } with the updated row, or 404 Delivery not found.
Since deliveries are never retried for you, sweeping this for
status: "failed" is the automated version of eyeballing the page. Both calls
need a session, not an API key.
6. Reconciling without a webhook
Even with retries, do not make fulfilment depend on the webhook alone. For an order that came through hosted checkout, your server can ask where it stands:
GET /api/merchants/checkout/sessions/<token>/status
x-merchant-api-key: sp_prod_...
That is the token returned alongside checkout_url when you created the
session. The call takes your API key and is limited to 120 requests a minute
per key. A token that is not yours, or does not exist, is a 404. It returns:
{
"token": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "completed",
"amount": "120.00",
"currency": "gbp",
"order_reference": "ORDER-123",
"customer_name": "Alex Morgan",
"metadata": { "order_id": "1042", "channel": "web" },
"created_at": "2026-09-14T10:02:11.000Z",
"expires_at": "2026-09-14T12:02:11.000Z",
"split": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "paid",
"approved_count": 3,
"total_count": 3,
"total_amount": "120.00",
"refunded_amount": "0.00",
"participants": [
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "0.00", "status": "captured" },
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "0.00", "status": "captured" },
{ "email": "[email protected]", "amount": "40.00", "refunded_amount": "0.00", "status": "captured" }
],
"refunds": []
}
}
| Field | Notes |
|---|---|
status | The session's status: open while the buyer can still choose how to pay, completed once the split has captured, expired once the two-hour window passed with no split, canceled if the buyer backed out |
customer_email, customer_name, metadata | Whatever you sent at creation, or null |
split | null until the buyer sets the split up. Its status, approved_count, total_count and participants are exactly what the webhooks carry, built by the same code, so a poll and a webhook can never disagree. refunded_amount is what has been refunded so far; refunds lists every refund on the order, see Refunding from your own server |
status === "completed" is the paid signal. It is set when the split
captures, moments before checkout_split.paid is sent, so the two agree.
A session lapses two hours after creation. A split set up near the end of that
window is still collecting approvals long after the session shows expired,
and split.status is the truth for that order. Only a session that is
expired with split: null is abandoned, and that is exactly when
checkout_session.expired fires.
Store the token against your own order at creation time. It is the only link
between your order reference and the eventual split.
The hosted page itself uses a credential-less lookup of the same token,
GET /api/merchants/checkout/sessions/<token> (no /status), which returns
only what a payer needs: amount, currency, order_reference,
merchant_name, return_url, cancel_url, line_items, customer_email,
status and split_id. It 404s once the session expires. Treat the token as
a secret for that reason: it exposes the order amount, your business name and
the buyer's email to anyone holding it.
For a split you created yourself with
POST /api/checkout-splits there is no session and no
token, so reconciliation is a dashboard operation. See Refunds, disputes and
order status.
7. Send a test event
The Developers page has a Send test event button, enabled once an endpoint
URL is saved. It goes to the endpoint for the mode you are in: your test URL
in test mode, your live URL otherwise. It fires a sample checkout_split.paid with a made-up
split_id of the form cs_test_... and order_reference of TEST-ORDER,
signed with your real secret, and shows the result in the delivery log. Behind
the button is POST /api/merchants/webhooks/test, which returns
{ delivery }, or 400 Set a webhook URL before sending a test event.
Test sends, resends and credential rotations share one bucket of 5 requests per
10 minutes, and it is keyed by IP, not by account. Debugging a handler by
firing test events will lock you out of all three with
429 Too many requests. Please try again later. Point your handler at a
recorded payload for the fast loop, and use the real test send to confirm.