Webhooks and signatures
Jabee posts to your address when an approval is answered, expires or fails. Every delivery is signed.
Setting one up
On Management → Approvals, set an https address and copy the
signing secret, which starts whsec_. The page has a Send a test button
that posts a signed approval.test event and shows you the status code
and body your server returned. Use it before you wire anything up: it will tell you
about a bad path or a redirect in a second rather than after a deploy.
What arrives
POST <your address>
Content-Type: application/json
X-Jabee-Event: approval.answered
X-Jabee-Delivery: 7c9e...
X-Jabee-Signature: t=1762596751,v1=5d41402abc4b2a76b9719d911017c592...
{ "id": "7c9e...", "event": "approval.answered", "approval": { ... } }
The events are approval.answered, approval.expired,
approval.failed, message.received and
approval.test.
The other direction: message.received
When the handset you verified types something on the alert thread that is not an
answer to a request and not STOP, it arrives at the same address. That is
how a reply from a phone becomes an instruction to your own system — on a WhatsApp
number you never had to buy or verify.
X-Jabee-Event: message.received
{
"id": "4b1c...",
"event": "message.received",
"message": {
"text": "redeploy staging",
"from": "9715xxxxxxxx",
"received_at": "2026-11-13T11:20:04.000Z",
"about_approval_id": null,
"whatsapp_message_id": "wamid...."
}
}
about_approval_id is set when the message was a swipe-reply to one of
your requests, so a system with several open can tell which one "do it" was about. It
is null for an ordinary message.
What it is not: a customer channel (only the handset you verified reaches it — a stranger messaging this number reaches no account at all), an acknowledgement (tapping Got it marks an alert seen and sends nothing here), or a command we interpret. Treat the text as untrusted and authorise it on your side: anyone holding that phone can send it. Nothing is relayed until you set an address, and a message with no words in it is never relayed.
Verifying it
v1 is the hex HMAC-SHA256 of "<t>.<raw body>"
under your signing secret. Three rules, and skipping any one of them makes the check
worthless:
- Compute it over the raw bytes, before anything parses the JSON.
- Compare in constant time.
- Refuse a
tmore than five minutes old, so a captured delivery cannot be replayed later.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// Raw on this route only. express.json() would consume the body and leave
// you re-serialising the parsed object, which produces a different string
// and a signature that never matches.
app.post("/webhooks/jabee", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Jabee-Signature"), process.env.JABEE_WHSEC)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
// Delivery is at least once. Drop anything you have seen before.
if (seen(req.get("X-Jabee-Delivery"))) return res.sendStatus(200);
handle(event); // your work
res.sendStatus(200); // answer first, work later, if the work is slow
});
function verify(rawBody, header, secret) {
if (!header) return false;
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
import hmac, hashlib, time
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/jabee")
def jabee():
raw = request.get_data() # bytes, before any parsing
if not verify(raw, request.headers.get("X-Jabee-Signature", ""), WHSEC):
return "", 400
if seen(request.headers.get("X-Jabee-Delivery")):
return "", 200
handle(request.get_json())
return "", 200
def verify(raw_body: bytes, header: str, secret: str) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
except (KeyError, ValueError):
return False
What happens when your endpoint is down
Any 2xx is success. Anything else, a timeout after ten seconds, or a redirect counts as a failure and is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, an hour, three hours and six hours. Eight attempts over about eleven hours, then it gives up and writes that into the request's history.
Redirects are never followed, so give the final https address rather than one that bounces.
Duplicates
Delivery is at least once, which means your handler will eventually see the same
event twice. X-Jabee-Delivery is unique per delivery attempt. Keep the
last few thousand in whatever you already have, and return 200 for one you recognise
rather than doing the work again.
If you would rather not run one
Poll instead. GET /approvals?since=<the last updated_at you saw>
returns what changed, oldest first. It is in the
approvals documentation, and for a job that runs
every minute anyway it is less to maintain than an endpoint.
Questions
My signature never matches. What is wrong?
Almost always the raw body. A JSON body parser consumes the bytes and your code then re-serialises the parsed object, which produces a different string. Capture the raw bytes on that route specifically.
Do you follow redirects?
No. A redirect is treated as a failed delivery and retried. Give the final address.
How long do you keep retrying?
Eight attempts over about eleven hours: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours and 6 hours after the first try.
Will I ever get the same event twice?
Yes. Delivery is at least once. Drop duplicates on X-Jabee-Delivery.