Your endpoint's URL is public, so anyone can send it a request. The signature is how you know a request really came from DevStride and was not changed on the way. Verify it on every request, before you trust or act on anything in it.
Every endpoint has its own signing secret, a value that starts with whsec_, shown once when you create the endpoint or rotate its secret. For each delivery attempt DevStride:
t;t, a full stop, then the exact bytes of the request body: {t}.{body};whsec_ prefix, as UTF-8 bytes), and writes it as 64 lowercase hex characters;X-DevStride-Signature header as t=<t>,v1=<hex>.To verify, your receiver repeats step 3 with its copy of the secret and compares the result with the v1 value in constant time, after checking that t is recent.
Every delivery carries these headers (header names are case-insensitive):
| Header | Example | Meaning |
|---|---|---|
X-DevStride-Signature | t=1760709791,v1=a8bb78… | The timestamp and one or two signatures. |
X-DevStride-Timestamp | 1760709791 | The same t, on its own. |
X-DevStride-Event-Id | 2f6c1d0a-8b4e-5c3f-9a71-0e5d4c3b2a19 | The event's id, identical on every retry and replay. Deduplicate on it. |
X-DevStride-Delivery | 9f1c6c3a-0b7e-4e2d-8d0a-4c2f3b1a5e77 | This delivery's id in the delivery log: one per event per endpoint, the same across retries and replays. |
X-DevStride-Event-Type | comment.created | The event type, so you can route before parsing. |
Content-Type | application/json | Always JSON. |
User-Agent | DevStride-Webhooks/1 | Identifies DevStride. Useful for routing; never proof of origin. |
A ping from the Test button carries the first three, but not X-DevStride-Delivery or X-DevStride-Event-Type, because a test is not part of the delivery log.
The header grammar is:
X-DevStride-Signature: t=<unix seconds>,v1=<64 hex chars>[,v1=<64 hex chars>]
Accept the request when any v1 value matches. For 24 hours after a secret rotation there are two v1 values, one per secret, so either your old or your new secret verifies.
Here is the reference implementation in TypeScript (Node.js). DevStride's own test suite runs this exact function against the signer the delivery worker uses, so it is known to be correct:
import { createHmac, timingSafeEqual } from 'node:crypto';
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)
);
}
Call it with the header value and the raw request body as a Buffer. It returns false for a missing or malformed header, a timestamp more than five minutes from your clock, or a body or secret that does not match. For a complete Express server around it, see the Quickstart.
Each function below follows the same steps as the reference implementation and has been checked against the test vector at the end of this page.
Uses only the standard library.
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300
def verify_devstride_signature(signature_header, raw_body, secret, now=None):
"""True when any v1 signature matches HMAC-SHA256(secret, "{t}.{raw_body}")
and t is within 5 minutes of now. raw_body must be the exact bytes received."""
if not signature_header:
return False
timestamp = None
signatures = []
for part in signature_header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and re.fullmatch(r"[0-9]+", value):
timestamp = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if timestamp is None or not signatures:
return False
now = int(time.time()) if now is None else now
# Older (or newer) than 5 minutes: a replayed capture, or a clock to fix.
if abs(now - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
# Two v1 values appear for 24 h after a rotation; either secret verifies.
return any(hmac.compare_digest(candidate, expected) for candidate in signatures)
In Flask pass request.get_data(), in Django request.body, in FastAPI or Starlette await request.body(). Full Flask and FastAPI receivers are in the Quickstart.
package devstride
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"strings"
"time"
)
var (
digitsPattern = regexp.MustCompile(`^[0-9]+$`)
signaturePattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// VerifyDevStrideSignature reports whether any v1 signature in header matches
// HMAC-SHA256(secret, "{t}.{rawBody}") and t is within 5 minutes of now.
// rawBody must be the exact bytes received.
func VerifyDevStrideSignature(header string, rawBody []byte, secret string, now time.Time) bool {
timestamp := int64(-1)
var signatures [][]byte
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch {
case key == "t" && digitsPattern.MatchString(value):
if t, err := strconv.ParseInt(value, 10, 64); err == nil {
timestamp = t
}
case key == "v1" && signaturePattern.MatchString(value):
if sig, err := hex.DecodeString(value); err == nil {
signatures = append(signatures, sig)
}
}
}
if timestamp < 0 || len(signatures) == 0 {
return false
}
// Older (or newer) than 5 minutes: a replayed capture, or a clock to fix.
if age := now.Unix() - timestamp; age > 300 || age < -300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
// Two v1 values appear for 24 h after a rotation; either secret verifies.
for _, candidate := range signatures {
if hmac.Equal(candidate, expected) {
return true
}
}
return false
}
In a net/http handler, read the body before anything else and pass time.Now():
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil || !VerifyDevStrideSignature(r.Header.Get("X-DevStride-Signature"), body, secret, time.Now()) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
// then decode body with json.Unmarshal and hand the event to a worker
require "openssl"
TOLERANCE_SECONDS = 300
# True when any v1 signature matches HMAC-SHA256(secret, "{t}.{raw_body}") and
# t is within 5 minutes of now. raw_body must be the exact bytes received.
def verify_devstride_signature(header, raw_body, secret, now: Time.now.to_i)
return false if header.nil? || header.empty?
timestamp = nil
signatures = []
header.split(",").each do |part|
key, value = part.strip.split("=", 2)
if key == "t" && value&.match?(/\A[0-9]+\z/)
timestamp = value.to_i
elsif key == "v1" && value&.match?(/\A[0-9a-f]{64}\z/)
signatures << value
end
end
return false if timestamp.nil? || signatures.empty?
# Older (or newer) than 5 minutes: a replayed capture, or a clock to fix.
return false if (now - timestamp).abs > TOLERANCE_SECONDS
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
# Two v1 values appear for 24 h after a rotation; either secret verifies.
signatures.any? { |candidate| secure_compare(candidate, expected) }
end
# Constant-time comparison of two equal-length strings.
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
end
In Rails pass request.raw_post; in Sinatra or plain Rack, request.body.read (call request.body.rewind first if something else may have read it). In Rails you can use ActiveSupport::SecurityUtils.secure_compare instead of the helper.
<?php
/**
* True when any v1 signature matches HMAC-SHA256($secret, "{t}.{$rawBody}")
* and t is within 5 minutes of now. $rawBody must be the exact bytes received.
*/
function verifyDevStrideSignature(?string $header, string $rawBody, string $secret, ?int $now = null): bool
{
if ($header === null || $header === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't' && preg_match('/\A[0-9]+\z/', $value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && preg_match('/\A[0-9a-f]{64}\z/', $value)) {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Older (or newer) than 5 minutes: a replayed capture, or a clock to fix.
if (abs(($now ?? time()) - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// Two v1 values appear for 24 h after a rotation; either secret verifies.
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}
Read the raw body with file_get_contents('php://input') and the header with $_SERVER['HTTP_X_DEVSTRIDE_SIGNATURE'] ?? null. In Laravel use $request->getContent() and $request->header('X-DevStride-Signature').
For .NET 6 and later.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
public static class DevStrideWebhooks
{
private static readonly Regex Digits = new(@"\A[0-9]+\z");
private static readonly Regex Signature = new(@"\A[0-9a-f]{64}\z");
/// True when any v1 signature matches HMAC-SHA256(secret, "{t}.{rawBody}")
/// and t is within 5 minutes of now. rawBody must be the exact bytes received.
public static bool VerifySignature(string? header, byte[] rawBody, string secret, long? nowSeconds = null)
{
if (string.IsNullOrEmpty(header)) return false;
long? timestamp = null;
var signatures = new List<byte[]>();
foreach (var part in header.Split(','))
{
var pair = part.Trim().Split('=', 2);
if (pair.Length != 2) continue;
if (pair[0] == "t" && Digits.IsMatch(pair[1]) && long.TryParse(pair[1], out var t))
timestamp = t;
else if (pair[0] == "v1" && Signature.IsMatch(pair[1]))
signatures.Add(Convert.FromHexString(pair[1]));
}
if (timestamp is null || signatures.Count == 0) return false;
// Older (or newer) than 5 minutes: a replayed capture, or a clock to fix.
var now = nowSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - timestamp.Value) > 300) return false;
var prefix = Encoding.UTF8.GetBytes($"{timestamp.Value}.");
var signed = new byte[prefix.Length + rawBody.Length];
prefix.CopyTo(signed, 0);
rawBody.CopyTo(signed, prefix.Length);
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed);
// Two v1 values appear for 24 h after a rotation; either secret verifies.
return signatures.Any(candidate => CryptographicOperations.FixedTimeEquals(candidate, expected));
}
}
In ASP.NET Core, copy the body into a byte array before model binding touches it:
app.MapPost("/devstride/webhooks", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var rawBody = buffer.ToArray();
if (!DevStrideWebhooks.VerifySignature(request.Headers["X-DevStride-Signature"].ToString(), rawBody, secret))
return Results.Unauthorized();
// parse rawBody with System.Text.Json and queue the event for processing
return Results.NoContent();
});
Two checks together stop an attacker who captured a real request from sending it to you again:
t is more than 300 seconds (5 minutes) away from your clock, in either direction. Every attempt is signed fresh with the time it was sent, so a retry that arrives hours after the change still verifies; only a stale copy fails. Keep your server's clock synchronized (NTP).X-DevStride-Event-Id you have processed and skip repeats. You need this anyway, because DevStride itself can deliver an event more than once; see At-least-once delivery.Compare signatures with your language's constant-time function: crypto.timingSafeEqual (Node.js), hmac.compare_digest (Python), hmac.Equal (Go), hash_equals (PHP), CryptographicOperations.FixedTimeEquals (.NET). An ordinary == returns as soon as one character differs, and the time that takes leaks, a little at a time, how much of a forged signature was right.
When you Rotate secret (see Managing Endpoints), DevStride signs every delivery with both the new secret and the previous one for 24 hours, so the header carries two v1 values, the new secret's first:
X-DevStride-Signature: t=1760709791,v1=<signed with the new secret>,v1=<signed with the previous secret>
Because your verifier accepts any matching v1, the receiver keeps working with the old secret until you deploy the new one, and with the new one as soon as you do. Nothing has to switch at the same moment; you have a whole day. After 24 hours only the new secret's signature is sent.
The signature covers the exact bytes DevStride sent. Parsing the JSON and serializing it again (even with no visible change) alters spacing, key order or escaping, and the signature no longer matches. This is the most common cause of a receiver that rejects every delivery.
| Stack | Do | Avoid |
|---|---|---|
| Express | express.raw({ type: 'application/json', limit: '1mb' }) on the webhook route, then req.body (a Buffer) | express.json() before verifying, then JSON.stringify(req.body) |
| Next.js (App Router) | Buffer.from(await request.arrayBuffer()) | await request.json() before verifying |
| Fastify | A content-type parser that keeps this route's body as a Buffer | Verifying the parsed object |
| Flask / Django / FastAPI | request.get_data() / request.body / await request.body() | request.json / json.dumps(...) |
| Rails | request.raw_post | params.to_json |
| ASP.NET Core | Copy Request.Body into a byte array first | Binding to a model, then serializing it |
| AWS Lambda behind API Gateway | event.body, base64-decoded first when event.isBase64Encoded is true | Re-serializing the parsed body |
Also check that nothing in front of your code changes the body: some API gateways, WAFs and logging proxies re-encode JSON. And parse the body only after verifying it.
comment.created event can carry up to 256 KB of comment HTML, so allow request bodies of at least 1 MB. Express's raw and json parsers default to 100 KB and answer larger events with 413, which DevStride counts as a failed delivery and retries.Run your function against this fixed example before connecting a real endpoint. These values are for testing only: no real endpoint uses this secret.
| Input | Value |
|---|---|
| Secret | whsec_Zm9yLWRvY3Mtb25seS1uZXZlci1hLXJlYWwtc2VjcmV0 |
Timestamp (t, and your "now") | 1760709791 |
| Body | the JSON below, exactly these bytes: one line, no trailing newline |
Expected v1 | a8bb7839b9aeb19af6b3195d9b3ba3d3231b107434fd44d48981035827bc32f4 |
{"id":"2f6c1d0a-8b4e-5c3f-9a71-0e5d4c3b2a19","data":{"comment":{"id":"c-1"}},"type":"comment.created","origin":{"kind":"customer","actorId":"req-1","correlationId":"corr-1"},"apiVersion":"2026-10-16","occurredAt":"2026-10-17T14:03:11.000Z","organizationId":"1a2b3c4d-0000-4000-8000-000000000001"}
t=1760709791,v1=a8bb7839b9aeb19af6b3195d9b3ba3d3231b107434fd44d48981035827bc32f4 and "now" set to 1760709791, your function returns true.1760710092 (301 seconds later), it returns false."customer" replaced by "member"), it returns false.t=1760709791,v1=a8bb7839b9aeb19af6b3195d9b3ba3d3231b107434fd44d48981035827bc32f4,v1=be8e8ee0163bd2194be3b66a96e2749db980895d960d68296270de87b968c7bc, it returns true both for the secret above and for whsec_cHJldmlvdXMtc2VjcmV0LWZvci1kb2NzLW9ubHk.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.
Delivery, Retries & Ordering
What counts as a successful webhook delivery, the request time limits, the ten-attempt retry schedule over about 24 hours, per-endpoint ordering, at-least-once delivery, failing and recovered alerts, and how long the delivery log keeps each delivery.