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
Configure webhook URLs per-site in Dashboard → Sites → [Site] → Settings → Notifications:
| Setting | Receives |
|---|---|
n8n_webhook | Errors, error spikes, uptime transitions, revenue silence |
n8n_leads_webhook | Leads, conversions, recovery sequence steps |
webhook_url | Everything not claimed by the two above — for a receiver that has only one URL |
Although the setting keys reference n8n, you can point them at any receiver — Zapier, Make, your
own server, and so on. If your receiver is a single endpoint (a Zapier Catch Hook, say), fill in
only webhook_url and every event arrives there.
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 site is on is Settings → Notifications → Payload contract. 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 |
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 |
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 is version 2 only. A version 1 receiver has a switch written before that event
existed, so sending it a thirteenth 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 n8n_webhook 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 site's webhook_payload setting 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 n8n_leads_webhook when a lead 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 webhook_payload set to full, the lead payload additionally carries the postal address, order notes, the raw submitted fields, the cart line items and the page context:
{
"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": "1 Market St",
"address2": null,
"city": "Kyiv",
"state": null,
"postcode": "01001",
"country": "UA",
"orderNotes": "Leave at the door",
"rawFields": { "billing_email": "jane@example.com" },
"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 — postal address, order notes, raw form fields — 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 address or line items.
n8n integration example
- In n8n, create a new workflow
- Add a Webhook trigger node
- Copy the webhook URL from n8n
- Paste it into the site's settings (
n8n_webhookorn8n_leads_webhook) - Connect downstream nodes — Slack, a CRM contact, a Google Sheets row, and so on
Webhook security
Set a signing secret in Settings → Integrations and every webhook 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.