Webhook events
Every subscribable Partner API event with example payloads, signature verification, and delivery semantics.
Foundry POSTs signed events to your HTTPS endpoint so you can react to changes instead of polling. This page covers the Partner API webhook system (org-wide, managed with an admin key). Headless storefront channels have a separate, channel-scoped webhook system — see the Storefront API reference.
Setup
Create an endpoint with an admin key holding webhooks.manage:
curl -X POST https://api.foundryims.com/api/v1/webhooks \
-H "Authorization: Bearer fims_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/foundry/webhook",
"events": ["order.created", "order.shipped", "invoice.created"]
}'
The response includes the endpoint’s signing secret (whsec_*) once — store it; you need it to verify deliveries. GET /webhooks/events always returns the live event catalog.
Delivery shape
POST <your webhook URL>
Headers:
Content-Type: application/json
X-Foundry-Event: order.shipped
X-Foundry-Delivery-Id: dlv_abc123
X-Foundry-Signature: t=1753142400,v1=<hex hmac sha256>
Body:
{ "event": "order.shipped", "data": { ... }, "delivery_id": "dlv_abc123", "attempt": 1 }
Payloads carry identifiers plus the fields shown below — fetch the full resource through the Partner API when you need more. Build clients that tolerate unknown fields; payloads gain fields without notice.
Event catalog
This list is written by hand and events are added without notice.
GET /webhooks/eventsis authoritative — validate against it before subscribing rather than hardcoding what you read here.
Orders
order.created — a sales order lands from any connected channel (platform sync or storefront checkout).
{ "orgId": "…", "channelId": "…", "orderId": "…" }
order.status_changed — an order moves through its lifecycle.
{
"orgId": "…", "orderId": "…", "channelId": "…", "channelOrderId": "1042",
"fromStatus": "PROCESSING", "toStatus": "SHIPPED", "source": "shipstation"
}
order.shipped — a shipment is created for an order.
{
"orgId": "…", "orderId": "…", "shipmentId": "…",
"carrier": "UPS", "trackingNumber": "1Z…", "trackingUrl": "https://…",
"shippedAt": "2026-07-21T15:04:05.000Z"
}
carrier, trackingNumber, trackingUrl, and shippedAt are nullable.
Inventory
inventory.availability_changed — a variant’s available quantity (on hand − reserved) shifts.
{ "orgId": "…", "variantId": "…", "oldAvailable": 14, "newAvailable": 12 }
Imports
import.completed — a supplier feed / CSV import finishes. This is the bulk-change signal: rather than firing thousands of per-row events, an import fires this once — re-read what you care about.
{ "orgId": "…", "channelIds": ["…"], "jobId": "…" }
Purchase orders
purchase_order.created
{
"orgId": "…", "purchaseOrderId": "…", "orderNumber": "PO-1042",
"channelId": "…", "type": "STANDARD", "status": "DRAFT"
}
purchase_order.status_changed — toStatus: "RECEIVED" is the goods-received moment accounting integrations key on.
{
"orgId": "…", "purchaseOrderId": "…", "orderNumber": "PO-1042",
"fromStatus": "SUBMITTED", "toStatus": "RECEIVED"
}
Catalog
product.created
{ "orgId": "…", "productId": "…", "type": "SIMPLE", "name": "Turbo Coupler 1042" }
product.updated — manual catalog edits, including archive/restore. Trashing a product delivers this with "deleted": true. Import-driven changes signal via import.completed instead.
{ "orgId": "…", "productId": "…", "deleted": true }
Costs & invoices
variant.cost_changed — a variant’s cost changed. Can burst during supplier imports — expect volume if you subscribe.
{ "orgId": "…", "variantId": "…", "oldCost": 41.2, "newCost": 39.85 }
invoice.created — a vendor invoice is recorded. purchaseOrderId is null for standalone invoices.
{
"orgId": "…", "invoiceId": "…", "purchaseOrderId": "…",
"invoiceType": "STANDARD", "invoiceNumber": "INV-889",
"status": "PENDING", "total": 1284.5
}
Verifying signatures
Verify against the exact bytes you received. Re-serializing a parsed JSON body changes the digest and every signature fails — a framework that parses the body for you must also expose the raw buffer. This is the single most common cause of a failed webhook-ping submission check, and it fails with no obvious explanation.
Compute HMAC-SHA256 over {t}.{rawBody} with your endpoint secret — t comes from the signature header itself — and constant-time-compare against v1. Reject timestamps older than ~5 minutes to prevent replay.
import crypto from "crypto";
function verify(req, secret) {
const sig = req.headers["x-foundry-signature"]; // "t=1753142400,v1=abc..."
const t = sig.match(/t=(\d+)/)?.[1];
const received = sig.match(/v1=([0-9a-f]+)/)?.[1];
if (!t || !received) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${req.rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(received, "hex"),
);
}
Retries & reliability
- Respond 2xx within 10 seconds — do your processing async.
- Failed deliveries retry with backoff, up to 8 attempts over ~2 days.
- An endpoint that fails 5 consecutive deliveries is auto-disabled (re-enable it via
PATCH /webhooks/:id). - Always dedup on
X-Foundry-Delivery-Id— a delivery can occasionally fire twice. - Inspect history with
GET /webhooks/:id/deliveries; re-send one withPOST /webhooks/deliveries/:deliveryId/replay. - Rotate the secret with
POST /webhooks/:id/rotate-secret(returns the new secret once).