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 runs its own Stripe webhook endpoints internally, on its own Stripe account. They are not part of your integration and you cannot point them at your server. 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.

Your webhook_secret is on the same page. It signs every delivery, so treat it like a password. Rotating it there invalidates the old value immediately.

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:

PATCH /api/merchants
{ "webhook_url": "https://yourstore.com/webhooks/sharepay" }

It returns { webhook_url, allowed_domain }. 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 has authorised their share. Fires once per participant, on the real pending to authorised transition only, so Stripe's duplicate deliveries do not re-fire it.
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.

There is no event for refunds or for chargebacks. See Manage splits 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 PaymentIntent 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 the PaymentIntent could not be read back, so we could not establish whether the money is still ring-fenced or was actually taken. Rather than guess, we write nothing, tell that customer nothing, and alert ourselves. 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": {
"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": "alex@example.com", "amount": "40.00", "status": "captured" },
{ "email": "sam@example.com", "amount": "40.00", "status": "captured" },
{ "email": "jo@example.com", "amount": "40.00", "status": "captured" }
]
}
}
  • 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, which is what you receive. 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 coarse summary of the split: paid once it is captured, canceled once it is cancelled, and pending at every other point. It is not the same value as the internal split status.
  • 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>

v1 is an HMAC-SHA256, hex-encoded, keyed on your webhook_secret, over the string <t>.<raw request body>. Use the raw bytes of the body. Re-encoding the parsed JSON will change the whitespace and the signature will not 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 to acknowledge. Anything else is recorded as a failed delivery.

Failed deliveries are not retried automatically. Every attempt to your configured endpoint, successful or not, is recorded in the delivery log on the Developers page with its response status, and each row has a Resend button that replays the stored payload to your current endpoint URL.

A resend replays the same data, with a fresh created timestamp and a fresh signature, and 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.

Handlers must be idempotent. A resend delivers the same event again, including one you already processed but failed to acknowledge in time. There is no event id in the payload, so key off data.split_id and type.

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, payload, url, status, response_status, error, attempts, created_at, updated_at }. status is success or failed.

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

Because deliveries are not retried, do not make fulfilment depend on the webhook alone. For an order that came through hosted checkout, your server can read the session it created:

GET /api/merchants/checkout/sessions/<token>

That is the token returned alongside checkout_url when you created the session. The call takes no credentials at all and is limited to 60 requests a minute per IP. It returns:

FieldNotes
amountThe order total, a decimal string in pounds
currencygbp
order_referenceAs you sent it
merchant_nameYour business name
return_urlAs you sent it
cancel_urlAs you sent it, or null if you sent none. This is what the hosted page's back-out link points at, and it is offered only before the split is created. See Hosted checkout
statusThe session's status, not the split's. In a 200 this is open or completed
split_idnull until the buyer sets the split up, then the split id. It is the same value webhooks carry as data.split_id

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 404 here is not a failed order

An unknown token returns 404 {"error":"This checkout has expired."}, and so does a session that passed its 2-hour window without capturing. A split created near the end of that window is still collecting approvals long after the session expires, so during that period the token 404s while the order is perfectly alive. Once the split captures, the same token returns 200 with status: "completed" again.

Poll it, but let the webhook and a 404 disagree in your favour: treat 404 as "no answer yet", not as "cancelled".

Store the token against your own order at creation time. It is the only link between your order reference and the eventual split_id, and this endpoint is the only split-state surface reachable from a server holding an API key. Because it needs no credentials, treat the token as a secret: it exposes the order amount and your business name.

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 Manage splits.

7. Send a test event

The Developers page has a Send test event button, enabled once an endpoint URL is saved. 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.