Skip to main content

Webhooks

Webhooks allow your application to receive real-time HTTP notifications when events occur in ZenFlow. Instead of polling the API, webhooks push data to your server as soon as something happens.

How It Works

  1. Subscribe to one or more topics (events)
  2. When an event occurs, ZenFlow sends a POST to your URL
  3. Your server responds with 2xx to acknowledge receipt
  4. If delivery fails, ZenFlow retries with exponential backoff

Subscribe to Webhooks

To receive notifications, create a webhook specifying your URL and the events you’re interested in:
POST /api/v1/webhooks
{
  "name": "My notifications",
  "url": "https://your-server.com/webhooks/zenflow",
  "events": ["order/created", "order/updated", "stock/updated"]
}
Save the secret returned in the response. You’ll need it to verify signatures. It won’t be shown again.

Available Topics

Orders

Triggered when a new order is created in ZenFlow.Payload:
{
  "id": "evt_abc123",
  "event": "order/created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 1,
    "state_name": "Pending",
    "warehouse_id": 1,
    "warehouse_name": "Main Warehouse",
    "items_count": 5,
    "total_quantity": 12,
    "source": "api",
    "source_integration": null,
    "created_at": "2024-01-15T10:30:00Z"
  }
}
FieldTypeDescription
order_idintegerInternal order ID
order_tenant_idstringOrder ID in your system
state_idintegerCurrent state ID
state_namestringState name
warehouse_idintegerAssigned warehouse ID
items_countintegerNumber of line items
total_quantityintegerTotal units
sourcestringOrigin: api, integration, manual
source_integrationstringSource integration (if applicable)
Triggered when order details are modified (state, items, etc).Payload:
{
  "id": "evt_def456",
  "event": "order/updated",
  "created_at": "2024-01-15T11:00:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 3,
    "state_name": "In Progress",
    "previous_state_id": 1,
    "previous_state_name": "Pending",
    "warehouse_id": 1,
    "updated_fields": ["state_id"],
    "updated_at": "2024-01-15T11:00:00Z"
  }
}
FieldTypeDescription
previous_state_idintegerPrevious state
previous_state_namestringPrevious state name
updated_fieldsarrayFields that were modified
Triggered when an order is cancelled.Payload:
{
  "id": "evt_ghi789",
  "event": "order/cancelled",
  "created_at": "2024-01-15T12:00:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 7,
    "state_name": "Cancelled",
    "previous_state_id": 1,
    "previous_state_name": "Pending",
    "cancellation_reason": "Customer requested cancellation",
    "cancelled_by": "[email protected]",
    "cancelled_at": "2024-01-15T12:00:00Z"
  }
}
FieldTypeDescription
cancellation_reasonstringCancellation reason
cancelled_bystringUser who cancelled
Triggered when an order completes fulfillment (picked and ready for shipping).Payload:
{
  "id": "evt_jkl012",
  "event": "order/completed",
  "created_at": "2024-01-15T14:00:00Z",
  "data": {
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "state_id": 5,
    "state_name": "Completed",
    "warehouse_id": 1,
    "items_picked": 5,
    "total_quantity_picked": 12,
    "picked_by": "[email protected]",
    "started_at": "2024-01-15T13:00:00Z",
    "completed_at": "2024-01-15T14:00:00Z",
    "duration_minutes": 60
  }
}
FieldTypeDescription
items_pickedintegerLines picked
total_quantity_pickedintegerTotal units picked
picked_bystringOperator who picked
duration_minutesintegerPicking time

Products

Triggered when a new product is created.Payload:
{
  "id": "evt_mno345",
  "event": "product/created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "name": "Widget A",
    "description": "Product description",
    "category": "Electronics",
    "price": 1500.00,
    "cost": 800.00,
    "weight": 0.5,
    "created_at": "2024-01-15T10:30:00Z"
  }
}
Triggered when product details are modified.Payload:
{
  "id": "evt_pqr678",
  "event": "product/updated",
  "created_at": "2024-01-15T11:00:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "name": "Widget A - Updated",
    "updated_fields": ["name", "price"],
    "updated_at": "2024-01-15T11:00:00Z"
  }
}
Triggered when a product is deleted.Payload:
{
  "id": "evt_stu901",
  "event": "product/deleted",
  "created_at": "2024-01-15T12:00:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "name": "Widget A",
    "deleted_at": "2024-01-15T12:00:00Z"
  }
}

Stock

Triggered when a product’s stock level changes at a location.Payload:
{
  "id": "evt_vwx234",
  "event": "stock/updated",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "product_name": "Widget A",
    "warehouse_id": 1,
    "warehouse_name": "Main Warehouse",
    "location_id": "A-01-01",
    "quantity": 50,
    "previous_quantity": 75,
    "change": -25,
    "reason": "order_fulfillment",
    "reference_id": "ORD-001"
  }
}
FieldTypeDescription
quantityintegerNew quantity
previous_quantityintegerPrevious quantity
changeintegerDifference (+ or -)
reasonstringReason for change
reference_idstringReference ID (order, adjustment, etc)
reason values:
  • order_fulfillment - Order picking
  • manual_adjustment - Manual adjustment
  • stock_receipt - Goods receipt
  • transfer - Location transfer
  • return - Return
  • inventory_count - Inventory count
Triggered when a product’s stock falls below the configured minimum threshold.Payload:
{
  "id": "evt_yz0567",
  "event": "stock/low_alert",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "product_id": 100,
    "sku": "PROD-001",
    "barcode": "7891234567890",
    "product_name": "Widget A",
    "warehouse_id": 1,
    "warehouse_name": "Main Warehouse",
    "current_quantity": 5,
    "minimum_threshold": 10,
    "suggested_reorder": 50
  }
}
FieldTypeDescription
current_quantityintegerCurrent stock
minimum_thresholdintegerConfigured minimum threshold
suggested_reorderintegerSuggested reorder quantity
Triggered when a stock movement is recorded (receipt, dispatch, transfer).Payload:
{
  "id": "evt_abc890",
  "event": "stock/movement_created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "movement_id": 5678,
    "type": "transfer",
    "product_id": 100,
    "sku": "PROD-001",
    "product_name": "Widget A",
    "quantity": 20,
    "from_location": "A-01-01",
    "to_location": "B-02-03",
    "warehouse_id": 1,
    "created_by": "[email protected]",
    "notes": "Relocation for space",
    "created_at": "2024-01-15T10:30:00Z"
  }
}
FieldTypeDescription
typestringType: receipt, dispatch, transfer, adjustment
from_locationstringSource location (null if receipt)
to_locationstringDestination location (null if dispatch)

Picking Flows

Triggered when an operator starts a picking flow.Payload:
{
  "id": "evt_def123",
  "event": "flow/started",
  "created_at": "2024-01-15T13:00:00Z",
  "data": {
    "flow_id": 789,
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "warehouse_id": 1,
    "operator_id": 5,
    "operator_email": "[email protected]",
    "items_to_pick": 5,
    "total_quantity": 12,
    "started_at": "2024-01-15T13:00:00Z"
  }
}
Triggered when a picking flow completes successfully.Payload:
{
  "id": "evt_ghi456",
  "event": "flow/completed",
  "created_at": "2024-01-15T14:00:00Z",
  "data": {
    "flow_id": 789,
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "warehouse_id": 1,
    "operator_id": 5,
    "operator_email": "[email protected]",
    "items_picked": 5,
    "total_quantity_picked": 12,
    "started_at": "2024-01-15T13:00:00Z",
    "completed_at": "2024-01-15T14:00:00Z",
    "duration_minutes": 60
  }
}
Triggered when a picking flow is cancelled.Payload:
{
  "id": "evt_jkl789",
  "event": "flow/cancelled",
  "created_at": "2024-01-15T13:30:00Z",
  "data": {
    "flow_id": 789,
    "order_id": 12345,
    "order_tenant_id": "ORD-001",
    "warehouse_id": 1,
    "operator_id": 5,
    "operator_email": "[email protected]",
    "cancellation_reason": "Product not available",
    "items_picked_before_cancel": 2,
    "started_at": "2024-01-15T13:00:00Z",
    "cancelled_at": "2024-01-15T13:30:00Z"
  }
}

Payload Structure

All payloads follow this base structure:
{
  "id": "evt_abc123",
  "event": "order/created",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    // Event-specific data
  }
}
FieldTypeDescription
idstringUnique event ID (use for idempotency)
eventstringEvent topic
created_atstringISO 8601 timestamp
dataobjectEvent-specific payload

Verifying Webhooks

Always verify webhook signatures to ensure requests come from ZenFlow.

Signature Format

ZenFlow includes a signature in the X-Webhook-Signature header:
t=1705312200,v1=5d4b8c7a...
  • t: Unix timestamp when the webhook was sent
  • v1: HMAC-SHA256 signature

Verification Process

const crypto = require("crypto");

function verifyWebhookSignature(payload, signature, secret) {
  const parts = signature.split(",");
  const timestamp = parts.find((p) => p.startsWith("t=")).substring(2);
  const expectedSig = parts.find((p) => p.startsWith("v1=")).substring(3);

  // Check timestamp is within 5 minutes
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false; // Replay attack
  }

  // Compute signature
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(computedSig)
  );
}

// Express.js example
app.post(
  "/webhooks/zenflow",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-webhook-signature"];
    const payload = req.body.toString();

    if (
      !verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)
    ) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(payload);
    // Process event...

    res.status(200).send("OK");
  }
);

Retry Policy

If webhook delivery fails, ZenFlow retries with exponential backoff:
AttemptDelay
1Immediate
21 second
32 seconds
4 (final)4 seconds
A delivery is considered failed if:
  • Your server returns a non-2xx status code
  • Connection times out (30 seconds default)
  • SSL/TLS errors occur

Best Practices

Respond Quickly

Return 200 immediately, process async

Handle Duplicates

Use event ID for idempotency

Verify Signatures

Always validate webhook signatures

Use HTTPS

Only use HTTPS webhook URLs

Async Processing

app.post("/webhooks/zenflow", (req, res) => {
  // Respond immediately
  res.status(200).send("OK");

  // Process in background
  processWebhookAsync(req.body).catch(console.error);
});

async function processWebhookAsync(event) {
  // Check for duplicate
  if (await isProcessed(event.id)) {
    return;
  }

  // Process event
  switch (event.event) {
    case "order/created":
      await handleOrderCreated(event.data);
      break;
    // ... other events
  }

  // Mark as processed
  await markProcessed(event.id);
}

Managing Webhooks

View Delivery History

GET /api/v1/webhooks/:id/deliveries

Test Your Webhook

POST /api/v1/webhooks/:id/test
This sends a test event to verify your endpoint is working.

Rotate Secret

If your webhook secret is compromised:
POST /api/v1/webhooks/:id/rotate-secret
Update your server with the new secret before the old one expires.