standardwebhooks ~main

Standard Webhooks (standardwebhooks.com) signing and verification for D


To use this package, run the following command in your project's root directory:

Manual usage
Put the following dependency into your project's dependences section:


This package provides sub packages which can be used individually:

standardwebhooks:vibe - vibe.d HTTP integration for standardwebhooks

standardwebhooks:ed25519 - Asymmetric ed25519 (v1a) signatures for standardwebhooks, via libsodium

standardwebhooks.d

CI codecov Dub version Dub downloads License: MIT API docs

A faithful Standard Webhooks implementation for the D programming language — sign outgoing webhooks and verify incoming ones with HMAC-SHA256, constant-time comparison, and timestamp replay protection.

The core is dependency-free (Phobos only). Two optional subpackages extend it: standardwebhooks:vibe wires it directly into vibe.d HTTP requests, and standardwebhooks:ed25519 adds the spec's asymmetric v1a signatures via libsodium.

Why Standard Webhooks?

Standard Webhooks is an open specification for securing webhooks, backed by a set of reference libraries across languages. A sender signs the message id, timestamp, and raw body; a receiver recomputes the signature and rejects anything that does not match or is too old. This library produces and accepts byte-for-byte the same webhook-signature values as the official Go, Python, JavaScript, and Rust libraries (verified against their published test vectors).

Install

dub add standardwebhooks

or in dub.json:

"dependencies": {
    "standardwebhooks": "~>0.1"
}

Quickstart

import standardwebhooks;

void main()
{
    // The secret you share with the other side. The `whsec_` prefix is
    // optional; raw key bytes are also supported via Webhook.fromRaw.
    auto wh = Webhook("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

    string id = "msg_2b3c4d5e";
    long timestamp = 1614265330;          // unix seconds
    string payload = `{"event":"ping"}`;  // the raw request body

    // --- Sending: build the three headers to attach to your HTTP request ---
    string[string] headers = wh.signHeaders(id, timestamp, payload);
    // headers["webhook-id"], ["webhook-timestamp"], ["webhook-signature"]

    // --- Receiving: verify an incoming request (throws on any mismatch) ---
    auto verified = wh.verify(payload, headers);
    assert(verified == payload);
}

verify throws a WebhookException if a header is missing, the timestamp is outside the tolerance window (±5 minutes by default), or no signature matches. On success it returns the raw payload so you can parse it in the same expression:

import std.json : parseJSON;

try
{
    auto data = parseJSON(wh.verify(body_, request.headers));
    handleEvent(data);
}
catch (WebhookException e)
{
    // Reject with HTTP 400; e.error gives the machine-readable cause.
}

API

All of the following live on the Webhook value type.

MemberPurpose
Webhook(string secret)Construct from a whsec_-prefixed or bare base64 secret. Padded or unpadded base64 both work.
Webhook.fromRaw(ubyte[] key)Construct directly from raw HMAC key bytes.
sign(id, timestamp, payload)Returns a single v1,<base64> signature. timestamp is unix seconds or a SysTime.
signHeaders(id, timestamp, payload)Returns the webhook-id / webhook-timestamp / webhook-signature headers as an associative array.
verify(payload, headers)Verifies signature and timestamp; returns the payload or throws.
verifyIgnoringTimestamp(payload, headers)Verifies the signature only (no replay-window check).
toleranceSecondsField; the replay window in seconds (default 300).

Header lookup is case-insensitive and also accepts the Svix-branded svix-id / svix-timestamp / svix-signature aliases as a fallback, so payloads from Svix-powered senders verify without remapping.

Signature scheme

The signed content is the literal string {id}.{timestamp}.{payload}, HMAC'd with SHA-256 under the decoded secret, then standard-base64 encoded. The webhook-signature header is a space-delimited list of v1,<sig> entries (supporting zero-downtime secret rotation); verification succeeds if any v1 entry matches in constant time. Entries with other versions are skipped. As an intentional anti-amplification divergence from the reference libraries, at most 64 signature entries are examined per header; any beyond that cap are ignored, which stays well above any realistic key-rotation overlap.

The core implements the symmetric (HMAC, whsec_, v1) scheme, which is what every official reference library ships, and treats unknown versions (including v1a) as skip-not-error. The spec's asymmetric v1a (ed25519) scheme is available as the optional standardwebhooks:ed25519 subpackage.

vibe.d integration

The standardwebhooks:vibe subpackage adds one-call helpers for vibe.d HTTP:

dub add standardwebhooks:vibe
import standardwebhooks;
import standardwebhooks.vibe;
import vibe.http.server;

void handler(HTTPServerRequest req, HTTPServerResponse res)
{
    auto wh = Webhook("whsec_...");
    try
    {
        auto payload = wh.verifyRequest(req);  // reads body + headers, verifies
        res.writeBody("ok");
    }
    catch (WebhookException)
    {
        res.statusCode = HTTPStatus.badRequest;
        res.writeBody("invalid signature");
    }
}

For outgoing requests, signRequest(wh, clientReq, id, timestamp, payload) sets the three headers; send payload as the body so the signed bytes match the sent bytes.

Both helpers are templated on the verifier/signer type, so they bridge an AsymmetricWebhook just as well as a Webhook. Because the binding is structural, standardwebhooks:vibe keeps zero dependency on standardwebhooks:ed25519; symmetric-only users link no libsodium.

Asymmetric (ed25519) signatures

The spec also defines an asymmetric scheme (v1a): the sender signs with an ed25519 private key and receivers verify with the matching public key, so a leaked verifier cannot forge messages. This is provided by the optional standardwebhooks:ed25519 subpackage, which links the system libsodium for the ed25519 primitives (so it is kept out of the dependency-free core).

# Install libsodium first: apt-get install libsodium-dev | brew install libsodium
dub add standardwebhooks:ed25519

libsodium 1.0.4 or newer is required (the detached ed25519 API the subpackage calls).

On macOS the subpackage searches the Homebrew (/opt/homebrew/lib, /usr/local/lib) and MacPorts (/opt/local/lib) prefixes. If libsodium lives elsewhere — Nix, a custom prefix, or another package manager — supply your own -L<dir> linker flag (e.g. via dub's lflags or LDFLAGS) so the linker can find it.

On Windows there is no system libsodium; install it with vcpkg (vcpkg install libsodium:x64-windows) so the import library is on the linker's LIB path and the matching DLL is on PATH at runtime — mirroring what CI does. The subpackage links libsodium via dub's libs-windows.

import standardwebhooks;
import standardwebhooks.ed25519;

// Sender holds a whsk_ signing key (or derive one from a 32-byte seed via
// AsymmetricWebhook.fromSeed); it can both sign and verify.
auto signer = AsymmetricWebhook("whsk_...");
auto headers = signer.signHeaders("msg_2b3c", 1614265330, `{"event":"ping"}`);
// signer.publicKeyEncoded() yields the whpk_ string to hand to receivers.

// Receiver holds only the whpk_ public key; it can verify but never sign.
auto verifier = AsymmetricWebhook("whpk_...");
auto payload = verifier.verify(`{"event":"ping"}`, headers);

AsymmetricWebhook mirrors the Webhook API — sign, signHeaders, verify, verifyIgnoringTimestamp, and the toleranceSeconds field all behave the same, with the same case-insensitive/svix-* header handling and replay window. Keys use the Standard Webhooks serialisation: whsk_ + base64 of the 64-byte seed ‖ public_key, and whpk_ + base64 of the 32-byte public key. Calling sign on a verify-only (whpk_) instance throws with WebhookError.signingKeyRequired.

See examples/asymmetric for a runnable end-to-end demo.

Accepting both schemes (v1 + v1a)

A receiver that must accept either a symmetric v1 or an asymmetric v1a signature builds one verifier of each and passes them to verifyAny. It tries the symmetric Webhook first, then the asymmetric AsymmetricWebhook, and returns the payload as soon as one accepts it — throwing only if neither does.

import standardwebhooks;
import standardwebhooks.ed25519;

auto symmetric = Webhook("whsec_...");
auto asymmetric = AsymmetricWebhook("whpk_...");

// Accepts a v1 (HMAC) signature or a v1a (ed25519) one.
auto payload = verifyAny(symmetric, asymmetric, body, headers);

verifyAnyIgnoringTimestamp is the replay-window-skipping counterpart, matching the per-verifier verifyIgnoringTimestamp. Each verifier applies its own toleranceSeconds.

Security

  • Constant-time comparison. Signatures are compared without short-circuiting to avoid timing side channels.
  • Replay protection. Payloads older or newer than toleranceSeconds (±5 minutes by default) are rejected.
  • Verify the raw body. Always verify the exact bytes you received, before parsing. Re-serializing JSON first will change the bytes and break verification — that is by design.
  • Don't treat a signature string as canonical. Like every reference implementation, an ed25519 (v1a) signature is base64 of the raw bytes, and base64 leaves a few trailing bits unconstrained — so several distinct encoded strings decode to the same valid signature. Never use the raw signature string as a deduplication or idempotency key; use the webhook-id instead.

See SECURITY.md to report a vulnerability.

Development

just build   # dub build
just test    # dub test (unit tests, incl. the reference vectors)
just fmt     # dfmt --inplace
just lint    # dscanner static analysis

(Or use dub directly; prefix with ulimit -n 65536 && if your terminal sets a huge file-descriptor limit — see CONTRIBUTING.md.)

License

MIT. The Standard Webhooks specification is also MIT-licensed by its authors; see NOTICE.

Authors:
  • Peter Alexander
Sub packages:
standardwebhooks:vibe, standardwebhooks:ed25519
Dependencies:
none
Versions:
0.2.0 2026-Jun-14
0.1.0 2026-Jun-14
~main 2026-Jun-14
~fix/99-zeroize-transient-secret-buffers 2026-Jun-14
~fix/98-siglen-runtime-check 2026-Jun-14
Show all 8 versions
Download Stats:
  • 0 downloads today

  • 0 downloads this week

  • 0 downloads this month

  • 0 downloads total

Score:
0.0
Short URL:
standardwebhooks.dub.pm