Our essays make an argument: software should subscribe to meaning, not to producers. Readers have been fair about it and asked the obvious next question: fine — but what actually shows up? JSON? A queue? Which fields? Signed how?
This post is the concrete answer. One event, walked field by field: what arrives when your workflow fires, why each field exists, and the machinery behind the two guarantees the other pieces keep invoking — one event, delivered once.
The ten-second version
An Evinor event is an HTTP POST to an endpoint you control. The body is one JSON document — we call it the envelope — carrying a signature header, X-Evinor-Signature. (The same event can land as a formatted Slack card instead; automations get the JSON, humans get the card, in either format.) No SDK, no polling, nothing to install: if your stack can receive a webhook, it can receive reality.
The envelope has two zones. The outside carries delivery facts — who signed it, which attempt this is, how to deduplicate it. The payload carries the happening itself. The split is deliberate: your infrastructure code reads the outside, your business logic reads the inside, and neither needs to understand the other.
eventCodeName, gate on confidence.band, dedupe retries on idempotencyKey and cross-rule repeats on externalEventId.The envelope, whole
Here is a representative envelope for an acquisition, trimmed only where marked:
// POST https://acme.example/hooks/evinor // X-Evinor-Signature: rTNIcqxpXVpVSF1I0Nb0y0Fr0IsHYVvvcAG8vYYU5cA= { "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-07-08T14:21:07.412Z", "sender": { "agentId": "evinor-incident-rules", "keyId": "8c41d1f2-…:1", "signatureAlg": "hmac-sha256", "signature": "rTNIcqxpXVpVSF1I0Nb0y0Fr0IsHYVvvcAG8vYYU5cA=" }, "provenance": [ { "sourceKind": "external", "sourceId": "b2ce…", "sourceName": "Reuters" }, // … one entry per corroborating outlet — 12 in total ], "payload": { /* the happening — next section */ } } // trimmed for the page: recipients, relatesTo
The outside: delivery facts
messageId and idempotencyKey are different fields because attempts and events are different things. If your endpoint times out, we retry with backoff — up to ten attempts. Each attempt is a new message with its own messageId, but every attempt of the same delivery carries the same idempotencyKey. Receiver-side dedup is therefore one line: skip any (sender.agentId, idempotencyKey)pair you’ve already processed. That’s half of what “delivered once” means; the other half happened before the send, and we’ll get to it.
sender is the verification block. agentId names the sending agent. keyIdis your rule’s id plus a generation counter — when you rotate the signing secret, the generation increments, so your receiver can cache keyId → secret and survive a rotation without a flag day: an in-flight message signed under the old generation still verifies while new traffic flows under the new one. signatureis a base64 HMAC-SHA256 over the envelope’s canonical bytes — the verification recipe is below.
provenance is the corroboration, itemized. One entry per independent outlet whose reporting contributed to this event, each with a stable id. Not “trust us, twelve sources” — the twelve, listed, auditable at the source level.
The inside: the happening
"payload": { "eventCodeName": "ACQUISITION", "occurredAt": "2026-07-08T13:58:00.000Z", "externalEventId": "3a7d40c9-…", "details": { "matchedAt": "2026-07-08T14:21:05.180Z", "matchingReportCount": 12, "roles": [ { "code": "ACQUIRING_COMPANY_NAME", "textValue": "Databricks", "namedEntityId": "c1f9…" }, { "code": "COMPANY_BEING_ACQUIRED", "textValue": "Tabular", "namedEntityId": "77b3…" }, { "code": "VALUE_OF_ACQUISITION", "textValue": "$1 billion", "parsedAmount": 1000000000 } ], "generatedSentence": "Databricks has acquired Tabular for $1 billion.", "confidence": { "score": 0.87, "band": "HIGH", "formulaVersion": 2 } } } // trimmed: template ids, reporting outlets, tags, cover image
eventCodeName is the shared type system, made a string. ACQUISITION, LAYOFF, IPO — the shape you subscribed to. We treat renaming an Event type code as a breaking change: we add a new one and migrate rather than rewrite an existing code under you, so the switchstatement you write today doesn’t rot. A thousand workflows building on a schema is a promise, not a feature.
occurredAt and matchedAt are two different facts. When the thing happened in the world, and when it cleared your rule’s bar. The gap between them — twenty-three minutes here — is the pipeline earning its keep: ingesting the first reports, resolving entities, accumulating corroboration. We’d rather give you the field pair and let you measure us than print a marketing latency number here.
roles is the extraction, kept honest. textValueis what the world actually said (“$1 billion”); parsedAmount and parsedDate are our machine-readable readings of it; namedEntityIdis the canonical entity the mention resolved to — the same stable id whether the outlet wrote “DBX” or “Databricks, Inc.” Map roles[].code into your CRM fields and you never parse prose again. (How resolution decides those two strings are one company deserves — and will get — its own post.)
generatedSentence is for the humans downstream. One deterministic line, rendered from the typed roles — not a model summarizing freely. It’s what lands in Slack, and what your automation can drop into a ticket without wondering what else the paragraph said.
Where the confidence number comes from
The essays claim the schema tells the truth about its own epistemology. This is the field where that claim has to cash out, so here is exactly what it is — and isn’t.
The pipeline produces internal certainty signals at different stages — how cleanly the roles extracted, how unambiguous the classification was — and corroboration contributes a bounded term: more independent outlets, more certainty, with diminishing returns. Those are blended into one composite. Three deliberate choices govern how it reaches you:
band is primary; score is ordinal. HIGH / MEDIUM / LOWis what you branch on. The float orders events within a band — it is not a probability, and we won’t pretend it’s calibrated as one. Reading 0.87as “87% chance this happened” is exactly the overclaim the field is designed to refuse.
The composite is all you get. The per-stage internals never reach the wire. They’re an abstraction of our pipeline that no receiver could weight sensibly, and they’d shift under you as the pipeline evolves. Instead, formulaVersion increments whenever we retune the blend — so a change in what the number means is visible in the payload, not silently absorbed.
Absent beats fabricated. When the underlying signals don’t exist for an event, the field is omitted — never emitted as a made-up zero. The same rule holds across the payload: optional fields are missing when we don’t know, not defaulted when we should have said so. A missing field is visible; a fabricated default is a quiet lie.
What “once” means mechanically
“Your workflow fires once” is doing work at three layers, and each layer has its own field.
One happening, one event. Before any delivery exists, canonicalization has already collapsed the retellings — forty articles, a filing, five status-page updates — into a single Event. By the time an envelope is built, the twelve outlets are inside one event as provenance, not twelve deliveries to your endpoint.
One delivery, despite retries. A flaky endpoint gets retried with backoff, each attempt a fresh messageId, all attempts sharing one idempotencyKey. Dedupe on it and a timeout can never double-fire a workflow.
One happening, across your rules. If three of your rules match the same happening, you get three envelopes — deliberately, because they may drive different actions with different thresholds. All three carry the same payload.externalEventId. A workflow that must act once per happening — a CRM write, an order reroute — keys on that.
The delivery path also carries a per-rule rate limit, so a misconfigured rule floods neither your endpoint nor your team’s patience. Dampers are part of the medium: a layer that lets strangers’ actions trigger your automations has to make not overreacting the default.
Verifying it’s us
The signature is computed over the envelope’s canonical bytes: RFC 8785 JSON canonicalization — sorted keys, normalized numbers and strings — applied to the envelope with exactly one field removed, sender.signature itself. Everything else, including the rest of the sender block, is under the signature.
Verification is five steps, none exotic: reject unsigned requests; look up your secret by sender.keyId; re-canonicalize with a published JCS implementation (don’t hand-roll key sorting); recompute the HMAC; compare in constant time. Then bound replay on the body’s timestamp — a ±5-minute window is the default we assume — because there is no timestamp header and no nonce. The whole verifier is about twenty lines of Node. The verification recipe walks it step by step, with a signed fixture and its secret so you can check your implementation before you hold a production one.
What’s deliberately not in the payload
A wire format is defined as much by what it refuses to carry.
Your filter clauses. The rule that matched — which entities you watch, which thresholds you set — is your alert strategy, and it stays yours. The envelope never echoes it back, because webhook endpoints are often operated by third parties — an automation platform, a contractor’s integration — and your watchlist shouldn’t leak to whoever runs the endpoint.
Our internals. Raw article references, internal media URLs, projection metadata, the per-stage confidence components: none of it crosses the wire. The payload is an explicit, enumerated contract — not a dump of our database row — precisely so we can change our internals without changing your parser.
The point
Read back over the fields and notice what they add up to. eventCodeName is the shared type system. namedEntityId is the shared keys. provenance and confidence are trust moved out of the connection and into the event, where you can inspect it. idempotencyKey and externalEventId are “once,” enforced twice. The essays argue that software should subscribe to meaning instead of producers; this envelope is what that position compiles down to when it has to survive contact with your infrastructure.
The world doesn’t poll — and now you’ve seen exactly what it POSTs.