Webhooks

FG.AI WMS emits webhooks back to the upstream so that the source system can mirror FG.AI’s view of the world. Three events cover the common cases: document state changes, inventory adjustments, and normalization conflicts.

Events

Event

When emitted

document.state-changed

A document moves to RELEASED, PICKING, PICKED, PACKED, SHIPPED, RECEIVED, COMPLETED, CANCELLED, or BLOCKED.

inventory.adjusted

FG.AI reconciliation produces an inventory adjustment vs the caller’s prior baseline.

master.normalization-conflict

An item was QUARANTINED. Lets the caller’s ops surface react without polling /quarantine.

The webhook payload schemas are documented in the OpenAPI reference under /webhooks/* paths.

Delivery semantics

Property

Value

Direction

FG.AI WMS → caller’s registered URL

Transport

HTTPS POST

Body format

JSON (application/json)

Signing

HMAC-SHA256 over the raw body, header X-FGAI-Signature: sha256=<hex>

Delivery

At-least-once, exponential-backoff retry

Ordering

Serialized per (partner_id, document.source_id) for document events; otherwise best-effort

Retry schedule (typical): 0s 5s 30s 2m 10m 1h ... 24h cap DLQ. After 24h of failed retries, the event drains to FG.AI’s DLQ and a supervisor is alerted on the FG.AI side.

Verifying the signature

The receiver MUST verify the signature before parsing the body. Skipping verification is a serious vulnerability — anyone who can reach the webhook URL can inject events.

import hmac, hashlib

def verify(raw_body: bytes, header_value: str, secret: bytes) -> bool:
    if not header_value.startswith("sha256="):
        return False
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    received = header_value[len("sha256="):]
    return hmac.compare_digest(expected, received)
public static boolean verify(byte[] rawBody, String headerValue, byte[] secret) {
    if (!headerValue.startsWith("sha256=")) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret, "HmacSHA256"));
    byte[] expected = mac.doFinal(rawBody);
    String expectedHex = HexFormat.of().formatHex(expected);
    String receivedHex = headerValue.substring("sha256=".length());
    return MessageDigest.isEqual(expectedHex.getBytes(), receivedHex.getBytes());
}

Constant-time comparison (hmac.compare_digest / MessageDigest.isEqual) is required to prevent timing attacks.

Idempotency

Every webhook payload carries a correlation_id. The receiver must be idempotent on (planner_id, correlation_id) — a re-delivered webhook (network error on the original, retried by FG.AI) returns 200 OK with no duplicate effect.

planner_id is FG.AI’s identifier for the emitting service; the value is fixed per environment. The combination (planner_id, correlation_id) uniquely identifies a webhook event for the receiver’s lifetime.

Acceptable responses

Status

FG.AI behavior

2xx

Treat as success. Drop from the retry queue.

4xx

Treat as terminal. Move to DLQ. Do not retry.

5xx / network error

Retry per the exponential backoff schedule.

429

Honor Retry-After; resume with backoff.

Returning 4xx to a webhook tells FG.AI not to retry. Use this only when the receiver has logically rejected the event (e.g. the document does not exist on the upstream side); never use it for transient errors.

Event payload shapes

document.state-changed

{
  "event":          "document.state-changed",
  "correlation_id": "01J7Y6K1NQ3W2C0X4V0R5T6E7N",
  "planner_id":     "fgai-wms",
  "occurred_at":    "2026-05-22T03:14:01Z",
  "document_ref":   { "type": "SHIPPER", "source_id": "SH-2026-000183", "internal_id": "fgai-doc-..." },
  "from_state":     "RELEASED",
  "to_state":       "PICKING",
  "actor":          { "kind": "SYSTEM", "id": "fgai-wes" }
}

inventory.adjusted

{
  "event":          "inventory.adjusted",
  "correlation_id": "01J7Y6K1NQ3W2C0X4V0R5T6E7P",
  "planner_id":     "fgai-wms",
  "occurred_at":    "2026-05-22T03:14:01Z",
  "warehouse_id":   "WH-Tokyo-01",
  "sku":            "SKU-WIDGET-RED-LG",
  "location":       "A.12.3.1",
  "lot":            "LOT-2026-04-15",
  "qty_delta":      -3,
  "reason":         "CYCLE_COUNT_RECONCILE",
  "ref":            { "kind": "CYCLE_COUNT", "id": "cc-2026-019" }
}

master.normalization-conflict

{
  "event":          "master.normalization-conflict",
  "correlation_id": "01J7Y6K1NQ3W2C0X4V0R5T6E7Q",
  "planner_id":     "fgai-wms",
  "occurred_at":    "2026-05-22T03:14:01Z",
  "entity_kind":    "sku",
  "source_id":      "SKU-FOO-001",
  "quarantine_id":  "qn-01HY...",
  "reason":         "Unknown UoM 'KG'. Register via /master/uoms first.",
  "submitted_payload": {}
}

Registration

Webhook URLs are configured per partner_id in the FG.AI partner registry. Each event may optionally have a different URL; the default is one URL per partner_id that receives all event kinds. Multi-URL configuration is supported but adds operational complexity.

URL changes go through the partner registry and require operator approval — they are not self-service. The HMAC secret is rotated at the same time, with a 24h dual-secret window.

What if I do not subscribe to webhooks?

Webhooks are recommended but not required. The same information can be polled:

  • document.state-changed → poll GET /documents/{type}/{source_id} (or a paginated GET /documents?since=… query).

  • inventory.adjusted → poll an inventory query endpoint.

  • master.normalization-conflict → poll GET /quarantine?state=PENDING.

Polling is acceptable for low-volume integrations or strictly outbound-only network postures. For everything else, webhooks are dramatically more efficient and the recommended default.