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 endpoints. Registering returns a signing secret — it is shown once, and it is what the signature check below uses. If you lose it, rotate 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:
- Register — You configure an HTTPS endpoint URL and choose the events you want to subscribe to via the SpireStock dashboard or API.
- Trigger — An event occurs in your workspace (e.g., a new order is created).
- Deliver — The event is queued the instant it happens and picked up by the delivery worker within 15 seconds, which
POSTs the signed payload to your endpoint. - Acknowledge — Your server responds
2xxwithin 10 seconds. Anything else is retried.
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:
{
"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
- Concatenate the timestamp and the raw request body, separated by a dot:
{timestamp}.{body} - Compute an HMAC-SHA256 digest using your webhook secret as the key.
- Compare the computed signature with the value in the
X-Webhook-Signatureheader 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.
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);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:
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.
curl "https://api.spirestock.com/api/v1/developer/webhook-deliveries?webhook_id=1&status=dead" \
-H "Authorization: Bearer YOUR_SESSION_TOKEN"{
"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
2xxwithin 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 asX-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-Timestampis 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_counton the endpoint, and read the 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 is what proves a delivery came from us; treat it as the only check that matters.