Webhooks
Webhooks push changes to your systems as they happen: we POST a signed JSON event to your HTTPS endpoint for every subscribed event.
GET/webhooks
Endpoints with delivery statsPOST/webhooks
Register an endpoint (secret shown once)PATCH/webhooks/{id}
Change events or URL, pause or resumeDELETE/webhooks/{id}
Remove an endpointEndpoints can also be managed under Developers → Webhooks, which shows every delivery and lets you resend one. Through the API the key needs the webhooks:write scope.
cURL
bash
curl -X POST "https://library.retailcommerceai.com/api/v1/webhooks" \
-H "Authorization: Bearer cl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.example.com/hooks/catalog",
"events": [
"product.updated",
"product.deleted",
"export.completed"
],
"description": "ERP catalog sync"
}'The response contains secret (whsec_…) once — store it; later responses only show a masked preview. Subscribe to "*" for every event.
Events
| Event | data |
|---|---|
product.created | { product } — the new CanonicalProduct |
product.updated | { product, changedFields } — after any change |
product.deleted | { id, sku } |
product.status_changed | { product, changedFields, previousStatus } |
category.created | the category |
category.updated | the category |
category.deleted | { id, code, name } |
attribute.created | the attribute definition |
attribute.updated | the attribute definition |
attribute.deleted | { id, code, label } |
asset.created | the asset |
asset.deleted | the asset |
import.completed | { jobId, type, status, result } |
export.completed | { jobId, type, status, result } — result.fileKey is the file |
channel.publish.completed | { jobId, type, status, result } |
job.failed | { jobId, type, status: "failed", result: { error } } |
Request
What your endpoint receives
http
POST /hooks/catalog HTTP/1.1
Content-Type: application/json
X-CL-Event: product.updated
X-CL-Delivery: cm1dlv…
X-CL-Timestamp: 1767225600
X-CL-Signature: t=1767225600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_4f2GkQm9cX5fGa1b9KpZ7Qw2",
"type": "product.updated",
"createdAt": "2026-09-24T08:12:45.120Z",
"workspaceId": "cm1w0rk…",
"data": {
"product": {
"id": "cm1…",
"sku": "FR-OUD-100",
"version": 8,
"…": "…"
},
"changedFields": [
"price",
"stock"
]
}
}Verify the signature
- Split
X-CL-Signatureon commas intot(Unix seconds) and one or morev1values. - Compute HMAC-SHA256 with your endpoint secret over
<t>.<raw request body>and hex-encode it. - Accept the request if it equals any v1 value (constant-time compare) and t is within 5 minutes of your clock.
Always verify against the raw bytes you received — re-serialized JSON will not match.
Node.js
javascript
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.CL_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, header, secret) {
let timestamp = null;
const signatures = [];
for (const part of (header ?? "").split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t") timestamp = Number(value);
if (key === "v1") signatures.push(value);
}
if (!timestamp || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
return signatures.some(
(sig) => /^[0-9a-f]{64}$/i.test(sig) && crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")),
);
}
const app = express();
// the signature covers the exact bytes: read the raw body, parse JSON only after verifying
app.post("/webhooks/content-library", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySignature(req.body, req.get("X-CL-Signature"), SECRET)) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // answer fast, then work
queue.add(event); // de-duplicate on event.id
});Delivery and retries
- Respond with any 2xx within 10 seconds. Do slow work after answering (queue it).
- Other answers and timeouts are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours.
- After 20 consecutive failed deliveries the endpoint is switched off; turn it back on in the app or with PATCH active: true.
- Events can arrive more than once and out of order: de-duplicate on the event
id, and comparedata.product.versionor re-fetch the product when order matters. - Redirects are not followed; use the final HTTPS URL.
Test your endpoint
The app's Developers → Webhooks page can send a signed
webhook.test event to your endpoint and shows the response.