Webhooks
PostAtom tells you what happened rather than making you ask. A delivery carries the event, a timestamp and a signature over both, and your receiver verifies it before trusting a word of it.
The signature
Every delivery carries one header. The signature covers the timestamp as well as the body, which is what makes a captured delivery useless tomorrow: a signature over the body alone stays valid for ever, so anyone who records one delivery can replay it whenever they like and you cannot tell.
PostNode-Signature: v1,t=1789273244,s=3f8c…Verification has three rules, and every hand-written receiver gets one of
them wrong: compare the digest in constant time rather than with ===, refuse a timestamp outside a narrow window,
and accept both secrets during a rotation. The examples below do all three
with nothing but each language’s standard library.
Verify a webhook (TypeScript)
Verify before you trust. The signature covers the timestamp as well as the body, so a captured delivery is useless tomorrow.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(header: string | undefined, raw: string, secrets: string[]) {
const match = /^v1,t=(\d{1,15}),s=([a-f0-9]{64})$/.exec(header?.trim() ?? '');
if (!match) return false;
const timestamp = Number(match[1]);
// Five minutes either way: an old delivery is as suspect as one from the future.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const supplied = Buffer.from(match[2], 'hex');
let verified = false;
for (const secret of secrets) {
const expected = createHmac('sha256', secret)
.update(`v1.${timestamp}.${raw}`)
.digest();
// Every secret is compared, in constant time.
if (timingSafeEqual(expected, supplied)) verified = true;
}
return verified;
}
app.post('/postnode', async (request, response) => {
// The raw body, exactly as sent. A parsed and re-serialized body will not verify.
const raw = await readRawBody(request);
const secrets = [process.env.POSTNODE_WEBHOOK_SECRET!];
if (!verify(request.headers['postnode-signature'], raw, secrets))
return response.status(400).end();
const event = JSON.parse(raw);
console.log(event.type, event.data);
response.status(204).end();
});Verify a webhook (Python)
Same three rules everywhere: compare in constant time, bind the timestamp, accept both secrets during a rotation.
import hashlib, hmac, json, os, re, time
def verify(header, raw, secrets):
match = re.fullmatch(r"v1,t=(\d{1,15}),s=([a-f0-9]{64})", (header or "").strip())
if not match or abs(time.time() - int(match[1])) > 300:
return False
signed = f"v1.{match[1]}.".encode() + raw
expected = [hmac.new(s.encode(), signed, hashlib.sha256).hexdigest() for s in secrets]
return any([hmac.compare_digest(e, match[2]) for e in expected])
@app.post("/postnode")
async def receive(request):
raw = await request.body()
secrets = [os.environ["POSTNODE_WEBHOOK_SECRET"]]
if not verify(request.headers.get("PostNode-Signature"), raw, secrets):
return Response(status_code=400)
event = json.loads(raw)
return Response(status_code=204)Verify a webhook (Go)
var signature = regexp.MustCompile(`^v1,t=(\d{1,15}),s=([a-f0-9]{64})$`)
func verify(header string, raw []byte, secrets []string) bool {
parts := signature.FindStringSubmatch(strings.TrimSpace(header))
if parts == nil {
return false
}
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
if age := time.Now().Unix() - timestamp; age > 300 || age < -300 {
return false
}
supplied, _ := hex.DecodeString(parts[2])
verified := false
for _, secret := range secrets {
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "v1.%d.", timestamp)
mac.Write(raw)
if hmac.Equal(mac.Sum(nil), supplied) {
verified = true
}
}
return verified
}Rotating a secret
Rotation issues a new secret and keeps the previous one valid for twenty-four hours. Verify against both for that window: verifying against only the new one makes every rotation a brief outage for whoever has not redeployed yet.
Rotate a webhook secret without an outage
Accept both for the overlap. Verifying against only the new secret makes every rotation a brief outage for anyone who has not redeployed.
verify(header, raw, [
process.env.POSTNODE_WEBHOOK_SECRET!,
process.env.POSTNODE_WEBHOOK_SECRET_PREVIOUS!,
]);When a delivery fails
A delivery that does not return a 2xx is retried on a widening schedule. The whole sequence spans about two days, which is long enough for a receiver to be repaired without PostAtom giving up on the event.
- 110 seconds
- 21 minute
- 35 minutes
- 415 minutes
- 51 hour
- 63 hours
- 76 hours
- 812 hours
- 924 hours
After the last attempt the endpoint is marked as failing and stops receiving until you re-enable it. Your receiver should be idempotent: a delivery that succeeded but whose response was lost is retried, and the same event arriving twice must not do the work twice.
What a receiver must do
- Read the raw body. A body that has been parsed and re-serialized will not verify — key order and whitespace are part of what was signed.
- Answer quickly. Acknowledge with a 2xx and do the work afterwards. A slow receiver becomes a failing one.
- Treat the event as a notification, not as truth. If the decision matters, read the post back through the API.