Appearance
Verify signatures
Anyone can send a request to your endpoint. Before you trust a delivery, check that it came from JustDocs and wasn't changed or replayed.
Every delivery has a header like this:
http
JustDocs-Signature: t=1793722800,v1=6c1f0a…e9tis when it was signed, in Unix seconds.v1is the hex HMAC-SHA256 of<t>.<raw body>, keyed with your endpoint's whole signing secret, includingwhsec_.
The check
- Read the raw body as bytes, before any JSON parsing. Parsing and re-serializing changes it, and the signature won't match.
- Split the header into
tandv1. - Compute
HMAC-SHA256(secret, t + "." + body)as lowercase hex. - Compare it to
v1in constant time. - Reject it if
tis more than 5 minutes from your clock. That stops an old delivery being replayed.
Examples
Each example answers 204 only once the event is on a durable queue. If it can't be queued, the endpoint answers with an error and JustDocs delivers it again. A 2xx is final: an event acknowledged and then lost, for example by a crash before its work starts, is never sent again.
js
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.JUSTDOCS_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
export function verify(header, rawBody, secret = SECRET, now = Date.now() / 1000) {
const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!parts.v1 || !Number.isFinite(timestamp) || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length / 2 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex"));
}
const app = express();
// The raw body, not express.json(): the signature covers the exact bytes.
app.post("/justdocs", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.get("JustDocs-Signature"), req.body.toString("utf8"))) return res.sendStatus(400);
const event = JSON.parse(req.body);
try {
await queue.add(event); // a durable queue; the work happens there
} catch {
return res.sendStatus(500); // not queued: JustDocs delivers it again
}
res.sendStatus(204);
});python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["JUSTDOCS_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE_SECONDS = 300
def verify(header: str | None, raw_body: bytes, secret: bytes = SECRET) -> bool:
parts = dict(part.split("=", 1) for part in (header or "").split(",") if "=" in part)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret, f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/justdocs")
def justdocs():
raw = request.get_data() # the exact bytes
if not verify(request.headers.get("JustDocs-Signature"), raw):
return "", 400
event = json.loads(raw)
enqueue(event) # a durable queue; if this raises, Flask answers 500 and JustDocs delivers it again
return "", 204go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 300 // seconds
func Verify(header string, body []byte, secret string, now time.Time) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(part, "=")
switch key {
case "t":
t = value
case "v1":
v1 = value
}
}
timestamp, err := strconv.ParseInt(t, 10, 64)
if err != nil || v1 == "" || math.Abs(float64(now.Unix()-timestamp)) > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(body)
given, err := hex.DecodeString(v1)
return err == nil && hmac.Equal(given, mac.Sum(nil))
}
func Handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r, 1<<20))
if err != nil || !Verify(r.Header.Get("JustDocs-Signature"), body, os.Getenv("JUSTDOCS_WEBHOOK_SECRET"), time.Now()) {
w.WriteHeader(http.StatusBadRequest)
return
}
// A durable queue, not a goroutine: work held only in this process is lost if it stops.
if err := enqueue(r.Context(), body); err != nil {
w.WriteHeader(http.StatusInternalServerError) // not queued: JustDocs delivers it again
return
}
w.WriteHeader(http.StatusNoContent)
}Test your check
Run your check against this delivery, signed by the same code that signs real ones. Pin your clock to the timestamp, or turn off the age check for this test:
| Secret | whsec_test |
| Timestamp | 1700000000 |
| Body | the line below, exactly |
JustDocs-Signature | t=1700000000,v1=c3dda4885510629413595eb52b3a6af2475f6abe7b73e77757ba1531afa56e0c |
json
{"id":"evt_test","type":"ping","created_at":"2023-11-14T22:13:20.000Z","workspace_id":"f1a3c5e7-9b2d-4c4f-a6e8-1b3d5f7a9c2e","data":{"message":"A test event from JustDocs"}}Your check should accept it. It should reject it once you change one character of the body, and reject it again when your clock is more than five minutes from the timestamp. Use Send a test on the Webhooks page to check the real thing end to end.
Rotating the secret
New secret on an endpoint makes a new secret and shows it once. Deliveries from then on are signed with it, including retries of earlier events. Update your endpoint as soon as you rotate. Deliveries signed with the new secret fail your check until you do, and are then retried. To rotate with no gap, have your check accept either secret for a few minutes.