Webhook signatures
Check that every delivery came from DarkStrata and arrived unaltered
Overview
Every webhook DarkStrata sends is signed with a secret that only you and we hold. Verify the signature before you act on a delivery and you know two things:
- It came from DarkStrata, not from someone who found your endpoint URL.
- The body wasn't changed on the way.
Delivery is at-least-once. A retry carries the same event ID as the original, so you can drop anything you've already handled.
Headers on every delivery
| Header | Format | Meaning |
|---|---|---|
X-DarkStrata-Signature | t=<unix seconds>,v1=<hex>[,v1=<hex>] | The timestamp t and one or more signatures. During a secret rotation there are two v1 values. |
X-DarkStrata-Event-Id | <uuid> | Identical on every retry of the same event. Also in the JSON body as event_id. |
X-DarkStrata-Event | alert.created | The event key. |
X-DarkStrata-Delivery-Attempt | 1, 2, 3 … | The attempt number, starting at 1. |
How the signature is computed
v1 is a hex-encoded HMAC-SHA256:
v1 = hex(HMAC-SHA256(key = signing_secret, message = t + "." + raw_body))- Key: your signing secret as UTF-8 bytes, including its
whsec_prefix. - Message: the timestamp
t, a full stop, then the raw request body.
Compute it over the raw body bytes exactly as received. If your framework has already parsed the JSON, re-serialising it will change whitespace or key order and the signature won't match.
Verifying a delivery
- Split the header on commas. Read
tand everyv1. - Reject the delivery if
tis more than 300 seconds from your current time, in either direction. This stops an old delivery being replayed. - Compute the expected signature from your secret,
tand the raw body. Compare it with eachv1using a constant-time comparison. - Accept the delivery if any
v1matches.
When you rotate your secret, we sign with both secrets for the overlap you choose: the new secret's signature comes first, the old one second. Accepting any match means nothing breaks while you update your receiver.
Code examples
Each example reads the secret from DARKSTRATA_WEBHOOK_SECRET and returns 400 for a bad signature.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.DARKSTRATA_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const parts = header.split(',').map((part) => part.split('='));
const t = parts.find(([key]) => key === 't')?.[1];
const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value);
if (!/^\d+$/.test(t ?? '') || Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
// express.raw keeps the body as the exact bytes we signed.
app.post('/webhooks/darkstrata', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('X-DarkStrata-Signature') ?? '', SECRET)) {
return res.sendStatus(400);
}
const eventId = req.get('X-DarkStrata-Event-Id');
// If you have already processed eventId, return 200 and stop here.
const event = JSON.parse(req.body);
res.sendStatus(200);
});Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["DARKSTRATA_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
pairs = [part.split("=", 1) for part in header.split(",") if "=" in part]
t = next((value for key, value in pairs if key == "t"), "")
signatures = [value for key, value in pairs if key == "v1"]
now = int(time.time()) if now is None else now
if not t.isdigit() or abs(now - int(t)) > TOLERANCE_SECONDS:
return False
message = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest().encode()
return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)
app = Flask(__name__)
@app.post("/webhooks/darkstrata")
def darkstrata_webhook():
# get_data() returns the raw body bytes, before any JSON parsing.
if not verify(request.get_data(), request.headers.get("X-DarkStrata-Signature", ""), SECRET):
abort(400)
event_id = request.headers["X-DarkStrata-Event-Id"]
# If you have already processed event_id, return 200 and stop here.
event = request.get_json()
return "", 200Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 300
func verify(body []byte, header, secret string, now time.Time) bool {
var t string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
switch key {
case "t":
t = value
case "v1":
signatures = append(signatures, value)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || now.Unix()-ts > toleranceSeconds || ts-now.Unix() > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, signature := range signatures {
received, err := hex.DecodeString(signature)
if err == nil && hmac.Equal(received, expected) {
return true
}
}
return false
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) // the raw bytes, exactly as received
secret := os.Getenv("DARKSTRATA_WEBHOOK_SECRET") // whsec_...
if err != nil || !verify(body, r.Header.Get("X-DarkStrata-Signature"), secret, time.Now()) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// If you have already processed r.Header.Get("X-DarkStrata-Event-Id"),
// return 200 and stop here. Otherwise handle the JSON in body.
w.WriteHeader(http.StatusOK)
}Test vector
Check your implementation against these values. Pass t as your current time so the 300-second window doesn't reject it.
| Input | Value |
|---|---|
| Signing secret | whsec_test_secret_do_not_use |
| t | 1700000000 |
| Body (exact bytes, no trailing newline) | {"event":"webhook.test","event_id":"3f8b1c2a-1111-4222-8333-444455556666"} |
| Expected v1 | e206a2de375c2b4de2015800520c29efebddb14d765233f48b5336ed237ee9e2 |
The full header is therefore:
X-DarkStrata-Signature: t=1700000000,v1=e206a2de375c2b4de2015800520c29efebddb14d765233f48b5336ed237ee9e2Duplicates and retries
- Delivery is at-least-once. If your endpoint times out or returns an error, we retry, and retries can keep arriving for up to about five days.
- Store each
X-DarkStrata-Event-Idyou process, and ignore any you've seen before. Return a 2xx for a duplicate so we stop retrying. X-DarkStrata-Delivery-Attempttells you which attempt you're looking at, which is useful in logs.
Getting your signing secret
- In the DarkStrata app, go to Integrations → Services.
- Open your webhook and find Signing secret.
- Select Rotate, choose how long the old secret keeps working, and copy the new secret.
A new custom webhook shows its secret straight after you create it.
The secret is shown once, and only in the web app. It is never available through the API or the MCP server, by design: anyone holding it could forge deliveries.
When you rotate, the old secret can keep working for:
- None (the old secret stops working immediately)
- 24 hours
- 3 days
- 7 days
Body fields
Every delivery body includes these fields. Custom payload templates can use them as placeholders.
| Field | Template placeholder | Meaning |
|---|---|---|
event_id | {{event_id}} | The event's UUID, the same as X-DarkStrata-Event-Id. |
occurred_at | {{occurred_at}} | When the event happened, as an ISO 8601 UTC timestamp. This doesn't change between retries. |
Resources and support
For webhook endpoints and the full API, see the API reference. If you need a hand, our team is happy to help.
Would you rather pull events than receive them? Use the SIEM integrations or the Splunk add-on.