---
title: "Webhooks"
description: "Receive real-time HTTP callbacks for orders and users, and verify their signatures."
source: https://0.0.0.0:8006/docs/webhooks
---

# Webhooks

Receive real-time HTTP callbacks whenever important events occur in your SpireStock workspace — new orders, status changes, user sign-ups, and payment confirmations.

💡 

Registering an endpoint

Create, update, pause, delete and test webhook endpoints in the dashboard under **Developer → Webhooks**, or with the [API Keys & Webhooks](/api-reference#developer) endpoints. Registering returns a signing secret — it is shown **once**, and it is what the [signature check](#signature-verification) below uses. If you lose it, [rotate](#rotating-secrets) rather than re-register.

⚠️ 

Webhook management needs an admin session

These endpoints reject `X-API-Key` credentials by design, so a leaked key cannot repoint your webhooks at somebody else’s server. Manage them signed in as a workspace administrator.

## How Webhooks Work

Webhooks follow a simple four-step flow:

1.  **Register** — You configure an HTTPS endpoint URL and choose the events you want to subscribe to via the SpireStock dashboard or API.
2.  **Trigger** — An event occurs in your workspace (e.g., a new order is created).
3.  **Deliver** — The event is queued the instant it happens and picked up by the delivery worker within 15 seconds, which `POST`s the signed payload to your endpoint.
4.  **Acknowledge** — Your server responds `2xx` within 10 seconds. Anything else is [retried](#retries).

💡 

Queued, not inline

Deliveries are queued durably rather than sent from inside the request that triggered them. Your endpoint being slow or down never slows down or fails the underlying operation — and because the retry schedule is stored, it survives our deploys and restarts.

⚠️ 

HTTPS and public addresses required

Endpoint URLs must use `https://` and resolve to a public address. Plain HTTP, embedded credentials, and hosts that resolve to private, loopback, or link-local ranges are rejected — both when you register the endpoint and again at delivery time. Redirects are not followed.

## Event Types

Subscribe to one or more of the following event types:

| Event | Description | Fired When |
| --- | --- | --- |
| `order.created` | A new order has been placed | A sales rep or customer submits a new order through any channel |
| `order.delivered` | An order has been delivered | The order status changes to delivered (status 4) |
| `user.created` | A new user joined the workspace | An admin creates a new user or a user signs up in your workspace |

💡 

webhook.test is not a subscription

Triggering a test sends a `webhook.test` event to the endpoint regardless of what it is subscribed to. You cannot subscribe to it — listing it in `events` is rejected — and it is never retried. It is signed exactly like a real delivery, so it is the right way to prove your signature check works.

## Payload Format

Every webhook delivery includes a set of custom headers and a JSON body.

### Headers

| Header | Description | Example |
| --- | --- | --- |
| `X-Webhook-Event` | The event type that triggered this delivery | `order.created` |
| `X-Webhook-Signature` | HMAC-SHA256 hex digest for payload verification | `sha256=a1b2c3d4e5...` |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) of _this attempt_ — a retry is re-signed with a fresh timestamp | `1717200000` |
| `X-Webhook-Id` | Event ID, identical across retries — deduplicate on this | `evt_9f3a1c7e2b8d4a6f0c5e1b7a3d9f2e4c` |
| `X-Webhook-Attempt` | Which attempt this is, starting at 1 | `3` |
| `Content-Type` | Always JSON | `application/json` |

### Example Payload

Below is a sample payload for an `order.created` event:

order.created payload 

```json
{
  "id": "evt_9f3a1c7e2b8d4a6f0c5e1b7a3d9f2e4c",
  "event": "order.created",
  "timestamp": "2026-06-01T10:30:00.000Z",
  "organization_id": 5,
  "data": {
    "id": 142,
    "order_code": "ORD-2026-0142",
    "order_status": 1,
    "user_id": 42,
    "user_name": "Sharma Dairy Store",
    "order_total_amount": 1400.00,
    "order_items_count": 1,
    "order_date": "2026-06-01",
    "production_unit_id": 3,
    "mode_of_payment": "credit",
    "created_at": "2026-06-01T10:30:00.000Z"
  }
}
```

## Signature Verification

Every webhook delivery is signed using your workspace’s webhook secret. You should **always** verify the signature before processing a payload to ensure it was sent by SpireStock and has not been tampered with.

### How It Works

1.  Concatenate the timestamp and the raw request body, separated by a dot: `{timestamp}.{body}`
2.  Compute an HMAC-SHA256 digest using your webhook secret as the key.
3.  Compare the computed signature with the value in the `X-Webhook-Signature` header using a **timing-safe comparison** to prevent timing attacks.

🚨 

Never skip verification

Without signature verification, an attacker could forge webhook deliveries to your endpoint. Always use a timing-safe comparison function to prevent timing-based side-channel attacks.

Signature Verification 

**Node.js (Express)**

```javascript
import crypto from "crypto";
import express from "express";

const app = express();

// Use raw body for signature verification
app.post(
  "/webhooks/spirestock",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-webhook-signature"];
    const timestamp = req.headers["x-webhook-timestamp"];
    const body = req.body.toString();

    // Build the signed content
    const signedContent = `${timestamp}.${body}`;

    // Compute expected signature
    const expected = "sha256=" +
      crypto
        .createHmac("sha256", process.env.WEBHOOK_SECRET)
        .update(signedContent)
        .digest("hex");

    // Timing-safe comparison
    const isValid =
      expected.length === signature.length &&
      crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(signature)
      );

    if (!isValid) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    // Parse and process the event
    const event = JSON.parse(body);
    console.log("Received event:", event.event, event.id);

    // Acknowledge receipt
    res.status(200).json({ received: true });
  }
);

app.listen(3000);
```

**Python (Flask)**

```python
import os
import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]

@app.route("/webhooks/spirestock", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Webhook-Signature", "")
    timestamp = request.headers.get("X-Webhook-Timestamp", "")
    body = request.get_data(as_text=True)

    # Build the signed content
    signed_content = f"{timestamp}.{body}"

    # Compute expected signature
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET.encode(),
        signed_content.encode(),
        hashlib.sha256,
    ).hexdigest()

    # Timing-safe comparison
    if not hmac.compare_digest(expected, signature):
        return jsonify({"error": "Invalid signature"}), 401

    # Process the event
    event = request.get_json()
    print(f"Received event: {event['event']} {event['id']}")

    return jsonify({"received": True}), 200

if __name__ == "__main__":
    app.run(port=3000)
```

## Rotating the signing secret

The signing secret is returned once, when you register the endpoint, and is never included in `GET /developer/webhooks`. If it leaks — or you simply lost it — rotate it:

Rotate a signing secret 

```bash
curl -X POST https://api.spirestock.com/api/v1/developer/webhooks/1/rotate-secret \
  -H "Authorization: Bearer YOUR_SESSION_TOKEN"
```

⚠️ 

Rotation takes effect immediately

Deliveries are signed with the new secret from the moment you rotate, with no grace period. Deploy the new secret to your endpoint first, or accept a short window of signature failures — those deliveries will be retried, so nothing is lost.

## Retries & Failure Handling

A delivery succeeds when your endpoint returns a `2xx` within 10 seconds. Anything else is retried with exponential back-off: 30 seconds, then 1, 2, 4, 8, 16, 32 minutes, doubling to a 6-hour ceiling with jitter. After **12 attempts** — roughly 14 hours — the delivery is marked `dead` and dropped.

| Your response | What happens |
| --- | --- |
| `2xx` | Delivered. The endpoint’s failure count resets to 0. |
| `5xx`, timeout, connection error | Retried — assumed temporary. |
| `408`, `429` | Retried — you asked us to slow down. |
| Any other `4xx` | Not retried. Your endpoint rejected the payload, and sending it again would only be rejected again. |

💡 

Pausing an endpoint stops the queue

Setting an endpoint’s `status` to `0`, or deleting it, cancels its queued deliveries instead of retrying into nothing. Pause before a planned outage and no time is spent on attempts you know will fail — but events that occur while paused are not queued, and are not backfilled when you resume.

## Inspecting deliveries

Every attempt is recorded — what was sent, what came back, and what is still queued. This is the first place to look when an endpoint is not receiving what you expect.

Recent deliveries for one endpoint 

```bash
curl "https://api.spirestock.com/api/v1/developer/webhook-deliveries?webhook_id=1&status=dead" \
  -H "Authorization: Bearer YOUR_SESSION_TOKEN"
```

Response (200 OK) 

```json
{
  "response_code": 200,
  "data": [
    {
      "id": 4821,
      "webhook_id": 1,
      "event_id": "evt_9f3a1c7e2b8d4a6f0c5e1b7a3d9f2e4c",
      "event_type": "order.created",
      "status": "dead",
      "attempts": 12,
      "max_attempts": 12,
      "last_attempt_at": "2026-08-18T04:11:07.000Z",
      "response_status": 500,
      "response_body": "Internal Server Error",
      "duration_ms": 812,
      "last_error": "Internal Server Error",
      "created_at": "2026-08-17T14:02:55.000Z"
    }
  ]
}
```

Filter with `webhook_id`, `status` (`pending`, `sending`, `sent`, `dead`) and `limit` (default 50, max 200).

## Best Practices

-   **Respond quickly** — Return a `2xx` within 10 seconds; after that the attempt is abandoned and retried. Acknowledge first, then process on your own queue.
-   **Handle duplicates** — Deduplicate on the event `id` (also sent as `X-Webhook-Id`), which stays the same across retries. A delivery that timed out on your side may still have been processed, so treat handlers as idempotent.
-   **Validate timestamps** — Reject deliveries whose `X-Webhook-Timestamp` is more than 5 minutes old to guard against replay. This is safe with retries: each attempt is re-signed with a fresh timestamp, so a retry arriving hours later still looks current.
-   **Use a separate endpoint per environment** — Register staging and production separately so each gets its own signing secret and neither can replay into the other.
-   **Monitor failures** — Watch `failure_count` on the endpoint, and read the [delivery log](#delivery-log) for the response your server actually returned.
-   **Do not allowlist by IP** — SpireStock does not publish a fixed egress range, and it can change without notice. The [signature](#signature-verification) is what proves a delivery came from us; treat it as the only check that matters.
