Skip to content

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.

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

Terminal window
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.

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.

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.

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.

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 t more than 5 minutes from your clock, so a captured request can’t be replayed later.
  • Compare in constant time.
  • Accept the request if any v1 matches. 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);
}
});

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.

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

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, with attemptedAt, outcome (SUCCEEDED or FAILED), responseStatus (null when no response came) and error. It is null until the first attempt.
{
"consecutiveFailures": 2,
"lastDelivery": {
"attemptedAt": "2026-11-07T09:12:44.120Z",
"outcome": "FAILED",
"responseStatus": 503,
"error": "The endpoint answered with HTTP 503"
}
}

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.

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.

Crossmart Cloud only delivers to public https URLs. To receive events locally:

  1. Start your receiver, for example on port 3000.

  2. Expose it with a tunnel:

    Terminal window
    cloudflared tunnel --url http://localhost:3000
  3. Add the tunnel’s URL as an endpoint, and store the secret where your receiver reads it.

  4. Press Send test event.