Skip to main content

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:

SettingReceives
n8n_webhookErrors, error spikes, uptime transitions, revenue silence
n8n_leads_webhookLeads, conversions, recovery sequence steps
webhook_urlEverything 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 } }
}
  • id is 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.
  • occurredAt is when the thing happened. sentAt is 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.dashboard is null on a deployment with no dashboard address configured. The event still arrives; only the link is missing.
  • data holds 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:

EventWhen
lead.createdA lead or checkout was captured
lead.convertedA checkout was paid — cancel an abandoned-cart flow on this
error.createdA new error, or a resolved one re-opening
error.spikeErrors this hour are well above the trailing average
uptime.down / uptime.upA monitor went down, or came back
uptime.slow / uptime.fast_againA monitor crossed its slow threshold, or returned under it
revenue.silence / revenue.resumedTraffic arrived and money did not, and then did
sequence.stepA cart-recovery step is due — you send the message, we never do
webhook.testThe 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:

  • source is "frontend" or "backend"not a script URL. The stack trace's file paths live in stackTrace (full mode below).
  • severity is "notice" | "warning" | "error" | "critical"; type is "js" | "ajax" | "promise" | "php".
  • environment and release are the site's own build strings, and may be null when the reporter did not state them.
  • occurrenceCount is how many times the fault has been reported, and id/fingerprint identify 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: an Error row 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:

  • sessionId is 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-visit visitorId is deliberately not sent — it identifies a person across visits, which an automation has no need for.
  • cartItemCount is the number of line items; the items themselves (cartData) are full-mode only.
  • cartTotal is a display string; cartTotalAmount (full mode) is the numeric amount.
  • wpUserId, ipAddress and visitorId are 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

  1. In n8n, create a new workflow
  2. Add a Webhook trigger node
  3. Copy the webhook URL from n8n
  4. Paste it into the site's settings (n8n_webhook or n8n_leads_webhook)
  5. 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.