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​

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 tickWhat the webhook receives
Errorserror.created (v1: new_error) — a new error, or a resolved one coming back
Error spikeserror.spike
Leadslead.created for a contact form
Abandoned checkoutslead.created for a checkout, and every sequence.step of your cart recovery
Paid orderslead.created for a paid checkout, and lead.converted (version 2 only)
Uptimeuptime.down, uptime.up, uptime.slow, uptime.fast_again, uptime.cert_expiring
Revenue silencerevenue.silence and revenue.resumed
Chatsconversation.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 } }
}
  • 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
conversation.startedA 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.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
uptime.cert_expiringA monitor's HTTPS certificate has 14, then 3, days left — each line once
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, 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:

  • 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 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:

  • 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 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​

  1. In n8n, create a new workflow
  2. Add a Webhook trigger node
  3. Copy the webhook URL from n8n
  4. In StorePilot, Settings → Notifications → Add destination, choose Webhook, paste the URL and tick the events the flow handles
  5. 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.