Webhooks
Crossmart can POST events to your URL as they happen: new and changed orders, stock changes,
listings that went live, failed or were removed, and product changes. Use them instead of polling
the API from n8n, Zapier, Make or your agent’s backend.
Add an endpoint
Section titled “Add an endpoint”In the dashboard, open Settings → Webhooks → Add endpoint. Or call the API with a key whose
role holds webhooks:manage (Owner or Admin, see Roles and permissions):
curl -X POST "https://api.crossmart.io/api/v1/workspaces/$WORKSPACE_ID/webhook-endpoints" \ -H "x-api-key: $CROSSMART_API_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://example.com/crossmart/webhooks", "eventTypes": ["order.created", "inventory.stock_changed"], "description": "Orders to the warehouse"}'The response holds endpoint and secret (whsec_…).
The URL must be https and resolve to public addresses only. Crossmart refuses private, loopback
and link-local addresses with 400, when you add the endpoint and again on every delivery.
Self-hosted instances on a trusted network can allow plain http and private addresses with
WEBHOOK_ALLOW_PRIVATE_URLS=true.
| Call | Does |
|---|---|
GET /webhook-endpoints |
Lists the endpoints and their last delivery. No secrets |
POST /webhook-endpoints |
Adds one and returns its secret once |
GET, PATCH, DELETE /webhook-endpoints/{id} |
Reads, changes (URL, events, description, enabled), deletes |
POST /webhook-endpoints/{id}/rotate-secret |
Issues a new secret, returned once |
POST /webhook-endpoints/{id}/test |
Sends a webhook.test event to this endpoint |
GET /webhook-endpoints/{id}/deliveries |
The delivery log, newest first (filter by status or event) |
GET /webhook-endpoints/{id}/deliveries/{deliveryId} |
One delivery, with the JSON body it sends |
POST …/deliveries/{deliveryId}/redeliver |
Sends that event again, with the same event id |
Paths are under /api/v1/workspaces/{workspaceId}. Endpoints count toward your plan’s webhook
endpoint limit, across the organization.
From an AI agent
Section titled “From an AI agent”AI agents connected with OAuth can list and add endpoints when the seller approved the
webhooks:write scope, so the agent’s own backend gets events as they happen. Webhooks send
workspace data to a URL the agent names, so this is the only scope that unlocks
webhooks:manage, and the seller’s role must hold that permission too. The
MCP server offers the list_webhook_endpoints and create_webhook_endpoint
tools. Changing, turning off and deleting endpoints stays in the dashboard and the REST API.
Events
Section titled “Events”| Type | Sent when | data |
|---|---|---|
order.created |
An order from a store is seen for the first time | order |
order.updated |
A newer version of an order arrives (status, lines, total) | order |
inventory.stock_changed |
Stock moves for any reason: an order, an edit, an import | items |
listing.published |
A product goes live on a store for the first time | listing |
listing.failed |
A store refuses a listing for good; it stays in error until fixed | listing |
listing.removed |
A listing is taken off its store | listing |
product.created |
A product is created, by hand, API, file or store import | product |
product.updated |
A product is edited, alone or in bulk | product |
product.deleted |
A product is deleted | product |
webhook.test |
You send a test event; it ignores subscriptions | endpointId, message |
Deleting a product sends product.deleted, not one listing.removed per store. New event types
and new fields may appear within v1, so ignore what you don’t know.
Payload
Section titled “Payload”Every delivery is a POST with Content-Type: application/json and this envelope:
{ "id": "7d0f4b5e-2c1a-4b8e-9a51-0e6f3c2d9b10", "type": "order.created", "createdAt": "2026-11-06T10:15:30.000Z", "workspaceId": "3b1f9a52-6c0e-4d7a-8f21-5a9c0e4b7d33", "apiVersion": "v1", "data": { "order": { "id": "c6a1d2e3-…", "externalId": "5432109876", "name": "#1001", "status": "OPEN", "currencyCode": "USD", "total": "45.00", "placedAt": "2026-11-06T10:15:02.000Z", "connectionId": "8e2b…", "channel": "shopify", "lines": [ { "externalId": "1122", "sku": "MUG-BLUE", "title": "Blue mug", "quantity": 3, "unitPrice": "15.00", "variantId": "a91c…" } ] } }}The other data shapes:
// inventory.stock_changed: one event per stock write, with every item it moved{ "items": [{ "inventoryItemId": "…", "variantId": "…", "productId": "…", "sku": "MUG-BLUE", "available": 12, "reserved": 3, "onHand": 15, "availableDelta": -3, "reason": "ORDER_SHIPPED" }] }
// listing.published, listing.failed, listing.removed{ "listing": { "id": "…", "productId": "…", "connectionId": "…", "channel": "ebay", "status": "ERROR", "externalId": null, "externalUrl": null, "lastError": "Add a shipping policy" } }
// product.created, product.updated{ "product": { "id": "…", "title": "Blue mug", "handle": "blue-mug", "status": "ACTIVE", "updatedAt": "2026-11-06T10:15:30.000Z" } }
// product.deleted{ "product": { "id": "…", "title": "Blue mug" } }
// webhook.test{ "endpointId": "…", "message": "…" }Money is a decimal string and times are ISO 8601 in UTC, as in the REST API. An event carries a snapshot of what changed. Fetch the resource from the API when you need all of it.
Verify the signature
Section titled “Verify the signature”Every request carries these headers:
| Header | Value |
|---|---|
Crossmart-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> (one v1 per secret) |
Crossmart-Event-Id |
The event id |
Crossmart-Event-Type |
The event type |
Crossmart-Delivery-Id |
This delivery, as the delivery log shows it |
v1 is an HMAC-SHA256 in lowercase hex, keyed with your whole secret (including whsec_), over
<t>.<raw request body>. To trust a delivery:
- Verify it against the raw body bytes, before any JSON parsing. A body that was parsed and encoded again does not match.
- Refuse a
tmore than 5 minutes from your clock, so a captured request can’t be replayed later. - Compare in constant time.
- Accept the request if any
v1matches. After a rotation there are two, one per secret, for 24 hours.
The SDKs do all of this and return the parsed event. Answer a delivery that fails verification
with 400.
@crossmart/sdk, Node 20+, with Express:
import express from 'express';import { verifyWebhookSignature, WebhookSignatureError } from '@crossmart/sdk';
const app = express();app.post('/crossmart/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { try { const event = await verifyWebhookSignature({ payload: req.body, // the raw Buffer header: req.header('Crossmart-Signature'), secret: process.env.CROSSMART_WEBHOOK_SECRET!, }); if (event.type === 'order.created') { // handle it; deduplicate on event.id } res.sendStatus(200); } catch (error) { res.sendStatus(error instanceof WebhookSignatureError ? 400 : 500); }});crossmart, with Flask:
import os
from flask import Flask, requestfrom crossmart import WebhookSignatureError, verify_webhook_signature
app = Flask(__name__)
@app.post("/crossmart/webhooks")def crossmart_webhook(): try: event = verify_webhook_signature( request.get_data(), # the raw body request.headers.get("Crossmart-Signature"), os.environ["CROSSMART_WEBHOOK_SECRET"], ) except WebhookSignatureError: return "", 400 if event["type"] == "order.created": ... # handle it; deduplicate on event["id"] return "", 200crossmart/crossmart-php, in plain PHP:
use Crossmart\Exception\WebhookSignatureException;use Crossmart\Webhook\WebhookEventType;use Crossmart\Webhook\WebhookSignature;
try { $event = WebhookSignature::verify( (string) file_get_contents('php://input'), // the raw body $_SERVER['HTTP_CROSSMART_SIGNATURE'] ?? null, (string) getenv('CROSSMART_WEBHOOK_SECRET'), );} catch (WebhookSignatureException $exception) { http_response_code(400); exit;}if ($event->eventType() === WebhookEventType::OrderCreated) { // handle it; deduplicate on $event->id}http_response_code(200);Without an SDK, in any language: split the header on ,, take t and every v1, compute
HMAC_SHA256(secret, t + "." + rawBody) in hex, and compare it with each v1 in constant time.
Delivery and retries
Section titled “Delivery and retries”- Answer 2xx quickly, within 10 seconds, and do slow work afterwards. Anything else counts as a failure: other statuses, timeouts, connection errors and redirects, which are not followed. Response bodies are ignored.
- At least once. A failed attempt is retried after 30 seconds, then with the delay doubling
each time up to 6 hours, plus a little jitter: 12 attempts over about 16 hours. The same event
may arrive more than once, so deduplicate on
id. It stays the same across retries and redeliveries. - No ordering. Events can arrive out of order. Use
createdAt, or fetch the resource, when order matters. - Delivery log. Each delivery shows its status (
PENDING,RETRYING,SUCCEEDED,FAILED), attempts, last HTTP status, duration and error. Deliveries are kept for 30 days and can be sent again with Redeliver.
Endpoint health
Section titled “Endpoint health”Every endpoint, in the list and on its own, carries two fields that tell you whether it is working. The dashboard shows them in Settings → Webhooks.
consecutiveFailures: failed attempts since the last one that went through.lastDelivery: the latest attempt, whatever event it carried, withattemptedAt,outcome(SUCCEEDEDorFAILED),responseStatus(nullwhen no response came) anderror. It isnulluntil the first attempt.
{ "consecutiveFailures": 2, "lastDelivery": { "attemptedAt": "2026-11-07T09:12:44.120Z", "outcome": "FAILED", "responseStatus": 503, "error": "The endpoint answered with HTTP 503" }}Rotate a secret
Section titled “Rotate a secret”POST …/rotate-secret
returns a new secret once. For the next 24 hours every delivery is signed with both the new and
the old secret, so you can deploy the new one without dropping events. Rotating again within those
24 hours ends the older secret’s grace at once.
Send a test event
Section titled “Send a test event”Press Send test event on the endpoint in the dashboard, or call
POST …/webhook-endpoints/{id}/test.
Crossmart sends a webhook.test event to that endpoint only, signed like any other, and tries it
once. The API answers 202 with the delivery, and its outcome shows in the delivery log within
seconds, so you can check your receiver end to end. You can’t subscribe to webhook.test: it only
comes when you ask for it.
Test on your machine
Section titled “Test on your machine”Crossmart Cloud only delivers to public https URLs. To receive events locally:
-
Start your receiver, for example on port 3000.
-
Expose it with a tunnel:
Terminal window cloudflared tunnel --url http://localhost:3000 -
Add the tunnel’s URL as an endpoint, and store the secret where your receiver reads it.
-
Press Send test event.