Webhooks: verify an alert or incident post
If you give Declarable a generic webhook URL, every post to it is signed: vulnerability alerts and Article 14 incident deadline reminders alike. Check the signature and the time before you trust it.
The headers
X-Declarable-Timestamp: when we sent the post, in unix seconds, digits only.X-Declarable-Signature-V2:sha256=followed by lower-case hex. Verify this one.X-Declarable-Signature: the older signature, over the body alone with no time in it, so a captured post would verify for ever. It is deprecated and is still sent for one release, then it stops. Move to the V2 header.
Exactly what is signed
The timestamp, one full stop, then the request body byte for byte:
<X-Declarable-Timestamp>.<raw body>The signature is sha256= and the lower-case hex HMAC-SHA256 of that string, keyed with the signing secret from your webhooks page. The key is the whole whsec_… string as shown, prefix included, not the part after the underscore. Sign the body you received, not a copy you parsed and wrote out again: key order and spacing are part of what was signed.
What to do with the time
- Check the V2 signature over the timestamp and body, comparing in constant time.
- Reject the post if the timestamp is more than 5 minutes from your own clock, earlier or later. A post captured on the wire stays correctly signed for ever; this window is what stops it being accepted for ever.
- Reject a repeated pair of id and timestamp. The id is
alertIdon an alert post andpostIdon an incident post (below). A resend carries the same id and a new timestamp, so keying on the pair drops replays without dropping resends.
Declarable cannot do these checks for you: they happen on your side.
The payload
One alert:
{
"alertId": "<opaque id>",
"sentAt": "2026-10-09T09:30:00.000Z",
"kind": "new_vulnerability",
"severity": "high",
"productName": "Acme Thermostat",
"vulnId": "CVE-2026-0001",
"summary": "<short text, may be empty>",
"score": 8.1,
"exploited": false,
"link": "https://…",
"releases": [
{ "version": "1.2.0", "supersededBy": "<newer version>", "supportedUntil": "2029-06-30" }
]
}severity can be unknown, and score can be null: both happen when the vulnerability has none recorded yet. A receiver that parses score as a number must accept null.
releases lists each superseded release of the product that matched this alert, with the version that replaced it and the date it is supported until. An empty list means every match is on the current release. The key can be absent, so do not require it.
The daily digest has no alert id; its date is its key:
{
"kind": "digest",
"date": "2026-10-09",
"count": 3,
"link": "https://…",
"sentAt": "2026-10-09T07:00:00.000Z"
}On an alert post, kind is new_vulnerability for a new match, or exploited when the vulnerability is known to be exploited (including a match we had already told you about, now escalated). The digest body has kind digest.
sentAt in the body is the same moment as the timestamp header. Answer with any 2xx status; anything else is recorded as a failed delivery.
A receiver has 5 seconds to answer. Answer first and do your work afterwards: a post that is still unanswered after 5 seconds is recorded as a failed delivery, with the reason “No answer within 5 seconds”, even if your work finishes a moment later. Each send is a single attempt, with no retry inside it. A failed alert is not marked as delivered, so a later run of the alert job may post it again; do not rely on that, and key your own dedupe on the id described above in case it does.
Incident posts (Article 14 deadlines)
If you have recorded an incident, the same webhook URL, headers and signature also carry its deadline reminders. These bodies have no alertId; the key to deduplicate on is postId. Do not drop a post because it lacks an alertId: these are the posts that carry a deadline.
{
"kind": "incident_deadline",
"incidentId": "<opaque id>",
"productName": "<your product>",
"stage": "notification",
"postId": "<incidentId>:notification:<reminder>",
"sentAt": "2026-10-09T09:30:00.000Z",
"dueAt": "2026-10-10T09:30:00.000Z",
"link": "<host>/app/incidents/<incident id>"
}kind:incident_deadlinefor a reminder that a report is coming due, orincident_clock_startedfor the line sent when the reporting clock starts.incidentIdandproductName: which incident, and which product it is about.stage: the report this post is about, one ofearly_warning,notificationorfinal.dueAt: when that report is due, as an ISO date and time;nullwhen the report has no date yet (the final report of an exploited vulnerability before a fix is recorded).postId:incidentId:stage:reminder, the same on every retry of one reminder for one stage of one incident and different between reminders. Deduplicate on the pair ofpostIdand timestamp.incidentIdplusstagealone is coarser: one stage can send several reminders, and you want each.sentAt: when this try was made, the same moment as the timestamp header. Check the header against your own clock (the replay window): it is what the signature covers, so it cannot be changed without breaking the signature, and the body alone can.link: the incident in Declarable.
The test button on the incident readiness page sends a short rehearsal body, { "kind": "incident_test", "link": "…" }, with the same headers and signature. It has no incident, no postId and no sentAt: acknowledge it with a 2xx and do not treat it as a deadline. With no postId there is no key to deduplicate on, so do not apply the repeat rule to it: just answer it.
Example: Node
import { createHmac, timingSafeEqual } from 'node:crypto';
const WINDOW_SECONDS = 5 * 60;
// rawBody: the request body exactly as received (a string or Buffer), NOT re-serialised JSON.
export function verify(rawBody, headers, secret) {
const timestamp = headers['x-declarable-timestamp'];
const received = headers['x-declarable-signature-v2'];
if (!/^[0-9]+$/.test(timestamp ?? '') || !received) return false;
// 1. Reject a post that is too old or too far in the future.
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skew > WINDOW_SECONDS) return false;
// 2. Sign "<timestamp>.<raw body>" with your signing secret and compare in constant time.
const expected =
'sha256=' + createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}
// 3. Also keep the (id, timestamp) pairs you have accepted and refuse a repeat. The id is alertId on an alert post and postId on an incident post.Example: Python
import hashlib, hmac, re, time
WINDOW_SECONDS = 5 * 60
# raw_body: the request body exactly as received (bytes), NOT re-serialised JSON.
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
# Header names can arrive in any case, so compare them lower-cased.
headers = {k.lower(): v for k, v in headers.items()}
timestamp = headers.get("x-declarable-timestamp", "")
received = headers.get("x-declarable-signature-v2", "")
if not re.fullmatch(r"[0-9]+", timestamp) or not received:
return False
# 1. Reject a post that is too old or too far in the future.
if abs(time.time() - int(timestamp)) > WINDOW_SECONDS:
return False
# 2. Sign "<timestamp>.<raw body>" with your signing secret and compare in constant time.
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# 3. Also keep the (id, timestamp) pairs you have accepted and refuse a repeat. The id is alertId on an alert post and postId on an incident post.Slack alerts are not signed: the Slack URL is the secret, so keep it private.
Declarable prepares documentation for you to review. It is not legal advice and not certification. You sign your own declaration of conformity.