Webhooks & events.

Sparx publishes a business event for everything that happens — an order is paid, a content entry is published, a customer is created. Subscribe a URL to the events you care about and Sparx delivers each one as a signed HTTP POST, with retries.

Updated 2026-06-059 min read

Overview

Internally, every Sparx event is published to a per-topic Google Pub/Sub stream and consumed by platform workers (the email worker, the search indexer, cache revalidation, and more). Webhooks expose that same stream to you: register an HTTPS endpoint, list the events you want, and Sparx POSTs each matching event to your URL.

  • Signed. Every delivery carries an X-Sparx-Signature HMAC so you can prove it came from Sparx.
  • At-least-once. A non-2xx response is retried with exponential backoff for up to ~7.5 hours — design your handler to be idempotent.
  • Tenant-scoped. A subscription only ever receives its own tenant’s events; isolation is enforced at the database layer by Row-Level Security.
Webhooks are one consumer; MCP is anotherThe event stream also powers the MCP server, which lets AI agents react to live data directly. Webhooks are the right tool when an external system needs to be notified; reach for MCP when an agent needs to read and write.

Create a subscription

Subscriptions are created via the API today — a tenant admin posts a name, a URL, and the events to listen for. (A dashboard editor is on the roadmap; until it ships, the dashboard points you here.) Creating one returns a signing secret exactly once; store it immediately, because every later read shows only a redacted preview.

The CMS → Webhooks screen, marked ‘coming soon’ — it explains that backend wiring is live and directs you to configure subscriptions via POST /v1/webhooks/subscriptions, and lists the subscribable content events.
The dashboard webhook editor is on the roadmap; today you create subscriptions through the API.
POST/v1/webhooks/subscriptions
curl https://api.sparx.works/v1/webhooks/subscriptions \
  -H "Authorization: Bearer $SPARX_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Content sync",
    "url": "https://example.com/hooks/sparx",
    "events": ["content.entry.published", "content.entry.updated"]
  }'

The response returns the created subscription, including the full secret this one time:

201
{
  "success": true,
  "data": {
    "id": "b7e3a1c2-9f4d-4a1b-8c2e-1f0a9d6b3e57",
    "name": "Content sync",
    "url": "https://example.com/hooks/sparx",
    "events": ["content.entry.published", "content.entry.updated"],
    "active": true,
    "signingSecret": "whsec_3f9a…e21",   // shown ONCE — store it now
    "createdAt": "2026-06-05T17:41:09Z"
  }
}
Store the signing secret nowThe full signingSecret (whsec_…) is only ever returned in this create response. GET and PATCH return a redacted preview (first 8 characters). If you lose it, rotate by deleting and recreating the subscription.

Subscribable events

A subscription names one or more event types in its events array. Today the following events are available for webhook delivery:

EventFires when
content.entry.createdA content entry (page, blog post, …) is created.
content.entry.updatedAn entry’s fields change.
content.entry.publishedAn entry goes live.
content.entry.scheduledAn entry is scheduled to publish later.
content.entry.unpublishedA live entry is taken down.
content.entry.deletedAn entry is deleted.
media.uploadedA media asset is uploaded.
media.processedDerivatives/transforms for an asset finish processing.
redirect.addedA URL redirect is created.
redirect.removedA URL redirect is removed.
The full platform event catalog is much larger (orders, carts, inventory, email, and more). Those events flow on the internal Pub/Sub bus today; additional event families are being opened up for webhook subscription over time. If you need one that isn’t listed above yet, it’s on the roadmap — not missing by design.

The delivery payload

Each delivery is an HTTP POST to your URL with a JSON body and three Sparx headers:

HeaderValue
X-Sparx-EventThe event type, e.g. content.entry.published.
X-Sparx-DeliveryA unique id for this delivery attempt — use it to dedupe.
X-Sparx-Signaturesha256=<hex> — the HMAC of the raw body (see below).
POST /hooks/sparx HTTP/1.1
Host: example.com
Content-Type: application/json
X-Sparx-Event: content.entry.published
X-Sparx-Delivery: 2b9f0c14-7e6a-4d33-9b2f-5a1c8e0d44a1
X-Sparx-Signature: sha256=9d1e7c…f04b

{
  "id": "2b9f0c14-7e6a-4d33-9b2f-5a1c8e0d44a1",   // the delivery id
  "type": "content.entry.published",
  "tenant_id": "a1c2d3e4-…",
  "data": { /* the event payload — shape varies by event type */ },
  "delivered_at": "2026-06-05T17:41:10Z"
}

The body is stable across event types: id (the delivery id), type, tenant_id, delivered_at, and data — the event payload, whose shape depends on the event type.

Verify the signature

Every request is signed with HMAC-SHA256 using your subscription’s signing secret. Compute the same HMAC over the raw request body and compare it to the X-Sparx-Signature header. Reject anything that doesn’t match.

import { createHmac, timingSafeEqual } from "node:crypto";

// IMPORTANT: verify against the RAW request body, before any JSON parse.
// Re-serializing the JSON changes the bytes and the signature won't match.
export function verifySparxWebhook(rawBody: string, header: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = header.replace(/^sha256=/, "");
  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && timingSafeEqual(a, b);
}

// In your handler:
//   const sig = req.headers["x-sparx-signature"];
//   if (!verifySparxWebhook(rawBody, sig, process.env.SPARX_WEBHOOK_SECRET)) {
//     return new Response("bad signature", { status: 400 });
//   }
Always sign the raw bodyVerify the bytes you received, not a re-serialized object. If your framework parses JSON before you can read the raw body, capture the raw payload first (e.g. a raw-body parser) — otherwise the signature will never match. Use a constant-time compare (timingSafeEqual), never ===.

Retries & failure

Respond 2xx quickly to acknowledge a delivery — do the real work afterward, off the request path. Any non-2xx response, a timeout (10s), or a network error schedules a retry with exponential backoff:

AttemptWait before retry
1 → 2~1 minute
2 → 3~5 minutes
3 → 4~15 minutes
4 → 5~30 minutes
5 → 6~1 hour
6 → 7~2 hours
7 → 8~4 hours

After 8 attempts (≈ 7.5 hours total) a delivery is marked failed and no longer retried. Because deliveries are retried, the same event may arrive more than once — make your handler idempotent by deduping on X-Sparx-Delivery.

Manage subscriptions

List your subscriptions (secrets come back redacted):

GET/v1/webhooks/subscriptions

Update a subscription’s name, URL, or event list — or pause it without deleting by setting active: false:

PATCH/v1/webhooks/subscriptions/:id
curl -X PATCH https://api.sparx.works/v1/webhooks/subscriptions/$ID \
  -H "Authorization: Bearer $SPARX_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'

Delete a subscription permanently:

DELETE/v1/webhooks/subscriptions/:id

All changes are recorded in the tenant audit log. Creating, updating, and deleting require an admin role; listing requires viewer.

Full event catalog

For reference, here is the platform’s event taxonomy (defined in @sparx/events), grouped by domain. The subset available for webhook subscription is called out above; the rest are consumed internally — by platform workers and MCP — as modules adopt the shared bus.

Platform & content

  • Tenant: tenant.created
  • Templates: template.install · template.installed · template.install_failed
  • Content: content.entry.created · content.entry.updated · content.entry.published · content.entry.scheduled · content.entry.unpublished · content.entry.deleted · content.revision.created · content_type.upserted
  • Media: media.uploaded · media.processed · media.deleted
  • SEO: redirect.added · redirect.removed
  • Search: search.entity.changed

Commerce

  • Catalog: product.created · product.updated · product.deleted · variant.created · variant.updated · variant.deleted
  • Inventory: inventory.adjusted · inventory.low · inventory.depleted
  • Cart & checkout: cart.created · cart.updated · cart.abandoned · cart.recovered · checkout.started · checkout.completed · checkout.expired
  • Orders: order.placed · order.paid · order.fulfilled · order.delivered · order.cancelled · order.refunded · order.payment_failed
  • Subscriptions: subscription.created · subscription.renewed · subscription.payment_failed · subscription.paused · subscription.resumed · subscription.cancelled
  • Returns: return.requested · return.approved · return.received · return.refunded
  • Reviews: review.submitted · review.published · review.flagged
  • Providers: provider.installed · provider.uninstalled · provider.health_changed
  • Gift cards & credit: giftcard.issued · giftcard.redeemed · storecredit.granted · storecredit.spent
  • Configurator: configuration.requested · configuration.quoted · configuration.accepted

Email

  • Sending: email.send · email.domain.verified

Email engagement and delivery events (opens, clicks, bounces, broadcast lifecycle) are tracked within the Email module rather than the cross-module bus — see the Email module docs for those.

Was this page helpful?