> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shelfforce.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time notifications when analyses complete, tasks update, and other events occur.

Webhooks let you receive HTTP POST notifications when events occur in your Shelfforce account. Instead of polling for analysis results, register a webhook URL and Shelfforce will notify you as soon as results are ready.

## Available events

| Event                | Description                                                           |
| -------------------- | --------------------------------------------------------------------- |
| `analysis.completed` | An image analysis finished successfully and products are available.   |
| `analysis.failed`    | An image analysis encountered an error and could not produce results. |
| `task.created`       | A new task was created.                                               |
| `task.updated`       | A task's status or details were modified.                             |
| `task.completed`     | A task moved to the `completed` status.                               |
| `*`                  | Wildcard — receive all events.                                        |

## Registering a webhook

Create a webhook by sending a `POST` request with an `admin`-role API key:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://shelfforce.ai/api/v1/webhooks \
    -H "Authorization: Bearer sf_live_a1b2c3d4..." \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://your-app.com/webhooks/shelfforce",
      "events": ["analysis.completed", "analysis.failed"],
      "description": "Production analysis handler"
    }'
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://shelfforce.ai/api/v1/webhooks",
      headers={
          "Authorization": "Bearer sf_live_a1b2c3d4...",
          "Content-Type": "application/json",
      },
      json={
          "url": "https://your-app.com/webhooks/shelfforce",
          "events": ["analysis.completed", "analysis.failed"],
          "description": "Production analysis handler",
      },
  )

  webhook = response.json()["data"]
  print(webhook["secret"])  # Store this securely!
  ```

  ```typescript Node.js theme={null}
  const response = await fetch("https://shelfforce.ai/api/v1/webhooks", {
    method: "POST",
    headers: {
      "Authorization": "Bearer sf_live_a1b2c3d4...",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://your-app.com/webhooks/shelfforce",
      events: ["analysis.completed", "analysis.failed"],
      description: "Production analysis handler",
    }),
  });

  const { data: webhook } = await response.json();
  console.log(webhook.secret); // Store this securely!
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "data": {
    "id": "wh_abc123",
    "url": "https://your-app.com/webhooks/shelfforce",
    "events": ["analysis.completed", "analysis.failed"],
    "secret": "whsec_a1b2c3d4e5f6g7h8i9j0",
    "status": "active",
    "createdAt": "2026-02-23T10:00:00Z"
  }
}
```

<Warning>
  The webhook `secret` is returned only once at creation. Store it securely — you will need it to verify webhook signatures.
</Warning>

## Webhook delivery format

Every webhook delivery is an HTTP POST to your registered URL. Here is the full shape of what hits your endpoint.

### HTTP headers

```
POST /webhooks/shelfforce HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: Shelfforce-Webhooks/1.0
X-Shelfforce-Signature: t=1740312618,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Shelfforce-Event: analysis.completed
X-Shelfforce-Delivery: evt_abc123
```

| Header                   | Description                                                                                    |
| ------------------------ | ---------------------------------------------------------------------------------------------- |
| `X-Shelfforce-Signature` | HMAC-SHA256 signature for verification (see [Signature verification](#signature-verification)) |
| `X-Shelfforce-Event`     | The event type (e.g., `analysis.completed`)                                                    |
| `X-Shelfforce-Delivery`  | Unique delivery ID for idempotency tracking                                                    |
| `Content-Type`           | Always `application/json`                                                                      |

### JSON body structure

Every webhook body follows the same top-level structure:

```json theme={null}
{
  "event": "analysis.completed",
  "data": { ... },
  "timestamp": "2026-02-23T10:32:00Z",
  "webhookId": "wh_abc123"
}
```

| Field       | Type     | Description                                            |
| ----------- | -------- | ------------------------------------------------------ |
| `event`     | `string` | The event type that triggered this delivery            |
| `data`      | `object` | Event-specific payload (see examples below)            |
| `timestamp` | `string` | ISO 8601 timestamp of when the event occurred          |
| `webhookId` | `string` | ID of the webhook endpoint that received this delivery |

## Payload examples by event type

<Tabs>
  <Tab title="analysis.completed">
    Fired when a shelf analysis finishes successfully.

    ```json theme={null}
    {
      "event": "analysis.completed",
      "data": {
        "id": "an_g7h8j9k0",
        "externalId": "customer-123",
        "metadata": { "store": "store-42", "aisle": "3" },
        "status": "completed",
        "totalRuns": 1,
        "productCount": 12,
        "totalUnits": 48,
        "totalValue": 127.50,
        "completedAt": "2026-02-23T10:32:00Z"
      },
      "timestamp": "2026-02-23T10:32:00Z",
      "webhookId": "wh_abc123"
    }
    ```

    The `externalId` and `metadata` fields are included when they were provided at submission time. Use the `id` to fetch the full analysis with products:

    ```bash theme={null}
    curl https://shelfforce.ai/api/v1/analyses/an_g7h8j9k0 \
      -H "Authorization: Bearer sf_live_a1b2c3d4..."
    ```
  </Tab>

  <Tab title="analysis.failed">
    Fired when a shelf analysis encounters an error.

    ```json theme={null}
    {
      "event": "analysis.failed",
      "data": {
        "id": "an_g7h8j9k0",
        "externalId": "customer-123",
        "metadata": { "store": "store-42", "aisle": "3" },
        "status": "failed",
        "failedRuns": 1
      },
      "timestamp": "2026-02-23T10:31:00Z",
      "webhookId": "wh_abc123"
    }
    ```
  </Tab>

  <Tab title="task.created">
    Fired when a new task is created.

    ```json theme={null}
    {
      "event": "task.created",
      "data": {
        "taskId": "tsk_xyz789",
        "title": "Check Coca-Cola facing at Walmart #4523",
        "placeId": "pl_abc123",
        "status": "pending",
        "createdAt": "2026-02-23T10:30:00Z"
      },
      "timestamp": "2026-02-23T10:30:01Z",
      "webhookId": "wh_abc123"
    }
    ```
  </Tab>

  <Tab title="task.updated">
    Fired when a task's fields are modified.

    ```json theme={null}
    {
      "event": "task.updated",
      "data": {
        "taskId": "tsk_xyz789",
        "title": "Check Coca-Cola facing at Walmart #4523",
        "status": "in_progress",
        "updatedFields": ["status", "assignedTo"]
      },
      "timestamp": "2026-02-23T12:00:00Z",
      "webhookId": "wh_abc123"
    }
    ```
  </Tab>

  <Tab title="task.completed">
    Fired when a task is marked as completed. Includes the analysis and compliance score if available.

    ```json theme={null}
    {
      "event": "task.completed",
      "data": {
        "taskId": "tsk_xyz789",
        "title": "Check Coca-Cola facing at Walmart #4523",
        "placeId": "pl_abc123",
        "generationId": "an_g7h8j9k0",
        "complianceScore": 87.5
      },
      "timestamp": "2026-02-23T14:00:00Z",
      "webhookId": "wh_abc123"
    }
    ```
  </Tab>
</Tabs>

## Webhook receiver examples

Copy-paste these handlers to get a working webhook receiver. They verify the signature and handle each event type.

<CodeGroup>
  ```typescript Express (Node.js) theme={null}
  import express from "express";
  import crypto from "crypto";

  const app = express();
  app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));

  const WEBHOOK_SECRET = process.env.SHELFFORCE_WEBHOOK_SECRET!;

  function verifySignature(rawBody: Buffer, signatureHeader: string): boolean {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((part) => {
        const [key, value] = part.split("=");
        return [key, value];
      })
    );

    const timestamp = parts["t"];
    const signature = parts["v1"];
    if (!timestamp || !signature) return false;

    // Reject requests older than 5 minutes
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp, 10)) > 300) return false;

    const signedPayload = `${timestamp}.${rawBody.toString()}`;
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(signedPayload)
      .digest("hex");

    return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  }

  app.post("/webhooks/shelfforce", (req, res) => {
    const signatureHeader = req.headers["x-shelfforce-signature"] as string;
    if (!verifySignature((req as any).rawBody, signatureHeader)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    // Respond immediately — process asynchronously
    res.status(200).json({ received: true });

    const { event, data } = req.body;

    switch (event) {
      case "analysis.completed":
        console.log(`Analysis ${data.generationId} completed with ${data.productCount} products`);
        // Fetch full results: GET /api/v1/analyses/{data.generationId}
        break;

      case "analysis.failed":
        console.error(`Analysis ${data.generationId} failed: ${data.error}`);
        break;

      case "task.created":
        console.log(`Task ${data.taskId} created: ${data.title}`);
        break;

      case "task.updated":
        console.log(`Task ${data.taskId} updated: ${data.updatedFields.join(", ")}`);
        break;

      case "task.completed":
        console.log(`Task ${data.taskId} completed — compliance: ${data.complianceScore}%`);
        break;
    }
  });

  app.listen(3000, () => console.log("Webhook receiver running on port 3000"));
  ```

  ```python FastAPI (Python) theme={null}
  import hmac
  import hashlib
  import time
  import os
  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()

  WEBHOOK_SECRET = os.environ["SHELFFORCE_WEBHOOK_SECRET"]


  def verify_signature(raw_body: bytes, signature_header: str) -> bool:
      parts = dict(part.split("=", 1) for part in signature_header.split(","))
      timestamp = parts.get("t")
      signature = parts.get("v1")

      if not timestamp or not signature:
          return False

      # Reject requests older than 5 minutes
      now = int(time.time())
      if abs(now - int(timestamp)) > 300:
          return False

      signed_payload = f"{timestamp}.{raw_body.decode()}"
      expected = hmac.new(
          WEBHOOK_SECRET.encode(),
          signed_payload.encode(),
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(signature, expected)


  @app.post("/webhooks/shelfforce")
  async def handle_webhook(request: Request):
      raw_body = await request.body()
      signature = request.headers.get("x-shelfforce-signature", "")

      if not verify_signature(raw_body, signature):
          raise HTTPException(status_code=401, detail="Invalid signature")

      payload = await request.json()
      event = payload["event"]
      data = payload["data"]

      if event == "analysis.completed":
          print(f"Analysis {data['generationId']} completed with {data['productCount']} products")
          # Fetch full results: GET /api/v1/analyses/{data['generationId']}

      elif event == "analysis.failed":
          print(f"Analysis {data['generationId']} failed: {data['error']}")

      elif event == "task.created":
          print(f"Task {data['taskId']} created: {data['title']}")

      elif event == "task.updated":
          print(f"Task {data['taskId']} updated: {', '.join(data['updatedFields'])}")

      elif event == "task.completed":
          print(f"Task {data['taskId']} completed — compliance: {data['complianceScore']}%")

      return {"received": True}
  ```
</CodeGroup>

<Tip>
  Your webhook endpoint should return a `200` response as quickly as possible. Perform heavy processing (fetching full analysis results, updating your database, etc.) asynchronously after acknowledging receipt.
</Tip>

## Signature verification

Every webhook request includes an `X-Shelfforce-Signature` header that you should verify to ensure the request is authentic and has not been tampered with.

The header format is:

```
X-Shelfforce-Signature: t=1740312618,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

Where:

* `t` — Unix timestamp of when the webhook was sent
* `v1` — HMAC-SHA256 signature

### How to verify

1. Extract the `t` (timestamp) and `v1` (signature) values from the header.
2. Construct the signed payload: `{timestamp}.{raw_json_body}`
3. Compute HMAC-SHA256 of that payload using your webhook secret.
4. Compare your computed signature with the `v1` value using a constant-time comparison.
5. Optionally, reject requests where the timestamp is more than 5 minutes old to prevent replay attacks.

### Standalone verification functions

If you already have a web server and just need the verification logic:

<CodeGroup>
  ```typescript Node.js theme={null}
  import crypto from "crypto";

  function verifyWebhookSignature(
    payload: string,
    signatureHeader: string,
    secret: string,
    toleranceSeconds = 300
  ): boolean {
    const parts = Object.fromEntries(
      signatureHeader.split(",").map((part) => {
        const [key, value] = part.split("=");
        return [key, value];
      })
    );

    const timestamp = parts["t"];
    const signature = parts["v1"];

    if (!timestamp || !signature) {
      return false;
    }

    // Check timestamp tolerance (prevent replay attacks)
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - parseInt(timestamp, 10)) > toleranceSeconds) {
      return false;
    }

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

    // Constant-time comparison
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time

  def verify_webhook_signature(
      payload: str,
      signature_header: str,
      secret: str,
      tolerance_seconds: int = 300,
  ) -> bool:
      # Parse the signature header
      parts = dict(part.split("=", 1) for part in signature_header.split(","))
      timestamp = parts.get("t")
      signature = parts.get("v1")

      if not timestamp or not signature:
          return False

      # Check timestamp tolerance
      now = int(time.time())
      if abs(now - int(timestamp)) > tolerance_seconds:
          return False

      # Compute expected signature
      signed_payload = f"{timestamp}.{payload}"
      expected = hmac.new(
          secret.encode(),
          signed_payload.encode(),
          hashlib.sha256,
      ).hexdigest()

      # Constant-time comparison
      return hmac.compare_digest(signature, expected)
  ```
</CodeGroup>

## Retry behavior

If your webhook endpoint returns a non-2xx status code or is unreachable, Shelfforce retries the delivery up to 3 times with exponential backoff:

| Attempt   | Delay     |
| --------- | --------- |
| 1st retry | 500ms     |
| 2nd retry | 1 second  |
| 3rd retry | 2 seconds |

After all retries are exhausted, the webhook delivery is marked as failed. You can view failed deliveries in the [Developers](https://shelfforce.ai/drive/developers) page in the dashboard.

## Managing webhooks

| Operation      | Endpoint                       | Key role |
| -------------- | ------------------------------ | -------- |
| List webhooks  | `GET /api/v1/webhooks`         | `admin`  |
| Create webhook | `POST /api/v1/webhooks`        | `admin`  |
| Delete webhook | `DELETE /api/v1/webhooks/{id}` | `admin`  |

### List webhooks

<CodeGroup>
  ```bash curl theme={null}
  curl https://shelfforce.ai/api/v1/webhooks \
    -H "Authorization: Bearer sf_live_a1b2c3d4..."
  ```

  ```python Python theme={null}
  response = requests.get(
      "https://shelfforce.ai/api/v1/webhooks",
      headers={"Authorization": "Bearer sf_live_a1b2c3d4..."},
  )

  webhooks = response.json()["data"]
  for wh in webhooks:
      print(f"{wh['id']}: {wh['url']} ({wh['status']})")
  ```

  ```typescript Node.js theme={null}
  const response = await fetch("https://shelfforce.ai/api/v1/webhooks", {
    headers: { "Authorization": "Bearer sf_live_a1b2c3d4..." },
  });

  const { data: webhooks } = await response.json();
  webhooks.forEach((wh) => {
    console.log(`${wh.id}: ${wh.url} (${wh.status})`);
  });
  ```
</CodeGroup>

### Delete a webhook

<CodeGroup>
  ```bash curl theme={null}
  curl -X DELETE https://shelfforce.ai/api/v1/webhooks/wh_abc123 \
    -H "Authorization: Bearer sf_live_a1b2c3d4..."
  ```

  ```python Python theme={null}
  response = requests.delete(
      "https://shelfforce.ai/api/v1/webhooks/wh_abc123",
      headers={"Authorization": "Bearer sf_live_a1b2c3d4..."},
  )
  ```

  ```typescript Node.js theme={null}
  await fetch("https://shelfforce.ai/api/v1/webhooks/wh_abc123", {
    method: "DELETE",
    headers: { "Authorization": "Bearer sf_live_a1b2c3d4..." },
  });
  ```
</CodeGroup>

## Testing

Use a tool like [webhook.site](https://webhook.site) to get a temporary URL for testing:

1. Go to [webhook.site](https://webhook.site) and copy the unique URL.
2. Register it as a webhook endpoint with `events: ["*"]`.
3. Submit a test analysis via the API.
4. Watch the event appear on webhook.site with the full payload and headers.

<Note>
  Remember to delete test webhooks when you are done testing to avoid unnecessary deliveries.
</Note>
