Receiving webhooks
When a sensor fires, Evinor delivers a signed HTTP POST to the URL you
configured on that sensor. This guide covers the payload shape, headers,
signature verification, and retry behavior you should expect when building a
receiver.
You attach a delivery URL to a sensor as an action. Each sensor has its own
signing secret — the value returned as signing_secret when you create the
sensor or rotate its secret (see the API Reference). Copy that secret
into your receiver's environment; it is the HMAC key you verify deliveries with.
Two action types are supported:
- Generic webhook — a
POSTof the canonical Evinor JSON envelope, signed with HMAC-SHA256. This is the focus of this guide. - Slack incoming webhook — a
POSTof a Slack message object to a Slack-issued URL. This works differently; see Slack incoming webhooks.
Payload shape
Evinor POSTs a JSON body with Content-Type: application/json. The canonical
envelope:
{
"protocolVersion": "0.1",
"plane": "data",
"type": "incident",
"messageId": "0197f2c1-8f4e-7a31-b2c6-3d9a41e80a52",
"subjectId": "6f1e9a2c-04d3-4c11-9a2e-5b8f0d6c2a71",
"idempotencyKey": "0d7f2b6e-55c1-4c8e-9a77-c53412fd0e19",
"timestamp": "2026-05-07T12:34:56.789Z",
"sender": {
"agentId": "evinor-incident-rules",
"keyId": "9c4f5d2a-1b3e-4f8a-a5d6-7e2c8b9f1a3d:1",
"signatureAlg": "hmac-sha256",
"signature": "<base64 HMAC-SHA256 — see Signature verification below>"
},
"recipients": {
"kind": "incident-rule-action",
"actionType": "GENERIC_WEBHOOK",
"actionId": "b1a2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
},
"provenance": [
{ "sourceKind": "external", "sourceId": "...", "sourceName": "Bloomberg" },
{ "sourceKind": "external", "sourceId": "...", "sourceName": "Reuters" }
],
"payload": {
"eventType": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"eventCodeName": "FUNDRAISING",
"occurredAt": "2026-05-07T12:30:00.000Z",
"externalEventId": "3e2d1c0b-9a8f-4e7d-b6c5-4a3b2c1d0e9f",
"details": {
"incidentId": "3e2d1c0b-9a8f-4e7d-b6c5-4a3b2c1d0e9f",
"templateCodeName": "FUNDRAISING",
"matchedAt": "2026-05-07T12:34:55.000Z",
"matchingReportCount": 3,
"reportingOutlets": [
{ "id": "...", "name": "Bloomberg" },
{ "id": "...", "name": "Reuters" }
],
"roles": [{ "code": "raised_amount", "textValue": "$50M", "parsedAmount": 50000000 }],
"generatedSentence": "...",
"coverImageUrl": null,
"confidence": { "score": 0.91, "band": "HIGH", "formulaVersion": 2 }
}
}
}
Field reference
protocolVersion/plane— fixed at"0.1"/"data"in this protocol version. Treat unknown future values as forward-compatible; ignore unknown fields rather than rejecting the request.type— envelope discriminator. Alert dispatch always sends"incident".messageId— unique per delivery attempt; a retry of the same delivery gets a newmessageId.idempotencyKey— stable across every attempt of the same delivery. Track this for receiver-side dedup (see Replay protection).timestamp— ISO-8601 UTC time the envelope was built. It is part of the signed material.sender.keyId—"<sensor id>:<secret generation>". Cache(keyId → secret)so an in-flight message signed under a previous secret generation still verifies during rotation.recipients— routing metadata; not something you act on, but useful for cross-referencing which sensor/action fired.provenance— one entry per corroborating source.sourceNamemay be absent if the source hasn't been named yet; fall back tosourceIdfor a stable label.payload.eventCodeName— an immutable human code (e.g.FUNDRAISING,LAYOFF). Never renamed, so aswitchon this field is safe long-term.payload.occurredAtvspayload.details.matchedAt— when the event happened in the world, vs when it cleared your sensor's bar.payload.externalEventId— the underlying incident id. One incident matching multiple sensors produces multiple envelopes (distinctsubjectIds) sharing this field — dedupe across sensors on it.payload.details.roles— extracted, typed values (textValueis the raw text;parsedAmount/parsedDateare machine-readable readings).payload.details.confidence— optional;band(HIGH/MEDIUM/LOW) is the primary signal,scoreorders events within a band (not a probability). Omitted entirely when the underlying signals aren't available.
Response contract
Evinor expects a 2xx response, and enforces a 10-second timeout. Return quickly and push any heavy work to a queue on your end. See Retry behavior for how non-2xx responses are handled.
Headers
Every generic-webhook delivery includes:
| Header | Value | Purpose |
|---|---|---|
X-Evinor-Signature | base64 HMAC-SHA256 | HMAC-SHA256 over the canonicalized envelope bytes, using the sensor's signing secret. Verify this on every request. |
Content-Type | application/json | The body is canonical JSON. |
Signature verification
The signature is computed over the envelope's canonical bytes:
RFC 8785 (JSON Canonicalization Scheme,
JCS) applied to the envelope with exactly one field removed — sender.signature
itself. Every other field, including the rest of the sender block, stays in the
canonicalized bytes.
canonicalBytes = JCS_canonicalize(envelope with sender.signature removed)
expected = base64(HMAC_SHA256(SIGNING_SECRET, canonicalBytes))
if not timingSafeEqual(received X-Evinor-Signature, expected):
reject
Use a published JCS / RFC 8785 implementation for your language — do not
hand-roll JSON.stringify with a custom key sort. JCS also fixes number
formatting and normalizes strings, which a naive sort won't replicate.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const canonicalize = require('canonicalize');
const SIGNING_SECRET = process.env.EVINOR_SIGNING_SECRET;
const app = express();
app.use(express.json());
app.post('/webhooks/evinor', (req, res) => {
const envelope = req.body;
const received = req.get('X-Evinor-Signature') ?? '';
// Canonicalize with exactly one field removed: sender.signature.
const { signature, ...senderWithoutSig } = envelope.sender;
const canonicalBytes = Buffer.from(
canonicalize({ ...envelope, sender: senderWithoutSig }),
'utf-8'
);
const expected = crypto
.createHmac('sha256', SIGNING_SECRET)
.update(canonicalBytes)
.digest('base64');
const a = Buffer.from(received, 'base64');
const b = Buffer.from(expected, 'base64');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send('invalid signature');
}
// Reject stale timestamps — see Replay protection below.
const sentAt = Date.parse(envelope.timestamp);
if (Math.abs(Date.now() - sentAt) > 5 * 60 * 1000) {
return res.status(403).send('timestamp outside window');
}
// (Optional) replay protection: check envelope.idempotencyKey against your store.
res.status(200).send('ok');
});
Two details matter:
- Canonicalize the parsed envelope with
sender.signatureremoved — not the raw request body. - Use a constant-time comparison (
crypto.timingSafeEqual, not===), wrapped in a length check because it throws on a length mismatch.
The same algorithm applies in any language: parse the body, drop
sender.signature, canonicalize with a published JCS/RFC 8785 implementation,
HMAC-SHA256 the result, and compare in constant time.
Replay protection
The protocol signs the entire envelope except sender.signature itself, so
timestamp is inside the signed material — an attacker cannot forge a fresher
timestamp without invalidating the signature. On the receiver side:
- Track
idempotencyKeyin a short-lived store. Every attempt of the same delivery shares oneidempotencyKey; reject any you've already processed. - Reject deliveries whose
timestampis outside a ±5-minute window of your clock. Because the timestamp is signed, this defends against actual replay.
Retry behavior
Evinor classifies your response and retries accordingly:
| Your response | Behavior |
|---|---|
| 2xx | Success. The delivery is complete. |
| 408, 429, or any 5xx | Retryable. Re-attempted later with exponential backoff. |
| Network error, DNS failure, TLS handshake failure | Retryable. Same as a 5xx. |
| Any other 4xx (not 408 or 429) | Permanent. Delivered once, not retried. |
If retries are exhausted, the delivery is abandoned. Note also:
- Evinor does not follow redirects — configure the final URL directly.
- A URL that resolves to a private/internal address is rejected and not delivered.
- Returning a
5xxfor a delivery you intend to permanently reject will cause Evinor to keep retrying. Return a4xx(e.g.400or404) instead. - If your endpoint is overloaded, return
429and Evinor will back off.
Delivery history
The Evinor web app shows a per-sensor delivery history — each attempt's status,
last error, timestamps, and latency. When you suspect a receiver missed a
delivery, that panel is the authoritative record. The idempotencyKey shown
there matches the idempotencyKey on the wire, so you can correlate it with
your own logs.
Slack incoming webhooks
If the action is a Slack incoming webhook, delivery differs:
- The Slack-issued URL is the secret. Possession of the URL grants the ability to post into the channel — don't log or commit it. The channel is fixed when the Slack webhook is created; Evinor cannot route by channel from the payload.
- The body is a Slack message object (
{ "text": "…" }, a human-readable summary of the incident), not the canonical Evinor envelope. - Evinor does not add
X-Evinor-Signature(or anyX-Evinor-*header) when posting to Slack URLs. The only header sent isContent-Type: application/json.
You do not implement signature verification for Slack; Slack handles delivery.
Operational recommendations
- Rotate your signing secret if you suspect compromise (via the API's rotate
endpoint or the web app). Update the secret in your receiver before
completing the rotation, or you'll see signature failures during the window.
Caching
(keyId → secret)lets in-flight messages under the old generation still verify. - Acknowledge fast. Push heavy work to a background queue; the 10-second timeout is firm.
- Test with a non-production receiver first.
- Use
idempotencyKeyfor log correlation on both sides.
See also
- API Reference — create sensors and rotate signing secrets.
- Getting Started — capture the one-time
signing_secret.