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 '{
        "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/events is 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_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).