standardwebhooks 0.1.0

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 WebhookVerificationException 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 (WebhookVerificationException 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.

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 (WebhookVerificationException)
    {
        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.

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
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.

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.

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