This page takes you from nothing to a working, verified webhook receiver. You need:
localhost, private network addresses and plain http:// are refused.A receiver does four things, in this order:
X-DevStride-Signature header against those bytes with your signing secret.2xx status quickly: DevStride waits at most 10 seconds.id it has already handled.Pick your language. Each sample reads the secret from the DEVSTRIDE_WEBHOOK_SECRET environment variable; you get the secret in step 3.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
// From "Verifying Signatures", unchanged.
function verifyDevStrideSignature(
signatureHeader: string | undefined,
rawBody: Buffer,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000)
): boolean {
if (!signatureHeader) return false;
let timestamp: number | undefined;
const signatures: Buffer[] = [];
for (const part of signatureHeader.split(',')) {
const [key, value] = part.trim().split('=');
if (key === 't' && value && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1' && value && /^[0-9a-f]{64}$/.test(value))
signatures.push(Buffer.from(value, 'hex'));
}
if (timestamp === undefined || signatures.length === 0) return false;
// Older (or newer) than 5 minutes: a replayed capture, or a clock to fix.
if (Math.abs(nowSeconds - timestamp) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
// Two v1 values appear for 24 h after a rotation; either secret verifies.
return signatures.some(
(candidate) =>
candidate.length === expected.length &&
timingSafeEqual(candidate, expected)
);
}
interface DevStrideEvent {
id: string;
type: string;
apiVersion: string;
occurredAt: string;
organizationId: string;
origin: { kind: string; actorId: string | null; correlationId: string };
data: Record<string, unknown>;
}
const secret = process.env.DEVSTRIDE_WEBHOOK_SECRET ?? '';
const handled = new Set<string>(); // use a database table or Redis in production
const app = express();
app.post(
'/devstride/webhooks',
// Raw bytes, not express.json(): the signature covers the exact body.
// Comment events can be a few hundred KB, above express.raw's 100 KB default.
express.raw({ type: 'application/json', limit: '1mb' }),
(req, res) => {
const rawBody = req.body as Buffer;
if (!verifyDevStrideSignature(req.get('X-DevStride-Signature'), rawBody, secret)) {
res.status(401).send('invalid signature');
return;
}
const event = JSON.parse(rawBody.toString('utf8')) as DevStrideEvent;
res.sendStatus(204); // acknowledge first, work afterwards
if (handled.has(event.id)) return; // a redelivery of an event you already have
handled.add(event.id);
setImmediate(() => handleEvent(event));
}
);
function handleEvent(event: DevStrideEvent): void {
switch (event.type) {
case 'item.created':
case 'item.status_changed':
case 'comment.created':
case 'sla.at_risk':
case 'sla.breached':
console.log(event.type, event.id, event.data);
break;
default:
// 'ping' from the Test button, or an event type added after you wrote this.
break;
}
}
app.listen(3000);
import json
import os
from flask import Flask, request
from devstride_webhooks import verify_devstride_signature # from "Verifying Signatures"
SECRET = os.environ["DEVSTRIDE_WEBHOOK_SECRET"]
handled = set() # use a database table or Redis in production
app = Flask(__name__)
@app.post("/devstride/webhooks")
def devstride_webhook():
raw_body = request.get_data() # the exact bytes received; never request.json here
if not verify_devstride_signature(
request.headers.get("X-DevStride-Signature"), raw_body, SECRET
):
return "invalid signature", 401
event = json.loads(raw_body)
if event["id"] not in handled:
handled.add(event["id"])
enqueue(event) # hand off to a worker or job queue; do not process inline
return "", 204
def enqueue(event):
print(event["type"], event["id"])
import json
import os
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
from devstride_webhooks import verify_devstride_signature # from "Verifying Signatures"
SECRET = os.environ["DEVSTRIDE_WEBHOOK_SECRET"]
handled = set() # use a database table or Redis in production
app = FastAPI()
@app.post("/devstride/webhooks")
async def devstride_webhook(request: Request, background_tasks: BackgroundTasks):
raw_body = await request.body() # the exact bytes received
if not verify_devstride_signature(
request.headers.get("x-devstride-signature"), raw_body, SECRET
):
raise HTTPException(status_code=401, detail="invalid signature")
event = json.loads(raw_body)
if event["id"] not in handled:
handled.add(event["id"])
background_tasks.add_task(handle_event, event) # runs after the response is sent
return Response(status_code=204)
def handle_event(event):
print(event["type"], event["id"])
The Python samples import verify_devstride_signature from a devstride_webhooks.py module: copy the Python verification function into it. Go, Ruby, PHP and C# versions are on the same page.
handled set in these samples forgets everything on restart and is not shared between instances. In production, record each event id in a database table with a unique key (or Redis with a 30-day expiry) and check it before acting. See Deduplicate by event id.Start the receiver and make sure its public HTTPS URL is reachable.
https://hooks.example.com/devstride/webhooks. It must start with https://; private, loopback and cloud-metadata addresses are refused.The Signing secret dialog opens straight away.
The dialog shows the endpoint's Signing secret, a value starting with whsec_. Copy it into your receiver's DEVSTRIDE_WEBHOOK_SECRET setting (or your secret manager) and restart the receiver if needed, then click Done.
Use the whole value, including the whsec_ prefix, as the HMAC key. Do not decode it.
In the endpoint's row, click Test. DevStride immediately sends your endpoint a signed ping event and shows what came back under the row:
| You see | Meaning |
|---|---|
| Responded 204 in 143 ms | Your endpoint answered 2xx. You are done with setup. |
| Responded 401 in 98 ms — a 2xx status is required | It answered, but not with 2xx. A 401 from the samples above means the signature check failed: check the secret. |
| No response: … (15002 ms) | Nothing came back: a timeout, a TLS or connection error, or a refused address. The reason is in the message. |
A test is not stored in the delivery log and is never retried. Your receiver should accept a ping and do nothing with it; its body looks like this:
{
"id": "0f9a1c2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
"type": "ping",
"apiVersion": "2026-10-16",
"occurredAt": "2026-10-17T14:02:55.120Z",
"organizationId": "8f14e45f-ceea-4e7a-9b1d-3c5a2f6e9d10",
"origin": { "kind": "system", "actorId": null, "correlationId": "4c7d2e9a-1b3f-4a6e-8d5c-2f0b9e7a1c36" },
"data": { "endpointId": "3b0d2b8e-7f6a-4a46-9a3f-7a2c0f1a2b3c" }
}
Now make a change that matches one of your events: create an item if you subscribed to item.created, or comment on one for comment.created. Within seconds your receiver logs it, and the Delivery log on the same settings page shows a row for it:
succeeded with Last response 204: all good.pending with a Next attempt time: your endpoint did not answer 2xx and DevStride will retry. Click the arrow at the start of the row to see the exact Payload and the last failed response.The page refreshes itself every 30 seconds while it is open.
Webhooks
DevStride POSTs a signed JSON event to your HTTPS endpoint the moment an item, comment or SLA timer changes, so your systems react in seconds instead of polling the API.
Events & Payloads
The JSON envelope every DevStride webhook carries and every field of item.created, item.status_changed, comment.created, sla.at_risk and sla.breached, with when each fires and a realistic example of each.