Send a Signed Webhook With Standard Webhooks#
Send a webhook that follows the Standard Webhooks format, so any compatible receiver can verify that the request came from you and was not modified.
What Is an Outgoing Webhook?#
A webhook is an HTTP request that one system sends to another when something happens. It is the push alternative to polling an API repeatedly.
An outgoing webhook means this script is the sender. The receiving service exposes a URL and handles the request. Outgoing webhooks are commonly used to:
- Send notifications to Slack, Discord, or another chat service.
- Trigger a deployment or automation workflow.
- Sync an event into another system.
- Report job results or failures.
The receiving endpoint usually expects a POST with a JSON body and
should return a 2xx status. The webhook URL is often a secret or
capability, because anyone with it may be able to trigger work. Receivers
should also be idempotent, since webhook delivery can be retried.
Signing matters because a webhook URL can leak, and an HMAC signature lets the receiver verify that a request came from you and was not modified.
The Script#
// send-signed-webhook.js
import env from "env";
function base64ToBytes(value) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bytesToBase64(bytes) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
const url = env.get("WEBHOOK_URL");
const secret = env.get("WEBHOOK_SECRET");
// Standard Webhooks secrets are base64 with a "whsec_" prefix.
const keyBytes = base64ToBytes(secret.replace(/^whsec_/, ""));
// The ID is used as an idempotency key by the receiver.
// It must not contain a dot.
const messageId = `msg_${crypto.randomUUID()}`;
// This is the delivery attempt time, not the event time in the body.
const timestamp = Math.floor(Date.now() / 1000);
// Sign exactly the JSON string that is sent.
const body = JSON.stringify({
type: "job.finished",
timestamp: new Date().toISOString(),
data: {
source: "geartrain",
message: "Daily job finished",
},
});
// Import the shared secret as a non-extractable HMAC-SHA256 key.
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
// Standard Webhooks signs "<id>.<timestamp>.<body>".
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(`${messageId}.${timestamp}.${body}`),
);
const signatureHeader = `v1,${bytesToBase64(new Uint8Array(signature))}`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"webhook-id": messageId,
"webhook-timestamp": String(timestamp),
"webhook-signature": signatureHeader,
},
body,
});
// A non-2xx response means the receiver rejected the webhook.
if (!res.ok) {
throw new Error(`Webhook failed: ${res.status}`);
}
console.log("Signed webhook delivered");
How It Works#
- Standard Webhooks signs the string
${webhook-id}.${webhook-timestamp}.${body}and sends the signature asv1,<base64>. - The format is defined by the Standard Webhooks specification.
- The receiver verifies the raw body, rejects stale timestamps, and compares signatures in constant time.
- HMAC proves authenticity and integrity, but it does not encrypt the body. Never log the secret or signature.
Setup#
-
Create a receiving endpoint that accepts
POSTrequests with a JSON body. -
Generate a secret:
openssl rand -base64 32 -
Prefix the generated value with
whsec_, then create two secrets:Secret Value WEBHOOK_URLThe endpoint that receives the post WEBHOOK_SECRETThe whsec_-prefixed signing secret -
Configure the receiver with a Standard Webhooks verification library, or follow the specification above.
-
Create a job that points at
send-signed-webhook.jsand run it once.