Verify webhook requests
Check the HMAC-SHA256 signature on every webhook before you trust it.
Every webhook POST is signed with your endpoint's secret. Recompute the signature from the raw body and compare it before you parse the JSON.
How signing works
| Header | Value |
|---|---|
X-Capsule-Timestamp |
Unix time, as a string, when the request was signed. |
X-Capsule-Signature |
sha256= followed by the hex HMAC-SHA256 of {timestamp}.{body}, keyed with the secret. |
{body} is the exact bytes of the request body. Delivery webhooks use the endpoint's Signing secret (secret in the API). Inbox webhooks use the same headers, keyed with the inbox's webhook_secret.
Verify a request
- Read the raw request body before any JSON parsing. Re-serialized JSON won't match.
- Read
X-Capsule-TimestampandX-Capsule-Signature. - Compute the HMAC-SHA256 of
{timestamp}.{body}with your secret, as lowercase hex. - Strip
sha256=from the header and compare the two values in constant time. - Reject the request with
401if they don't match.
Each attempt, including a retry, is signed with a fresh timestamp. To guard against replayed requests, you can also reject timestamps more than a few minutes old.
With an SDK
The Node.js and Ruby SDKs include a verify helper. Install them from SDKs.
verify.js
1import { PostShiba } from "@postshiba/node"
2
3const postshiba = new PostShiba(process.env.POSTSHIBA_API_KEY)
4
5const ok = postshiba.webhooks.verify(
6 rawBody,
7 request.headers["x-capsule-timestamp"],
8 request.headers["x-capsule-signature"],
9 process.env.POSTSHIBA_WEBHOOK_SECRET
10)
verify.rb
1require "postshiba"
2
3ok = PostShiba::Webhooks.verify(
4 payload: request.raw_post,
5 signature: request.headers["X-Capsule-Signature"],
6 timestamp: request.headers["X-Capsule-Timestamp"],
7 secret: ENV["POSTSHIBA_WEBHOOK_SECRET"]
8)
verify-express.js
1import express from "express"
2import { createHmac, timingSafeEqual } from "node:crypto"
3
4const app = express()
5
6app.post("/webhooks/postshiba", express.raw({ type: "application/json" }), (req, res) => {
7 const timestamp = req.get("X-Capsule-Timestamp") || ""
8 const given = (req.get("X-Capsule-Signature") || "").replace(/^sha256=/, "")
9 const expected = createHmac("sha256", process.env.POSTSHIBA_WEBHOOK_SECRET)
10 .update(`${timestamp}.${req.body}`)
11 .digest("hex")
12
13 const a = Buffer.from(expected)
14 const b = Buffer.from(given)
15 if (a.length !== b.length || !timingSafeEqual(a, b)) {
16 return res.sendStatus(401)
17 }
18
19 for (const event of JSON.parse(req.body)) {
20 console.log(event.event, event.email, event.sg_event_id)
21 }
22 res.sendStatus(204)
23})
verify.py
1import hashlib
2import hmac
3import os
4
5
6def verify(raw_body: bytes, timestamp: str, signature: str) -> bool:
7 secret = os.environ["POSTSHIBA_WEBHOOK_SECRET"].encode()
8 signed = timestamp.encode() + b"." + raw_body
9 expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
10 given = signature.removeprefix("sha256=")
11 return hmac.compare_digest(expected, given)
verify-openssl.rb
1require "openssl"
2
3def verify(raw_body, timestamp, signature)
4 expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("POSTSHIBA_WEBHOOK_SECRET"), "#{timestamp}.#{raw_body}")
5 given = signature.to_s.delete_prefix("sha256=")
6 OpenSSL.secure_compare(expected, given)
7end