const crypto = require("crypto");
// Use the raw body: app.use(express.raw({ type: "application/json" }))
function verifyReplyBox(req, secret) {
const ts = req.header("X-Replybox-Timestamp");
const received = (req.header("X-Replybox-Signature") || "").replace("sha256=", "");
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.body}`) // req.body is the raw Buffer/string
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
app.post("/replybox", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyReplyBox(req, process.env.REPLYBOX_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body);
// ... handle event ...
res.sendStatus(200);
});
Security · Source: docs/public-api.md
¶ Webhook Signatures
Every webhook request is signed with the webhook's secret using HMAC-SHA256. Always verify the signature before trusting a payload.
What arrives
¶ Four headers on every delivery
Every delivery is a POST with these headers. Respond with any 2xx status to acknowledge.
message-insert.
X-Replybox-Event-IdA stable ID for the event — use it to deduplicate.
X-Replybox-TimestampUnix seconds when the request was signed.
X-Replybox-Signaturesha256=<hex> HMAC signature.
The signed string
¶ Timestamp and raw body, joined with a dot
The signed string is the request timestamp and the raw request body joined with a dot. Compare the result against the X-Replybox-Signature header (drop the sha256= prefix) using a constant-time comparison.
signed_string = "{X-Replybox-Timestamp}.{raw_request_body}"
signature = hex( HMAC_SHA256(secret, signed_string) )
Verify against the raw body bytes, before any JSON re-serialization — re-encoding can change whitespace and break the signature.
Verification steps
¶ What your handler does, in order
Before any JSON parsing.
{timestamp}.{raw_body}
With the webhook secret, hex-encoded.
Against the header, sha256= prefix dropped. Reject on mismatch.
import hmac, hashlib
from flask import request, abort
def verify_replybox(secret: str) -> bytes:
ts = request.headers.get("X-Replybox-Timestamp", "")
received = request.headers.get("X-Replybox-Signature", "").removeprefix("sha256=")
raw = request.get_data() # raw bytes, before JSON parsing
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
return raw
After the signature checks out
¶ Replay, retries, and destinations
¶ Timestamp checks
Optionally reject deliveries whose X-Replybox-Timestamp is more than a few minutes old to mitigate replay.
¶ Idempotency
Retries reuse the same X-Replybox-Event-Id. Because a timeout can occur after your server processed the event, treat handlers as idempotent — dedupe on X-Replybox-Event-Id.
¶ Destinations
Only public HTTPS destinations are accepted; requests to private, loopback, or internal addresses are rejected.
The secret
¶ Returned only at creation
The secret is required to verify signatures. Save it when you register the webhook.
secret when registering; if omitted, ReplyBox generates one and returns it once.
Not rotated by updateSecrets are not rotated by PATCH; create a new webhook if you need a new signing secret.
Not the internal connector HMAC. Customer webhooks sign {ts}.{raw_body}. Connector-to-API callbacks use a different signed string; see HMAC internal API.