Webhooks

Verifying Signatures

How DevStride signs every webhook with HMAC-SHA256, the exact string it signs, the headers it sends, replay protection, secret rotation and raw-body pitfalls, with tested verification code in Node.js, Python, Go, Ruby, PHP and C#.

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.

How signing works

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:

  1. takes the current time in Unix seconds, t;
  2. builds the signed string: t, a full stop, then the exact bytes of the request body: {t}.{body};
  3. computes HMAC-SHA256 of that string, keyed with the whole secret (including the whsec_ prefix, as UTF-8 bytes), and writes it as 64 lowercase hex characters;
  4. sends it in the 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.

Headers and signature

Every delivery carries these headers (header names are case-insensitive):

HeaderExampleMeaning
X-DevStride-Signaturet=1760709791,v1=a8bb78…The timestamp and one or two signatures.
X-DevStride-Timestamp1760709791The same t, on its own.
X-DevStride-Event-Id2f6c1d0a-8b4e-5c3f-9a71-0e5d4c3b2a19The event's id, identical on every retry and replay. Deduplicate on it.
X-DevStride-Delivery9f1c6c3a-0b7e-4e2d-8d0a-4c2f3b1a5e77This delivery's id in the delivery log: one per event per endpoint, the same across retries and replays.
X-DevStride-Event-Typecomment.createdThe event type, so you can route before parsing.
Content-Typeapplication/jsonAlways JSON.
User-AgentDevStride-Webhooks/1Identifies 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.

Verification in other languages

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.

Python

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.

Go

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

Ruby

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

<?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').

C#

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();
});

Replay protection

Two checks together stop an attacker who captured a real request from sending it to you again:

  • The timestamp window. Reject a request whose 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).
  • The event id. Inside the window a captured request still verifies, so also record each 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.

Constant-time comparison

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.

Secret rotation

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.

Raw-body pitfalls

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.

StackDoAvoid
Expressexpress.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
FastifyA content-type parser that keeps this route's body as a BufferVerifying the parsed object
Flask / Django / FastAPIrequest.get_data() / request.body / await request.body()request.json / json.dumps(...)
Railsrequest.raw_postparams.to_json
ASP.NET CoreCopy Request.Body into a byte array firstBinding to a model, then serializing it
AWS Lambda behind API Gatewayevent.body, base64-decoded first when event.isBase64Encoded is trueRe-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.

Test your verifier

Run your function against this fixed example before connecting a real endpoint. These values are for testing only: no real endpoint uses this secret.

InputValue
Secretwhsec_Zm9yLWRvY3Mtb25seS1uZXZlci1hLXJlYWwtc2VjcmV0
Timestamp (t, and your "now")1760709791
Bodythe JSON below, exactly these bytes: one line, no trailing newline
Expected v1a8bb7839b9aeb19af6b3195d9b3ba3d3231b107434fd44d48981035827bc32f4
{"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"}
  • With the header t=1760709791,v1=a8bb7839b9aeb19af6b3195d9b3ba3d3231b107434fd44d48981035827bc32f4 and "now" set to 1760709791, your function returns true.
  • With "now" set to 1760710092 (301 seconds later), it returns false.
  • With the body changed in any way (for example "customer" replaced by "member"), it returns false.
  • With the two-signature header of a rotation, t=1760709791,v1=a8bb7839b9aeb19af6b3195d9b3ba3d3231b107434fd44d48981035827bc32f4,v1=be8e8ee0163bd2194be3b66a96e2749db980895d960d68296270de87b968c7bc, it returns true both for the secret above and for whsec_cHJldmlvdXMtc2VjcmV0LWZvci1kb2NzLW9ubHk.