Skip to main content

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.

You do not configure anything in Stripe

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.

An empty value clears the field

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.

typeWhen it fires
checkout_split.participant_authorisedOne 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.paidEvery share has been captured to your Stripe account. This is the event to fulfil the order on.
checkout_split.canceledThe 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.

Participant statuses in a checkout_split.canceled payload

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

Failure sends nothing at all

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_email": "[email protected]",
"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_email": "[email protected]",
"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"
}
}
  • created is a Unix timestamp in seconds, and matches the t in 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 amount here. Reconcile total_amount against your order value, not against the sum of the Stripe charges. See what SharePay charges.
  • data.status is a simplified summary: paid once the money is taken, canceled once the order is cancelled, partially_refunded once any of it has gone back, refunded once all of it has, and pending for everything else. It is not the same as the detailed status you get from the API — see Status flow.
  • approved_count counts participants who have authorised or been captured, out of total_count.
  • participants[].status is one of pending, 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.

AttemptWaits after the previous one
21 minute
35 minutes
415 minutes
51 hour
63 hours
76 hours
812 hours
924 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_email": "[email protected]",
"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": []
}
}
FieldNotes
statusThe 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, metadataWhatever you sent at creation, or null
splitnull 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.

An expired session is not always a dead order

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.

Five sends per ten minutes

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.