Webhooks
StorePilot can send an HTTP POST request to a URL of your choice when something happens on your site — an error, a captured lead, a paid checkout, a monitor going down — so you can trigger any downstream automation.
Configuration
A webhook is a notification destination with the Webhook channel: add one in Settings → Notifications, give it a name, paste the receiver's URL and tick the events it should receive. You can have several — an n8n flow for errors, another for carts, a Zapier hook for orders — and each one receives exactly what it ticked.
| Event you tick | What the webhook receives |
|---|---|
| Errors | error.created (v1: new_error) — a new error, or a resolved one coming back |
| Error spikes | error.spike |
| Leads | lead.created for a contact form |
| Abandoned checkouts | lead.created for a checkout, and every sequence.step of your cart recovery |
| Paid orders | lead.created for a paid checkout, and lead.converted (version 2 only) |
| Uptime | uptime.down, uptime.up, uptime.slow, uptime.fast_again, uptime.cert_expiring |
| Revenue silence | revenue.silence and revenue.resumed |
| Chats | conversation.started (version 2 only) — who started it, where, how many messages, and a link; never the transcript |
If your receiver is a single endpoint (a Zapier Catch Hook, say), add one webhook destination and tick every event.
Each webhook destination carries its own Payload version, Payload mode and signing secret, so one site can feed a version-1 flow built last year and a version-2 hook built today.
Two contracts
Version 2 is the current shape: one envelope for every event. Version 1 is what sites created before September 2026 are still on, and they stay on it — the automations built against it are your work, and we do not change the shape underneath them.
Which one a webhook receives is its destination's Payload version. A new destination starts on version 2; destinations carried over from the old settings kept the version their site was on. Switching changes the shape of every payload, so whatever you have built against the current one needs updating first.
Version 2 envelope
{
"version": 2,
"id": "0e0b1f6a-8f6a-4a5f-9a3e-3d5c1b2a7c11",
"event": "lead.created",
"occurredAt": "2026-09-20T10:00:00.000Z",
"sentAt": "2026-09-20T10:00:01.412Z",
"site": { "id": 7, "name": "My Shop", "domain": "https://shop.example" },
"links": { "dashboard": "https://dashboard.store-pilot.net/sites/7/ecommerce/carts/42" },
"data": { "lead": { "id": 42 } }
}
idis an idempotency key. Delivery is fire-and-forget with no retries, but a receiver behind a queue can still see the same body twice; de-duplicate on this.occurredAtis when the thing happened.sentAtis when we posted about it. They differ when an alert is deferred — a checkout alert waits for a sweep so the basket is judged at its real size rather than at its smallest.links.dashboardisnullon a deployment with no dashboard address configured. The event still arrives; only the link is missing.dataholds the resource, under the same key the v1 body used (lead,error,spike,silence,uptime,sequence/step).
Version 2 events
A closed list, all of the form <noun>.<verb>, so a Switch node can be written once:
| Event | When |
|---|---|
lead.created | A lead or checkout was captured |
lead.converted | A checkout was paid — cancel an abandoned-cart flow on this |
conversation.started | A visitor started a chat in the shop's widget — data.conversation names who, the page, the message count and the provider; the transcript stays in the dashboard |
error.created | A new error, or a resolved one re-opening |
error.spike | Errors this hour are well above the trailing average |
uptime.down / uptime.up | A monitor went down, or came back |
uptime.slow / uptime.fast_again | A monitor crossed its slow threshold, or returned under it |
uptime.cert_expiring | A monitor's HTTPS certificate has 14, then 3, days left — each line once |
revenue.silence / revenue.resumed | Traffic arrived and money did not, and then did |
sequence.step | A cart-recovery step is due — you send the message, we never do |
webhook.test | The drill, from the Send test button |
lead.converted, uptime.cert_expiring and conversation.started are version 2 only. A version 1 receiver has a switch written before that event
existed, so sending it a new name would be a body it can only ignore or mis-handle.
Version 1 envelope
Three top-level fields: the event name, the numeric siteId, and the resource object. The
site's domain is not included, and neither is a timestamp or a link.
Event names are new_error, new_lead, error_spike, uptime_down, uptime_up,
uptime_slow, uptime_fast_again, revenue_silence, revenue_resumed, sequence_step and
test. The uptime events flatten their fields onto the top level rather than nesting them.
Payload: Error webhook
POSTed to webhook destinations subscribed to Errors when a new error fires (or a resolved one re-opens). The default (summary) body:
{
"event": "new_error",
"siteId": 1,
"error": {
"id": 42,
"fingerprint": "9f2c1a7b4e6d8c0a",
"message": "Uncaught TypeError: Cannot read properties of null (reading 'total')",
"severity": "error",
"type": "js",
"source": "frontend",
"status": "new",
"environment": "production",
"release": "1.4.2",
"occurrenceCount": 1,
"lastSeenAt": "2026-01-15T10:00:00.000Z"
}
}
Notes on the fields:
sourceis"frontend"or"backend"— not a script URL. The stack trace's file paths live instackTrace(full mode below).severityis"notice" | "warning" | "error" | "critical";typeis"js" | "ajax" | "promise" | "php".environmentandreleaseare the site's own build strings, and may benullwhen the reporter did not state them.occurrenceCountis how many times the fault has been reported, andid/fingerprintidentify the aggregated issue — one row per fingerprint, not per occurrence.- The identity of the visitor who happened to report it (
visitorId,ipAddress) is deliberately not sent: anErrorrow is an aggregate over everyone who hit it, so those columns name only the latest reporter and mean nothing to a receiver.
Full mode
Set the destination's Payload mode to full to add the two unbounded fields the summary omits:
{
"event": "new_error",
"siteId": 1,
"error": {
"id": 42,
"fingerprint": "9f2c1a7b4e6d8c0a",
"message": "Uncaught TypeError: Cannot read properties of null (reading 'total')",
"severity": "error",
"type": "js",
"source": "frontend",
"status": "new",
"environment": "production",
"release": "1.4.2",
"occurrenceCount": 1,
"lastSeenAt": "2026-01-15T10:00:00.000Z",
"context": { "url": "https://mystore.com/shop/", "referrer": "" },
"stackTrace": "TypeError: Cannot read properties of null\n at submit (https://mystore.com/app.min.js:1:2481)"
}
}
Payload: Lead webhook
POSTed to webhook destinations subscribed to Leads, Abandoned checkouts or Paid orders — whichever this lead is — when it is captured. The default (summary) body carries what an automation acts on — contact details and cart totals — and nothing else:
{
"event": "new_lead",
"siteId": 1,
"lead": {
"id": 17,
"sessionId": "8b1e2f3a-4c5d-6e7f-8a9b-0c1d2e3f4a5b",
"status": "abandoned",
"category": "checkout",
"leadType": "abandoned_cart",
"source": "woocommerce_checkout",
"formId": null,
"formName": null,
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Smith",
"phone": "+380501234567",
"company": null,
"cartItemCount": 2,
"cartTotal": "49.98",
"currency": "USD",
"errorCount": 0,
"createdAt": "2026-01-15T10:05:00.000Z"
}
}
Notes on the fields:
sessionIdis the per-visit key this product joins on, and the only handle a receiver can use to refer back to the lead. The durable cross-visitvisitorIdis deliberately not sent — it identifies a person across visits, which an automation has no need for.cartItemCountis the number of line items; the items themselves (cartData) are full-mode only.cartTotalis a display string;cartTotalAmount(full mode) is the numeric amount.wpUserId,ipAddressandvisitorIdare never sent, in any mode.
Full mode
With Payload mode set to full, the lead payload additionally carries the city, region and country, order notes, the raw submitted fields, the cart line items and the page context. The street address and the postcode are never collected — they are replaced by the marker [hidden] before a lead is stored — so address1, address2 and postcode only ever read [hidden] or null:
{
"event": "new_lead",
"siteId": 1,
"lead": {
"id": 17,
"sessionId": "8b1e2f3a-4c5d-6e7f-8a9b-0c1d2e3f4a5b",
"status": "abandoned",
"category": "checkout",
"leadType": "abandoned_cart",
"source": "woocommerce_checkout",
"formId": null,
"formName": null,
"email": "jane@example.com",
"firstName": "Jane",
"lastName": "Smith",
"phone": "+380501234567",
"company": null,
"cartItemCount": 2,
"cartTotal": "49.98",
"currency": "USD",
"errorCount": 0,
"createdAt": "2026-01-15T10:05:00.000Z",
"address1": "[hidden]",
"address2": null,
"city": "Kyiv",
"state": null,
"postcode": "[hidden]",
"country": "UA",
"orderNotes": "Leave at the door",
"rawFields": { "billing_email": "jane@example.com", "billing_address_1": "[hidden]" },
"cartData": [ { "product_id": 123, "name": "Premium Widget", "qty": 2, "line_total": 49.98 } ],
"cartTotalAmount": "49.98",
"pageUrl": "https://mystore.com/checkout/",
"referrer": "https://mystore.com/shop/"
}
}
full mode ships the shop's own customer data — order notes, raw form fields, line items — to a third-party URL, so it is opt-in. summary (the default) is enough for the common abandoned-cart and CRM flows; choose full only when your automation genuinely needs the order notes, the raw fields or the line items.
n8n integration example
- In n8n, create a new workflow
- Add a Webhook trigger node
- Copy the webhook URL from n8n
- In StorePilot, Settings → Notifications → Add destination, choose Webhook, paste the URL and tick the events the flow handles
- Connect downstream nodes — Slack, a CRM contact, a Google Sheets row, and so on
Webhook security
Set a signing secret on a webhook destination and every request to it is signed. The request then carries:
X-StorePilot-Signature: sha256=<hex>
On version 2 the request also carries when it was signed:
X-StorePilot-Timestamp: 1789916219
and the signed material is <timestamp>.<raw body>. A signature over the body alone never ages,
so a captured request replays for ever against a receiver that checks only the digest; with the
timestamp inside it, your receiver can reject anything older than a few minutes.
// Version 2
const stamp = req.header('X-StorePilot-Timestamp');
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${stamp}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(stamp)) < 300;
const ok = fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.header('X-StorePilot-Signature')));
On version 1 the signed material is the raw body by itself and no timestamp header is sent — unchanged, so a verifier written against it keeps working:
// Version 1
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.header('X-StorePilot-Signature')));
Use the same secret value here and in your receiver — StorePilot never sends it back, only the signature. A site with no secret set receives no signature header, exactly as before. (Putting a secret in the URL query string is not the way — it is logged by proxies and receivers; the header is.)
Outbound webhook URLs are also SSRF-checked at send time, so a target must resolve to a public address.
Retry behaviour
Webhook delivery is fire and forget — if the target URL is unavailable, the notification is not retried. For reliable delivery, use n8n's own retry mechanism or point the webhook at a queue you control.