One happening becomes one Event. One Event becomes one signed envelope, delivered to your endpoint until you acknowledge it. Everything below describes that envelope.
codeNames to be stable and treat renaming one as a breaking change; role codeNames may still change before 1.0. Gate on payload.eventCodeName, and treat an unrecognized role code as data you skip, not as an error.The catalog of Event types we emit today, and the roles each one carries, lives on Event types.
01The envelope
Every delivery is a single JSON object. The top-level fields are transport concerns — who sent it, who it is addressed to, how to verify it, how to deduplicate it. payload carries the Event itself. Nothing else is nested at the top level.
| 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. |
messageId | string | Unique per delivery attempt. A retry of the same Event carries a new messageId — never deduplicate on it. |
type | "incident" | Discriminator for the payload shape. Only "incident" is emitted today. |
subjectId | string | Identifies the rule match this delivery is about. Optional on the base envelope; always present on "incident" envelopes. |
idempotencyKey | string | Stable across every retry of the same delivery. This is the dedupe key. See §03. |
sender | object | agentId, keyId, signatureAlg, signature. See §02. |
recipients | object | Which configured action this delivery is addressed to: kind, actionType, actionId. |
timestamp | string (ISO-8601) | When the envelope was built. The only clock on the wire: there is no timestamp header. |
relatesTo | array | optional Outgoing edges to other envelopes (supersedes, retracts, …). Not emitted by Event delivery today. |
provenance | array | optional One entry per source that reported the happening: sourceKind, sourceId, sourceName. |
payload | object | The Event: eventType, eventCodeName, occurredAt, externalEventId, details. |
Inside payload.details:
| Field | Type | Notes |
|---|---|---|
incidentId | string | Stable id of the Event this delivery is about (the world Incident aggregate id). 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. |
templateDisplayName | string | optional Presentable Event-type name; fall back to templateCodeName when absent. |
matchedAt | string (ISO-8601) | When the rule 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 }. Described in §05; omitted for legacy Events. |
That table is the whole of payload.details— a deliberately-enumerated set of wire fields, not a passthrough of our internal shape. Evinor’s projection metadata, internal media URLs, the article source ids behind each report, and your rule’s alert filter criteria are not on the wire.
Inside payload, details.roles carries the extracted participants of the Event — each entry a code plus a textValue, with optional parsed companions. It is a list, not a map. The same code may legitimately appear more than once — a funding round backed by three investors arrives as three INVESTING_COMPANY_NAME entries. Do not key roles by code: collapsing them into an object keeps only the last value and silently drops the rest. Group by code instead. Which roles can repeat is documented per Event type on Event types.
The envelope below is illustrative and unsigned: it was built without a signer, so sender.signature reads <not-captured> rather than a base64 HMAC. That placeholder is not a signature format — a real, verifiable envelope is in §02.
{
"protocolVersion": "0.1",
"plane": "data",
"messageId": "b09e110d-0f53-7212-b376-9c12b51d6e45",
"type": "incident",
"subjectId": "sample-match-1",
"idempotencyKey": "sample-delivery-1",
"sender": {
"agentId": "evinor-incident-rules",
"keyId": "rule-sample:1",
"signatureAlg": "hmac-sha256",
"signature": "<not-captured>"
},
"recipients": {
"kind": "incident-rule-action",
"actionType": "GENERIC_WEBHOOK",
"actionId": "sample-action-1"
},
"timestamp": "2026-04-01T12:00:00.000Z",
"payload": {
"eventType": "sample-template-acquisition",
"eventCodeName": "ACQUISITION",
"occurredAt": "2026-04-01T11:55:00.000Z",
"externalEventId": "sample-incident-1",
"details": {
"incidentId": "sample-incident-1",
"incidentReportId": "sample-report-1",
"templateId": "sample-template-acquisition",
"templateCodeName": "ACQUISITION",
"templateDisplayName": "Acquisition",
"matchedAt": "2026-04-01T12:00:00.000Z",
"matchingReportCount": 3,
"reportingOutlets": [
{
"id": "outlet-1",
"name": "Example Tech Daily"
},
{
"id": "outlet-2",
"name": "Global Business Wire"
}
],
"roles": [
{
"code": "ACQUIRING_COMPANY_NAME",
"textValue": "Northwind Corp",
"namedEntityId": "ne-northwind"
},
{
"code": "COMPANY_BEING_ACQUIRED",
"textValue": "Contoso Ltd",
"namedEntityId": "ne-contoso"
},
{
"code": "VALUE_OF_ACQUISITION",
"textValue": "$2.4 billion",
"parsedAmount": 2400000000
}
],
"generatedSentence": "Northwind Corp acquired Contoso Ltd for $2.4 billion.",
"coverImageUrl": null,
"summaryText": "Northwind Corp announced the acquisition of Contoso Ltd in a deal valued at $2.4 billion.",
"tags": {
"temporal": "recent",
"articleRelationship": "subject"
},
"confidence": {
"score": 0.82,
"band": "HIGH",
"formulaVersion": 1
}
}
},
"provenance": [
{
"sourceKind": "external",
"sourceId": "outlet-1",
"sourceName": "Example Tech Daily"
},
{
"sourceKind": "external",
"sourceId": "outlet-2",
"sourceName": "Global Business Wire"
}
]
}02Signing & verification
Deliveries arrive as an HTTP POST with exactly two headers you need to care about: Content-Type: application/json and X-Evinor-Signature. The signature header is a base64 HMAC-SHA256 and is a verbatim copy of envelope.sender.signature, so you may verify from the body alone. There is no timestamp header and no nonce. The body timestamp is the sole replay control.
sender.keyId is composed as "<ruleId>:<keyGeneration>". Look the secret up by keyIdrather than assuming a single secret per endpoint: rotating a rule’s signing secret bumps the generation, and both generations are valid during the overlap window. sender.signatureAlg is fixed to "hmac-sha256" at protocolVersion 0.1.
Verification recipe
sender.signature removed — and only that key. Every other sender.* field (agentId, keyId, signatureAlg) stays in. Deleting the whole sender block, or HMAC-ing the raw request body, produces different bytes and fails 100% of verifications.verify(request):
raw = request.body # bytes, exactly as received
header = request.headers["X-Evinor-Signature"]
envelope = json_parse(raw) # raw is parsed, never hashed
# 1. Resolve the secret from the key id. keyId is "<ruleId>:<keyGeneration>".
# Keep the previous generation live during a rotation overlap window.
secret = your_secret_store.get(envelope.sender.keyId)
if secret is null: reject(401)
# 2. Strip ONLY sender.signature. Keep sender.agentId, sender.keyId and
# sender.signatureAlg. Deleting the whole sender block is the classic bug.
unsigned = deep_copy(envelope)
delete unsigned.sender.signature
# 3. Canonicalize with RFC 8785 (JSON Canonicalization Scheme), UTF-8 encode.
bytes = utf8(jcs_canonicalize(unsigned))
# 4. HMAC-SHA256 with the secret, then base64.
expected = base64(hmac_sha256(secret, bytes))
# 5. Compare in constant time. The header is a verbatim copy of
# envelope.sender.signature, so either source is equivalent.
if not constant_time_equals(expected, header): reject(401)
# 6. Replay guard. There is no timestamp header and no nonce: the body
# timestamp is the only clock. Reject envelopes more than five minutes
# in the future, and (recommended) far in the past.
if envelope.timestamp > now() + 5.minutes: reject(400)
accept(envelope)A worked example you can run
Below is a real signed envelope and the secret it was signed with. The secret is a test fixture, not a live credential — it exists so you can implement the recipe above and check your output before you hold a production signing secret. Running the recipe over this envelope with this secret must reproduce YXZ2EAIXBJj/nV654hFOKcaMFBiUSCOJrYP/wi35AqA= exactly.
| Input | Value |
|---|---|
secret | golden-fixture-secret-do-not-rotate-without-regen |
keyId | rule-golden:1 |
expected signature | YXZ2EAIXBJj/nV654hFOKcaMFBiUSCOJrYP/wi35AqA= |
{
"protocolVersion": "0.1",
"plane": "data",
"messageId": "4ba21e4c-e3a9-7684-ad83-5467af61a24f",
"type": "incident",
"subjectId": "match-golden-1",
"idempotencyKey": "delivery-golden-1",
"sender": {
"agentId": "evinor-incident-rules",
"keyId": "rule-golden:1",
"signatureAlg": "hmac-sha256",
"signature": "YXZ2EAIXBJj/nV654hFOKcaMFBiUSCOJrYP/wi35AqA="
},
"recipients": {
"kind": "incident-rule-action",
"actionType": "GENERIC_WEBHOOK",
"actionId": "action-golden-1"
},
"timestamp": "2026-04-01T12:00:00.000Z",
"payload": {
"eventType": "tpl-layoff",
"eventCodeName": "LAYOFF",
"occurredAt": "2026-04-01T11:55:00.000Z",
"externalEventId": "incident-golden-1",
"details": {
"incidentId": "incident-golden-1",
"incidentReportId": "report-golden-1",
"templateId": "tpl-layoff",
"templateCodeName": "LAYOFF",
"templateDisplayName": "Layoffs",
"matchedAt": "2026-04-01T12:00:00.000Z",
"matchingReportCount": 2,
"reportingOutlets": [
{
"id": "outlet-1",
"name": "Acme News"
},
{
"id": "outlet-2"
}
],
"roles": [
{
"code": "COMPANY_NAME",
"textValue": "TechCorp",
"namedEntityId": "ne-techcorp"
},
{
"code": "AFFECTED_EMPLOYEES_COUNT",
"textValue": "200 people",
"parsedAmount": 200
}
],
"generatedSentence": "TechCorp laid off 200 people.",
"coverImageUrl": "https://cdn.example.com/cover.jpg",
"triggerText": "TechCorp announced layoffs",
"summaryText": "TechCorp announced layoffs of 200 employees.",
"tags": {
"temporal": "recent",
"articleRelationship": "subject"
}
}
},
"provenance": [
{
"sourceKind": "external",
"sourceId": "outlet-1",
"sourceName": "Acme News"
},
{
"sourceKind": "external",
"sourceId": "outlet-2"
}
]
}03Idempotency
Deliveries are at-least-once. Deduplicate on the pair (sender.agentId, idempotencyKey). That key is stable across every retry attempt of the same delivery, which is exactly what makes it safe to use as a primary key on your side.
Do not deduplicate on messageId: it is fresh on every attempt, deterministically derived from the delivery and the attempt number. Two receipts of the same Event will differ there and agree on idempotencyKey.
If you run several rules that can match the same real-world happening, each match is its own delivery with its own idempotencyKey. To collapse those into one thing in your system, deduplicate a second time on payload.externalEventId — the identifier of the underlying Event, shared across every rule that matched it.
04Retries & backoff
A delivery is attempted up to roughly ten times with backoff between attempts. Each attempt has a short timeout, so acknowledge fast and do slow work asynchronously. How we classify your response:
| Response | Treatment |
|---|---|
2xx | Success. The delivery is complete. |
5xx | Retryable. |
408, 429 | Retryable. |
| Network error or timeout | Retryable. |
Any other 4xx | Permanent. No further attempts. |
3xx redirect | Permanent. Redirects are never followed — publish the final URL. |
| Blocked target | Permanent. Endpoints resolving to private or link-local addresses are refused. |
Each rule also has an hourly delivery cap. Deliveries beyond it are abandoned rather than queued, so a runaway rule cannot silently build an unbounded backlog against your endpoint. Both abandonment and permanent failure are recorded on the rule’s delivery log.
05Confidence
payload.details.confidence is our external gauge of how certain we are that the Event is real. It has three fields: band, score, and formulaVersion.
band is the primary representation — HIGH, MEDIUM, or LOW. Gate your automation on it. score is ordinal, not a probability: it is a coarse ranking in [0, 1] whose only job is to order Events within a band. It is not calibrated, and score = 0.8 does not mean an 80% chance the Event is real. Do not threshold on it.
Being straight with you about how far that goes: the cut points that turn a score into a band are themselves tunable, and have not yet been calibrated against a labelled corpus of real and false Events. So band is the coarsest and most stable signal we publish — the one worth gating on, and the one we work hardest to keep meaning what it means — but it is not a probability either. HIGHmeans “we think this is worth acting on”, not “this is correct with probability p”.
formulaVersion tells you which blend produced the score. If we retune the blend, the version bumps — which is the signal that any threshold you built on the raw score has silently changed meaning. Another reason to gate on band.
confidence is optional on the wire. It is omitted entirely for legacy Events recorded before the gauge existed, rather than emitted as a fabricated zero. Treat an absent confidenceas “unknown”, not as “low”.Detection difficulty is not uniform across Event types. Each type on Event types carries a maturity signal telling you which ones warrant a band gate.
06Push-only delivery
Evinor is push-only. There is no poll API. No cursor, no since parameter, no backfill endpoint. We POST to the endpoint your rule configures, and we keep trying within the retry budget above.
The practical consequence: your endpoint is the durable boundary. Acknowledge with a 2xxas soon as you have written the envelope somewhere you trust, and process it after. If you exhaust the retry budget, the delivery is abandoned and visible on the rule’s delivery log — not silently re-offered later.