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": "FUND_RAISING",
"occurredAt": "2026-05-07T12:30:00.000Z",
"externalEventId": "3e2d1c0b-9a8f-4e7d-b6c5-4a3b2c1d0e9f",
"details": {
"incidentId": "3e2d1c0b-9a8f-4e7d-b6c5-4a3b2c1d0e9f",
"templateCodeName": "FUND_RAISING",
"matchedAt": "2026-05-07T12:34:55.000Z",
"matchingReportCount": 3,
"reportingOutlets": [
{ "id": "...", "name": "Bloomberg" },
{ "id": "...", "name": "Reuters" }
],
"roles": [{ "code": "AMOUNT_RAISED", "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.FUND_RAISING,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.
Full envelope reference
The bullets above call out what you're most likely to act on. This table is the exhaustive field-by-field reference for anyone implementing a strict parser.
| Field | Type | Notes |
|---|---|---|
protocolVersion | "0.1" | Literal. Pinned for the lifetime of this protocol version. |
plane | "data" | Literal. Separates data-plane messages from future control-plane ones. |
type | "incident" | Discriminator for the payload shape. Only "incident" is emitted today. |
messageId | string | Unique per delivery attempt. A retry of the same delivery carries a new messageId — never dedupe on it. |
subjectId | string | Identifies the sensor match this delivery is about. |
idempotencyKey | string | Stable across every attempt of the same delivery. This is the dedupe key — see Replay protection. |
timestamp | string (ISO-8601) | When the envelope was built. The only clock on the wire — there is no timestamp header. |
sender | object | agentId, keyId, signatureAlg, signature. See Signature verification. |
recipients | object | Which configured action this delivery is addressed to: kind, actionType, actionId. |
relatesTo | array, optional | Outgoing edges to other envelopes (supersedes, retracts, …). Not emitted today. |
provenance | array, optional | One entry per corroborating source: sourceKind, sourceId, sourceName. |
payload | object | The event: eventType, eventCodeName, occurredAt, externalEventId, details. |
payload.details carries the rest of what the sensor matched on:
| Field | Type | Notes |
|---|---|---|
incidentId | string | Stable id of the event this delivery is about. Use it to correlate deliveries — it is not the report id. |
incidentReportId | string | Id of the specific triggering report. |
templateId | string | Machine id of the event type. Mirrors payload.eventType. |
templateCodeName | string | Stable event-type code (e.g. LAYOFF). Mirrors payload.eventCodeName. See Event types. |
templateDisplayName | string, optional | Presentable event-type name; fall back to templateCodeName when absent. |
matchedAt | string (ISO-8601) | When the sensor matched. |
matchingReportCount | number | Reports matching this event at match time. |
reportingOutlets | array | One { id, name? } per outlet that reported it. Ids are stable across deliveries of the same event. |
roles | array | The extracted roles: { code, textValue, parsedAmount?, parsedDate?, namedEntityId? } — a list, not a map; a code may repeat. Catalogued per event type on Event types. |
generatedSentence | string, optional | One-line human rendering of the event. |
coverImageUrl | string | null | Publishable cover image URL, or null. Internal media URLs are never included. |
triggerText | string, optional | The raw trigger phrase from the source. |
summaryText | string, optional | Short summary. |
tags | object, optional | { temporal?, articleRelationship?, entitySpecificity? } — extraction-derived tags; absent when none. |
confidence | object, optional | { score, band, formulaVersion }. band (HIGH/MEDIUM/LOW) is the primary signal; score orders events within a band — it is ordinal, not a probability. Omitted for legacy deliveries. |
That table is the whole of payload.details — a deliberately enumerated set
of wire fields, not a passthrough of Evinor's internal shape. Do not key
roles by code:
Object.fromEntries(roles.map(r => [r.code, r.textValue])) keeps only the
last entry and silently drops the rest — group by code instead.
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.