Docs

Running a store

DocsRunning a store

Webhooks

6 sections19 min readdocs/webhooks.md

On this page6 sections
  1. 1Async and sync
  2. 2Signing
  3. 3Answering a sync webhook
  4. 4Retries and the queue
  5. 5Scope
  6. 6Event reference

Getting told when something happens in the store - and, for a few events, getting a say in whether it happens at all.

A webhook is an HTTPS endpoint you own. Skelet POSTs a signed JSON payload to it when a subscribed event fires. If instead of observing an event you want to answer a question - what does shipping cost, how much tax is owed - you want a remote provider, not a webhook.

Async and sync

This is the first decision, and the two behave very differently.

Async is the normal kind. It fires after the operation commits, cannot affect it, and is retried on failure. Use it for “tell my ERP an order was placed.”

Sync fires during the operation, before it commits. The emitting operation blocks waiting for your response, and your reply can reject the operation outright or, for some events, modify it. Sync webhooks are never retried - there is nothing to retry into, since the operation has already been decided. Use it for “check this order against our fraud list before it is accepted.”

Sync webhooks are, in effect, business logic living on your server. Treat their latency as checkout latency.

Signing

Identical to the remote-provider scheme, so one verification helper covers both - only the header names differ.

signature = HMAC_SHA256(secret, X-Webhook-Timestamp + "." + raw_request_body)

Sent as X-Webhook-Signature-256: sha256=<hex>, alongside X-Webhook-Event, X-Webhook-Timestamp (unix seconds), X-Webhook-Api-Version, and any custom headers you configured. The signing secret is shown once when the webhook is created.

The timestamp is inside the signed string specifically so you can enforce a replay window - five minutes is the recommended tolerance, and without that check a captured delivery stays replayable forever. Hash the raw bytes before parsing, and compare in constant time. There’s a worked example, in three languages, under Verifying the signature in the remote provider guide - the only change is the header prefix.

Custom headers you set ride along, but they can never shadow the dispatcher’s own; reserved names (Content-Type, X-Webhook-*) are rejected when you save the webhook.

Answering a sync webhook

Return 2xx and the operation proceeds. Return anything else and what happens depends on the webhook’s failure policy:

  • fail_closed (the default) - the emitting operation aborts. Your endpoint is a gate, so if it is down, orders stop.
  • fail_open - the failure is logged and the operation continues. Your endpoint is advisory.

The distinction that matters: fail_open forgives broken endpoints - transport errors, timeouts, non-2xx, malformed bodies. It does not forgive a deliberate veto. A 2xx response whose body says {"reject": true} aborts the operation under both policies, because that is you making a decision rather than your server failing.

Include a reason alongside it; it propagates back to the emitting service and into the error the shopper or admin sees.

Some sync events also accept modifications in the response body - the fields differ per event, because each event decides how to fold a response in. Cart line events take unitPriceCents and quantity overrides, for instance. Events that don’t implement this treat sync delivery as a blocking notification: you can veto, but not edit.

A repeatedly failing endpoint trips a circuit breaker. While it is open, deliveries short-circuit - which under fail_closed means the operation is refused with an error telling you to fix the endpoint or switch the webhook to fail_open.

Retries and the queue

Async deliveries that fail are retried with backoff. Every attempt - including retries - writes its own delivery record with the request, the response, status and duration, visible in the admin along with success/failure counts and the timestamps of the last delivery, last success and last failure. When retries are exhausted the delivery lands in the dead-letter list, where you can inspect and replay it after fixing the endpoint.

Each attempt has its own timeout: 5s for sync, 10s for async by default, overridable per webhook up to 30s. Remember that a sync timeout is time the shopper spends waiting.

Scope

A webhook is either global - it fires for every channel’s events - or bound to one channel, in which case it only fires for events carrying that channel. Events that aren’t channel-scoped only ever reach global webhooks.

Target URLs are checked against an SSRF guard, so internal and loopback addresses are refused.

Event reference

Event types are registered in code by the domain that emits them, and this table is generated from that registry by skelet gen.

The two pools are disjoint. Each event is either sync or async, and a webhook may only subscribe to events of its own kind - a sync webhook subscribing to an async event is rejected when you save it. So the kind column isn’t a hint, it’s which sort of webhook can receive that event at all.

You can subscribe with wildcards: * for every event of the webhook’s kind, or <prefix>.* for a namespace, e.g. order.*. A wildcard is validated against your kind’s pool only, so order.* is valid on both a sync and an async webhook even though the order. namespace contains some of each - each one receives only its own half.

Enabled plugins emit their own, declared in their manifest and registered at boot. Those are runtime additions belonging to the plugin, so they are not in the core catalogue below. The event picker in Settings shows what your install actually has.

113 event types - 92 async, 21 sync.

attribute

EventKindWhat it means
attribute.deletedasyncFires after an attribute is deleted, along with every value products held for it. Payload includes attributeId and slug.
attribute.updatedasyncFires after an attribute, its values, its product-type assignments or its flags change. Payload includes attributeId, slug and changedFields.

auth

EventKindWhat it means
auth.login.attemptingsyncFires before credentials are verified, for both admin and customer logins. Reject to block the attempt (IP allow/deny lists, geo-anomaly rules, external rate limits). Payload includes subject (admin|customer), email, ip, userAgent, and channelId (null for admin). Response schema: {“reject”: bool, “reason”: string}.
auth.login.failedasyncFires after a failed login attempt. Subscribe for brute-force / credential-stuffing alerting.
auth.login.succeededasyncFires after a successful login. Payload includes the actor + IP; no credentials.
auth.logoutasyncFires after a user (admin or customer) logs out a single session. Payload includes subject, subjectId, and allDevices (false for a single-device logout).
auth.password.reset.completedasyncFires after a password is successfully changed via the reset-token flow (distinct from customer.password_changed, the logged-in self-service change). Payload includes the subject, subjectId, email, and channelId. Subscribe to send a ‘your password was changed’ notice or revoke downstream sessions.
auth.password.reset.requestingsyncFires before a password-reset token is minted. Reject to refuse the reset (e.g. SSO-only accounts that must not reset via email). A rejection is applied silently - the caller sees the same response as an unknown email - to preserve the enumeration defense. Response schema: {“reject”: bool, “reason”: string}.
auth.refresh.issuedasyncFires when a refresh token is minted - on login and on every refresh rotation, for both admin and customer. Payload includes subject, subjectId, sessionId (admin only), ip, userAgent. Chatty; subscribe for session-count metrics or concurrent-session caps.
auth.refresh.revokedasyncFires when a refresh token is revoked. Payload includes subject, subjectId, and reason (rotation | logout | logout_other_devices | revoke_all). Subscribe to cascade session revocation to dependent systems.

backinstock

EventKindWhat it means
backinstock.requestedasyncFires when a shopper subscribes to a restock alert for a product. Payload: ChannelID, ProductID, SKU, Email, CustomerID (null for guests).

blog

EventKindWhat it means
blog.post.content.changedasyncFires after a post’s body/content changes. Can be chatty during editing - subscribe only if you mirror content.
blog.post.createdasyncFires after a blog post is created (draft or published).
blog.post.deletedasyncFires after a blog post is deleted.
blog.post.listing.changedasyncFires when a post’s per-store listing changes.
blog.post.publishedasyncFires after a blog post moves to status=published - admin click, scheduler promotion, or any other path. Useful for static-site rebuild triggers.
blog.post.publishingsyncFires before a blog post moves to status=published - admin click or scheduler promotion. A scheduled post that is rejected stays scheduled and is asked again on the next run. Response schema: {“reject”: bool, “reason”: string}.
blog.post.unpublishedasyncFires after a blog post moves out of status=published into draft or archived.
blog.post.updatedasyncFires after a blog post’s metadata is updated.

bundle

EventKindWhat it means
bundle.createdasyncFires after a bundle product is created. Payload includes productId and SKU.
bundle.deletedasyncFires after a bundle product is deleted. Payload includes productId and SKU.
bundle.updatedasyncFires after a bundle product is updated. Payload includes productId, SKU, and changedFields.

cart

EventKindWhat it means
cart.bundle.addedasyncFires after a bundle is added to a cart (the bundle counterpart to cart.line.added). Payload includes cartId, the bundle SKU, and the picked components.
cart.bundle.addingsyncFires before a bundle line is added. Reject to block the add. Response schema: {“reject”: bool, “reason”: string, “unitPriceCents”: int, “quantity”: number}. unitPriceCents overrides the bundle’s summed component price.
cart.clearedasyncFires after every line is removed from a cart. Payload includes cartId, channelId, customerId, anonId. Response ignored (async).
cart.line.addedasyncFires after a line is added to a cart. Payload includes cartId, channelId, customerId (null for guests), anonId, lineId, productSku, unitPrice, quantity. Response ignored (async).
cart.line.addingsyncFires before a simple line is added. Reject to block the add. Response schema: {“reject”: bool, “reason”: string, “unitPriceCents”: int, “quantity”: number}. unitPriceCents/quantity override the line being added (external dynamic pricing, per-customer clamps); omit to keep the input.
cart.line.removedasyncFires after a line is removed from a cart. Payload includes cartId, lineId. Response ignored (async).
cart.line.removingsyncFires before a line is removed. Reject to block the removal. Response schema: {“reject”: bool, “reason”: string}.
cart.line.updatedasyncFires after a cart line’s quantity changes. Payload includes cartId, lineId, quantity. Response ignored (async).
cart.line.updatingsyncFires before a line quantity changes. Reject to block the change. Response schema: {“reject”: bool, “reason”: string, “quantity”: number}. quantity overrides the absolute new line quantity.
cart.mergedasyncFires after a guest cart merges into a customer cart on login (guest→customer conversion). Payload includes customerCartId, guestCartId, customerId, channelId. Response ignored (async).
cart.mergingsyncFires before a guest cart is merged into a customer’s cart at login. Response schema: {“reject”: bool, “reason”: string}.
cart.offer.expiredasyncFires once when an offer reaches its expiry without being completed. Payload includes cartId, channelId, customerId, email, expiresAt. Response ignored (async).
cart.offer.openedasyncFires the first time an offer’s recipient follows its link. Payload includes cartId, channelId, customerId. Response ignored (async).
cart.offer.sentasyncFires after an admin emails an offer cart to its recipient. Payload includes cartId, channelId, customerId, email, expiresAt. Response ignored (async).

category

EventKindWhat it means
category.createdasyncFires after a catalog category is created.
category.deletedasyncFires after a category is deleted.
category.updatedasyncFires after a category is updated (name, parent, URL, etc.).

channel

EventKindWhat it means
channel.deletedasyncFires after a store/channel is deleted.
channel.locales.changedasyncFires after a store’s active locale set changes.
channel.updatedasyncFires after a store’s own fields change - name, currency, locales, active flag, public URL or default flag. Subscribe to bust a headless frontend’s nav cache.

checkout

EventKindWhat it means
checkout.validatingsyncFires when checkout is fully resolved (cart, addresses, payment provider, engine discount) but BEFORE the placement transaction opens - the preferred veto point for fraud screens, address checks and approval gates since a slow endpoint here never pins a DB transaction. Response schema: {“reject”: bool, “reason”: string, “discountCents”: int, “riskScore”: int, “tags”: [string]}. Enrichment is seeded onto the order.placing payload that follows; discounts accumulate, risk score takes the max, tags append.

cms

EventKindWhat it means
cms.page.publishedasyncFires after a CMS page transitions to is_published=true. Subscribe to invalidate caches or kick off a static rebuild.
cms.page.publishingsyncFires before a CMS page transitions to is_published=true - admin click, bulk publish or scheduler promotion. A scheduled page that is rejected stays scheduled and is asked again on the next run. Response schema: {“reject”: bool, “reason”: string}.
cms.page.unpublishedasyncFires after a CMS page transitions back to is_published=false.

collection

EventKindWhat it means
collection.createdasyncFires after a collection is created.
collection.deletedasyncFires after a collection is deleted.
collection.products.changedasyncFires when the set of products in a collection changes (manual collections). Payload includes the collection id.
collection.updatedasyncFires after a collection is updated.
EventKindWhat it means
consent.changedasyncFires after a customer opts in or out of a consent kind - marketing_email, marketing_sms and so on. Payload: CustomerID, Kind, OptedIn, Source. Account-global, so per-channel webhooks never match it.

customer

EventKindWhat it means
customer.address_changedasyncFires after an address book entry is added, updated or deleted, by the customer or an admin, or made the default. Payload includes the action: add, update, delete, default_billing or default_shipping.
customer.deletedasyncFires after an admin deletion commits. Payload includes the customer’s last-known email and channel_id so downstream systems can purge.
customer.deletingsyncFires before an admin customer delete commits. Use to refuse the delete based on external state (open tickets, active subscriptions). Response schema: {“reject”: bool, “reason”: string}.
customer.email_verifiedasyncFires after a customer confirms their email via the verification link. Useful for moving the customer to a marketing-opted-in list or granting a welcome incentive.
customer.password_changedasyncFires after a logged-in customer changes their own password (distinct from the reset-token flow).
customer.profile_updatedasyncFires after a customer edits their own profile, or a customer import changes one. Payload includes changedFields, named in camelCase like customer.updated’s (firstName, lastName, phone), and Source: self or import.
customer.registeredasyncFires after a new customer registration commits. Response is ignored (async).
customer.registeringsyncFires before customer registration commits. Blocks the flow until response. Response schema: {“reject”: bool, “reason”: string, “tags”: [string]}. Returned tags are appended to the customer’s registration tags.
customer.updatedasyncFires after an admin updates a customer, including the active toggle, group, tax status and VAT id. Response is ignored (async). Payload includes changedFields listing which fields were modified, in camelCase.
customer.updatingsyncFires before an admin customer update commits. Blocks the flow until response. Response schema: {“reject”: bool, “reason”: string, “tags”: [string]}. Returned tags are forwarded to the customer.updated async event.

download

EventKindWhat it means
download.accessedasyncFires when a buyer redeems a download token (a file is served).

giftcard

EventKindWhat it means
giftcard.adjustedasyncFires after an admin balance correction (top-up, goodwill, chargeback). Payload includes giftCardId, signed delta, new balance, and note.
giftcard.issuedasyncFires after a gift card is minted - an admin issue, or one card per gift-card line on a placed order. Payload includes giftCardId, code, currency, amount (opening balance, cents), customerId, and orderId (set when issued from a purchase).
giftcard.redeemedasyncFires after a gift card is spent via the standalone redeem path. Payload includes giftCardId, code, amount applied, and remaining balance. (Checkout-tender redemption applied inside the order transaction is not emitted here.)

inventory

EventKindWhat it means
inventory.allocatingsyncFires before stock is reserved for an order line. Reject to refuse the allocation. Response schema: {“reject”: bool, “reason”: string, “routing”: {"": ""}}. Routed lines allocate from exactly the picked location (warehouse routing, store pickup); unrouted lines use the default algorithm.
inventory.stock.changedasyncFires on any salable-quantity change for a product at a location. Chatty - payload includes productId, locationId, and the new quantity.
inventory.stock_lowasyncFires when a stock level transitions across the per-item low_stock_threshold (from above to at-or-below the threshold while still positive).
inventory.stock_outasyncFires when a stock level reaches zero from a positive quantity.
EventKindWhat it means
menu.updatedasyncFires after a storefront menu (navigation) is updated. Subscribe to bust a headless frontend’s nav cache.

order

EventKindWhat it means
order.cancelledasyncFires after an order transitions to cancelled. More specific alternative to filtering order.status_changed on toStatus.
order.cancellingsyncFires before a cancel transition commits. Blocks the flow until response. Response schema: {“reject”: bool, “reason”: string, “note”: string}. Use to gate on refund success or to append a note onto the order_event audit row.
order.editedasyncFires after an admin edits a placed order (lines/quantities/prices). Subscribe to re-sync external systems that mirror the order.
order.fulfilledasyncFires after an order transitions to fulfilled. Subscribe here for shipping notifications, accounting closeout, or post-fulfillment surveys.
order.fulfillingsyncFires before a fulfilled transition commits. Blocks the flow until response. Response schema: {“reject”: bool, “reason”: string, “carrier”: string, “trackingNumber”: string}. Carrier + trackingNumber, if returned, are appended to the order_event message.
order.fulfillment_createdasyncFires when a (possibly partial) fulfillment/shipment is created for an order. Payload includes the fulfilled lines + tracking when set.
order.payment_status_changedasyncFires when an order’s payment state transitions (e.g. pending → paid → refunded). Payload includes orderId, fromStatus, toStatus.
order.placedasyncFires after a new order commits. Response is ignored (async).
order.placingsyncFires before order placement commits. Blocks the flow until response. Response schema: {“reject”: bool, “reason”: string, “discountCents”: int, “riskScore”: int, “tags”: [string]}. Discounts accumulate across webhooks; risk score takes the max; tags append. Tax/shipping/total are owned by the remote tax + shipping providers, not this webhook.
order.return_approvedasyncFires after a requested return is approved by a moderator/admin.
order.return_receivedasyncFires when returned goods are marked received (the step after return_approved). Completes the RMA lifecycle.
order.return_rejectedasyncFires after a requested/approved return is rejected (terminal).
order.return_requestedasyncFires after a customer/admin opens a return (RMA) against a shipped order. Payload includes orderId and returnId.
order.status_changedasyncFires after a non-placement order status transition commits. Payload includes orderId, fromStatus, and toStatus. Response is ignored (async).
order.tenderingsyncFires inside the placement transaction once the order exists and core’s gift-card and store-credit tenders have been applied, carrying the balance still owed - the veto point for a credit line or pay-later arrangement, which refuses by rejecting. Payload: OrderID, OrderNumber, ChannelID, CustomerID (null for guests), Email, Provider, CurrencyCode, TotalCents, RemainingCents. Response schema: {“reject”: bool, “reason”: string}. Published for every method with a balance, so match on Provider before acting. A slow endpoint here holds the placement transaction open - prefer checkout.validating for anything that only needs to say no.

payment

EventKindWhat it means
payment.authorizedasyncFires after a payment authorization succeeds (funds held, not yet captured). Payload includes orderId, provider, amount, currency.
payment.capturedasyncFires after funds are captured (money moves). The usual trigger for accounting + fulfillment.
payment.provider_webhookasyncFires when an inbound payment-provider callback is received and processed - useful for reconciliation of async/delayed settlement events.
payment.refundedasyncFires after a full or partial refund is issued. Payload includes the refunded amount.
payment.voidedasyncFires after an uncaptured authorization is voided (the hold is released).

product

EventKindWhat it means
product.createdasyncFires after a new product row commits. Typical subscribers: search index sync, CDN cache warmer, ERP catalog mirror.
product.creatingsyncFires before a new product row inserts. Blocks the flow until response. Response schema: {“reject”: bool, “reason”: string, “tags”: [string]}. Returned tags are joined and stored under metadata.webhook_tags.
product.deletedasyncFires after a product row delete commits. Use the snapshot fields in the payload - the row is gone by the time this fires.
product.listing.changedasyncFires when a product’s per-store listing changes (published/unpublished or price override). Payload includes productId + channelId.
product.updatedasyncFires after a product row update commits. Payload includes changedFields listing the top-level fields that were modified.
product.updatingsyncFires before a product update commits. Payload includes changedFields. Response schema: {“reject”: bool, “reason”: string, “tags”: [string]}. Returned tags are merged into metadata.webhook_tags.

qna

EventKindWhat it means
qna.question_answeredasyncFires after an operator answers a question (which publishes it to the storefront). Payload includes questionId, productId, channelId, answeredBy, and both the question and answer text.
qna.question_submittedasyncFires after a shopper submits a product question (pending moderation). Payload includes questionId, productId, channelId, customerId, authorName, and the question text.

ratelimit

EventKindWhat it means
ratelimit.throttle_trippedasyncFires when an IP exhausts a request budget on the front API. Payload: IP, Surface, Count, Budget, ChannelID. Feed it to a WAF or alerting.

review

EventKindWhat it means
review.approvedasyncFires after a review is approved and becomes publicly visible.
review.rejectedasyncFires after a review is rejected in moderation.
review.submittedasyncFires after a customer submits a product review (pre-moderation). Payload includes productId, rating, and the author.
review.submittingsyncFires before a product review is stored. Reject to refuse the submission. Response schema: {“reject”: bool, “reason”: string, “status”: “approved”|“rejected”|“pending”, “tags”: [string]}. status short-circuits manual moderation (external spam services); tags append and survive onto review.submitted. String literal to avoid a webhook→review→ai import cycle; mirrors review/events.go.

subscription

EventKindWhat it means
subscription.cancelledasyncFires after a subscription is cancelled (customer or admin).
subscription.createdasyncFires after a subscription is started from a placed order.
subscription.invoicedasyncFires after a billing-period invoice is minted (one per period). Payload includes the amount + currency.
subscription.pausedasyncFires after an admin pauses a subscription (billing suspended until resumed).
subscription.payment_failedasyncFires after a renewal charge fails. Attempt counts from 1; NextRetryAt is the next dunning step, null when the ladder is exhausted and WillCancel is true (the subscription was cancelled).
subscription.resumedasyncFires after a paused subscription is reactivated.

urlrewrite

EventKindWhat it means
urlrewrite.changedasyncFires after a url_rewrite row is created, updated, or deleted (a canonical URL moved or a redirect changed). Payload includes path, entityType, entityId, channelId (null = global), and deleted. Subscribe to purge the affected path from edge caches.

webform

EventKindWhat it means
webform.submittedasyncFires after a storefront web form (contact, lead, custom) is submitted. Payload includes the form id + submitted fields.

wishlist

EventKindWhat it means
wishlist.item_addedasyncFires after a signed-in customer saves a product to their wishlist. Payload includes customerId and productId.
wishlist.item_removedasyncFires after a customer removes a product from their wishlist. Payload includes customerId and productId.