Docs navigation
Home / Docs / Webhook events

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 '{
        "name": "Order sync",
        "url": "https://example.com/foundry/webhook",
        "events": ["order.created", "order.shipped", "invoice.created"]
      }'

All three fields are required. name is how the endpoint is labelled in the merchant’s admin — omitting it returns 400, and events must contain at least one entry.

The response is the endpoint record, including its id and its signing secret (whsec_*). The secret is returned only on creation — store it now; you need it to verify deliveries and there is no way to read it back, only to rotate it.

GET /webhooks/events returns the live catalog, as an object rather than a bare array:

{ "events": ["order.created", "order.shipped", "…"] }

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/events is authoritative.

You don’t need to filter your subscription against it defensively: an unrecognised event name is rejected with a 400 that names the offending event and lists every valid one. Subscribe to what you want and read the error if you get one — that fails louder, and sooner, than silently subscribing to a subset.

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_changedtoStatus: "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 with POST /webhooks/deliveries/:deliveryId/replay.
  • Rotate the secret with POST /webhooks/:id/rotate-secret (returns the new secret once).