Skip to main content

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 POST of the canonical Evinor JSON envelope, signed with HMAC-SHA256. This is the focus of this guide.
  • Slack incoming webhook — a POST of 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 new messageId.
  • 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. sourceName may be absent if the source hasn't been named yet; fall back to sourceId for a stable label.
  • payload.eventCodeName — an immutable human code (e.g. FUNDRAISING, LAYOFF). Never renamed, so a switch on this field is safe long-term.
  • payload.occurredAt vs payload.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 (distinct subjectIds) sharing this field — dedupe across sensors on it.
  • payload.details.roles — extracted, typed values (textValue is the raw text; parsedAmount / parsedDate are machine-readable readings).
  • payload.details.confidence — optional; band (HIGH/MEDIUM/LOW) is the primary signal, score orders 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:

HeaderValuePurpose
X-Evinor-Signaturebase64 HMAC-SHA256HMAC-SHA256 over the canonicalized envelope bytes, using the sensor's signing secret. Verify this on every request.
Content-Typeapplication/jsonThe 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:

  1. Canonicalize the parsed envelope with sender.signature removed — not the raw request body.
  2. 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:

  1. Track idempotencyKey in a short-lived store. Every attempt of the same delivery shares one idempotencyKey; reject any you've already processed.
  2. Reject deliveries whose timestamp is 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 responseBehavior
2xxSuccess. The delivery is complete.
408, 429, or any 5xxRetryable. Re-attempted later with exponential backoff.
Network error, DNS failure, TLS handshake failureRetryable. 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 5xx for a delivery you intend to permanently reject will cause Evinor to keep retrying. Return a 4xx (e.g. 400 or 404) instead.
  • If your endpoint is overloaded, return 429 and 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 any X-Evinor-* header) when posting to Slack URLs. The only header sent is Content-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 idempotencyKey for log correlation on both sides.

See also