Skip to main content

Event Delivery API Reference

Complete API reference for the /events/* surface: destinations, subscriptions, secrets, deliveries, and the event type catalog. All endpoints require authentication via API key.

Interactive playground

An interactive OpenAPI playground is available at api.feeds.onhelix.ai/docs. The spec is generated from the live route validators, so it cannot drift from the running implementation.

Base URL: https://api.feeds.onhelix.ai

Authentication

Authorization: Bearer YOUR_API_KEY

The organization is derived from the API key — it is never passed in the path. See the Authentication Guide.

Errors

Every error response — including a 401 from a bad API key and the 404 for a route that doesn't exist — uses the same envelope:

{
"success": false,
"error": {
"code": "INVALID_BODY",
"message": "Invalid request body: Unrecognized key: \"secret\""
}
}

error.code and the status it comes with:

CodeStatusMeaning
INVALID_BODY400, 413, 415The request body failed validation: malformed JSON, an unsupported content type, a body over the size limit, or an unknown/invalid field caught by the route's schema (message names the field).
INVALID_PARAMS400A path parameter ({ref}, {subRef}, {id}) failed validation.
INVALID_QUERY400A query parameter failed validation — most often a zone-less since/until, or an out-of-range limit/offset.
VALIDATION_FAILED400A rule checked in handler code, after the request body already passed its schema: a destination URL that isn't HTTPS or resolves into a private/reserved range, a custom header that breaks the header rules, an externalId path segment that fails the externalId rules, or a backfill window whose event count exceeds the 10,000-event scan cap. message names the specific problem. (An inverted or over-30-day backfill window, and most other body-shape problems, are caught earlier and come back as INVALID_BODY instead.)
AUTHENTICATION_FAILED401The API key is missing, malformed, or invalid.
NOT_FOUND404The resource doesn't exist, doesn't belong to your organization, or the route itself doesn't exist. Used for "exists but isn't yours" too — see below.
ALREADY_EXISTS409Two distinct cases share this code: (1) a POST reused an externalId that already exists for this organization (destinations) or this destination (subscriptions) — PUT is the idempotent alternative; (2) replay refused a delivery that's still in flight, was claimed by a concurrent replay, was superseded by a newer delivery, or can't be replayed for a feed-access reason. message tells the two apart.
CONFLICT409A destination's active-secret cap (POST .../secrets/rotate) was hit; rotate again once the oldest overlapping secret expires.
RATE_LIMITED429POST /events/destinations/validate exceeded 10 requests/minute/organization.
SERVICE_UNAVAILABLE503A dependency this request needed — the workflow engine, the rate-limit counter store — couldn't be reached. The request itself was fine.
INTERNAL_ERROR500An unexpected server error.

A stranger's id and a genuine 404 are indistinguishable on purpose: no /events route ever answers 403 for a resource that exists but belongs to another organization, because doing so would confirm the id exists at all.

{ref} resolution

Every path segment naming a destination or subscription ({ref}, {subRef}) accepts either the server-assigned UUID or the customer-supplied externalId. Resolution tries the UUID form first and falls back to externalId.

externalId is 1–255 characters matching [A-Za-z0-9._:-]+, and may not be UUID-shaped or exactly . or ... A UUID-shaped externalId would be shadowed by the server id it resembles, and ./.. are normalized out of a URL path, so neither could be addressed reliably as a {ref}. The rule applies on every write that sets an externalId — a POST body, a PATCH, and the {ref}/{subRef} of a PUT that creates.

A timestamp you send (since, until) must be ISO 8601 with a Z or an explicit offset, e.g. 2026-09-18T10:00:00Z or 2026-09-18T12:00:00+02:00. A timestamp with no zone is a 400 rather than being read in the server's time zone.

A malformed JSON body, an empty body sent as application/json, or an unsupported content type is a 400 or 415 with the code INVALID_BODY; a body over the size limit is a 413.

Those answers are only ever given to an authenticated caller. Your API key is checked before the body is read, so a request with a missing, malformed or invalid key is a 401 whatever it sent — the body is not parsed and its size is not checked.

Strict request bodies

Every /events write route rejects unknown body fields: request schemas are strict, so a field the endpoint doesn't recognize is a 400 (INVALID_BODY) that names the offending field, rather than being silently ignored. If you send extra fields — internal tracking metadata, a client timestamp, anything the schema doesn't declare — on a POST, PUT, or PATCH, strip them first.

Destinations

POST /events/destinations

Creates an event destination and mints its first signing secret. The secret is returned in full and is not retrievable any other way than GET /events/destinations/{ref}/secrets. The response carries a validation block from a pre-flight reachability probe: an unreachable endpoint is reported, not rejected, since destinations are commonly created before the consumer is deployed. Only a structural problem — a non-HTTPS url, one over 2048 characters, or one resolving into a private or reserved range — is a 400.

An externalId that already exists for this organization is a 409 (ALREADY_EXISTS) — POST is not idempotent; use PUT /events/destinations/{ref} if you need safe retries against a fixed externalId.

Unlike POST /events/destinations/validate, this route is not rate limited, even though it makes the identical outbound probe — bulk provisioning is unrestricted today.

config.headers accepts static headers sent with every request, subject to:

  • Names must be a valid HTTP header token (^[!#$%&'*+\-.^_\|~0-9A-Za-z]+$`).
  • Values may not contain CR, LF, or NUL.
  • At most 20 headers, and 8 KiB total across all names and values (UTF-8).
  • A name that case-insensitively collides with a transport- or platform-controlled header — host, content-length, transfer-encoding, connection, keep-alive, upgrade, webhook-id, webhook-timestamp, webhook-signature, content-type, user-agent — is rejected; those are set by Event Delivery itself and cannot be overridden.

A violation of any of these is a 400 (VALIDATION_FAILED) naming the offending header. The same rules apply to config.headers on PUT and PATCH.

curl -X POST https://api.feeds.onhelix.ai/events/destinations \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Newsroom CMS",
"transport": "webhook",
"config": { "url": "https://cms.acme.com/hx", "headers": { "X-Tenant": "acme" } },
"debounceWindowSeconds": 0
}'
{
"success": true,
"data": {
"id": "8f14e45f-ceea-467e-adde-3fb5c25adfcd",
"status": "enabled",
"secrets": [{ "secret": "whsec_…", "expiresAt": null }],
"validation": {
"reachable": true,
"statusCode": 200,
"responseTimeMs": 142
}
}
}

Every destination response (create, get, list, replace, update) carries these fields worth calling out — the rest are self-explanatory:

FieldMeaning
statusenabled, disabled (you set this), or auto_disabled (the platform set this — see Auto-disable).
statusReasonWhy the destination is in its current status. Set automatically to "Auto-disabled after N consecutive failed deliveries" when auto-disable trips; cleared to null whenever you change status yourself via PATCH.
consecutiveFailuresThe live streak auto-disable counts against. A success resets it to 0; poll or alert on it if you want warning before the destination trips.
lastSuccessAtWhen the destination last succeeded, ever — not cleared by a later failure.
lastFailureAt / lastErrorMessageWhen the destination last failed, and why, since its last success — a success clears both back to null, so a non-null value means every attempt since the last success has failed.

GET /events/destinations

Returns the calling organization's event destinations. Query params: limit (default 50, max 100), offset.

POST /events/destinations/validate

Checks a config without saving anything: HTTPS, address safety, and reachability. Returns 400 only for structural problems. An unreachable endpoint is reported, not rejected.

Because it makes an outbound request to the url you supply, this endpoint is limited to 10 requests per minute per organization. Over the limit the response is 429 with a Retry-After header (in seconds) and the error code RATE_LIMITED; retry after that interval.

POST /events/destinations/{ref}/test

Sends one delivery.test event to this destination, now, through the same transport a real delivery uses — same CloudEvents envelope, same webhook-id / webhook-timestamp / webhook-signature headers signed with the destination's active secret(s) (both, during a rotation overlap), same custom headers, timeout and address checks. Nothing is recorded: no delivery row, no health change. delivery.test is not in the catalog and cannot be subscribed to; your handler should verify it like any event, return 2xx, and otherwise ignore it. Shares the validate endpoint's limit of 10 requests per minute per organization. See Reliability: Test deliveries.

{
"success": true,
"data": {
"delivered": true,
"statusCode": 200,
"responseTimeMs": 142,
"error": null,
"webhookId": "…",
"event": { "type": "delivery.test", "id": "…", "time": "…" }
}
}

delivered is true only for a 2xx; otherwise statusCode carries what the endpoint returned (or null if it never answered) and error says why.

GET /events/destinations/{ref}

Resolves {ref} as a destination id first, then as an externalId.

PUT /events/destinations/{ref}

Idempotent upsert keyed by externalId. Re-running the same request is a no-op rather than a duplicate. Replaces every mutable field, so an omitted optional field is cleared. Never rotates an existing destination's signing secret. A UUID-shaped {ref} that does not resolve to an existing destination is a 404, not a create — use a non-UUID-shaped externalId if you want PUT to create, or use POST with an explicit externalId. The url is checked as on create: a non-HTTPS url, or one resolving into a private or reserved range, is a 400 on both the create and the replace branch.

PATCH /events/destinations/{ref}

Merges the supplied fields. Omitted fields are left unchanged. A supplied config.url is held to the same rule as on create: a non-HTTPS url, or one resolving into a private or reserved range, is a 400 (no reachability probe runs). The secrets in this and every other destination response are the active ones only, newest first — the same set GET /events/destinations/{ref}/secrets returns. This is how you re-enable a destination after fixing whatever caused auto-disable:

curl -X PATCH https://api.feeds.onhelix.ai/events/destinations/{ref} \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "enabled" }'

DELETE /events/destinations/{ref}

Deletes the destination, its secrets, its subscriptions, and its delivery history. In-flight deliveries stop. This cannot be undone.

Secrets

GET /events/destinations/{ref}/secrets

Returns every secret still valid for verifying this destination's signatures — never-expiring ones plus any inside a rotation overlap window. Values are returned in full: the API key already grants complete access to the organization.

POST /events/destinations/{ref}/secrets/rotate

Mints a new secret and sets expiresAt 24 hours out on every never-expiring secret, in one transaction. A secret already inside an earlier rotation's overlap keeps its original expiresAt. Both the old and new secret sign outgoing requests during the overlap window. Concurrent rotations of one destination run one after another, so exactly one secret is left never-expiring. A destination may hold at most 10 active secrets (the current one plus those still in their overlap); a rotation beyond that is a 409 until the oldest overlapping secret expires. See Signature Verification.

POST /events/destinations/{ref}/secrets/{secretId}/expire

Retires one secret immediately: from this call on it no longer signs and no longer verifies. This is the answer to a leaked secret — rotation keeps the previous secret signing through its overlap window, which is right for routine rotation and wrong for a compromise. The sequence for a leak is: rotate (the new secret takes over), update your verifier, then expire the leaked one here. Expiring a destination's only active secret is refused with a 409, since it would leave nothing to sign with. A secret that is not this destination's, or has already expired, is a 404. Returns the secrets still active, as GET does.

Subscriptions

GET /events/destinations/{ref}/subscriptions

Returns the destination's subscriptions, oldest first. total counts the whole set, not the page.

GET /events/destinations/{ref}/subscriptions/{subRef}

Returns a single subscription. Resolves {subRef} as a subscription id first, then as an externalId, same as every other {ref}/{subRef} path segment (see {ref} resolution). A subscription that doesn't belong to the resolved destination — including one belonging to a different destination or a different organization — is a 404.

POST /events/destinations/{ref}/subscriptions

Declares what this destination receives. scopeKind: "all" takes every event the organization can see, "feed_type" every feed of one kind, and "feeds" exactly the pinned feeds. Every pinned feed must be one the calling organization owns or is subscribed to; one it cannot read is a 404. An externalId that already exists for this destination is a 409 (ALREADY_EXISTS) — POST is not idempotent; use PUT if you need safe retries against a fixed externalId.

curl -X POST https://api.feeds.onhelix.ai/events/destinations/{ref}/subscriptions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"eventTypes": ["news.*"],
"scopeKind": "feeds",
"feeds": [{ "feedType": "news", "feedId": "…" }]
}'

See Concepts for the wildcard grammar and Feed scoping for scopeKind semantics.

PUT /events/destinations/{ref}/subscriptions/{subRef}

Idempotent upsert keyed by externalId. Replaces the whole declaration, including feed pins — an omitted feeds clears them rather than merging. A UUID-shaped {subRef} that does not resolve to an existing subscription is a 404, not a create. Feed access is re-checked on every replace.

PATCH /events/destinations/{ref}/subscriptions/{subRef}

Merges the supplied fields. Omitted fields are left unchanged, except feeds, which replaces the pin set wholesale when supplied. The merged scope is validated as a whole, so a patch cannot reach a combination a create would have rejected. active: true re-checks access to every pinned feed, stored pins included: re-activating a subscription that was switched off because feed access was lost is a 404 until access is restored.

DELETE /events/destinations/{ref}/subscriptions/{subRef}

Removes the subscription and its feed pins. The destination keeps any other subscriptions. Any of its deliveries still pending (not yet sent) are discarded rather than sent — deleting a subscription stops its queued work instead of letting it drain.

Deliveries

GET /events/destinations/{ref}/deliveries

The delivery log for one destination, newest first. Query params: status (pending | delivering | succeeded | failed | superseded | discarded), type, feedId, since, until, limit, offset. type takes the same forms a subscription does — an exact type, a trailing wildcard such as news.*, or * — and any other form is a 400. since and until are inclusive, need a Z or an explicit offset, and since after until is a 400. type and feedId filter on the delivered event, not on the delivery, so they answer "did this feed's items reach me" directly.

Each row is readable as a log line on its own. It carries the event it delivers under eventtype, source, subject, time, feedType, feedId — and the outcome of its newest attempt under lastAttempt (attemptNumber, trigger, responseStatus, errorMessage, failureReason, attemptedAt; null until the first attempt has been made). So "which items failed to reach me in the last hour, and with what status" is one request. The payload and the full attempt history are on GET /events/deliveries/{id}.

{
"id": "…",
"eventId": "…",
"status": "failed",
"attemptCount": 9,
"nextAttemptAt": null,
"createdAt": "2026-09-19T10:00:02.000Z",
"completedAt": "2026-09-20T23:30:02.000Z",
"event": {
"type": "news.item.added",
"source": "/feeds/news/2f8c…",
"subject": "news-item:9b1e…",
"time": "2026-09-19T10:00:00.000Z",
"feedType": "news",
"feedId": "2f8c…"
},
"lastAttempt": {
"attemptNumber": 9,
"trigger": "automatic",
"responseStatus": 503,
"errorMessage": "HTTP 503 Service Unavailable",
"failureReason": "http_status",
"attemptedAt": "2026-09-20T23:30:02.000Z"
}
}

POST /events/destinations/{ref}/backfill

Replays a historical window to this destination against its current subscriptions. See Reliability for the 30-day window bound and what "against its current subscriptions" means for a subscription that changed since the window.

The scan is also capped at 10,000 in-scope events, independent of the 30-day window — a busy window well inside the 30-day bound can still exceed it. Exceeding the cap is refused outright with a 400 (VALIDATION_FAILED) rather than partially backfilled: narrow the window and run backfill again in parts, oldest-first, until the whole gap is covered.

curl -X POST https://api.feeds.onhelix.ai/events/destinations/{ref}/backfill \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "since": "2024-01-01T00:00:00Z", "until": "2024-01-02T00:00:00Z" }'
{
"success": true,
"data": { "eventsScanned": 42, "deliveriesCreated": 40 }
}

eventsScanned is how many events fell inside the window; deliveriesCreated is how many new deliveries this run made. A re-run over an overlapping window reports eventsScanned again but deliveriesCreated: 0 — nothing is created twice.

GET /events/deliveries/{id}

The full attempt history for one delivery, including each response status and body, oldest attempt first, plus the event it carries in full. event.data is the exact payload the webhook body contained, so a consumer that lost a delivery — a crashed handler, a truncated log — can recover it from here rather than replaying it. trigger distinguishes the automatic retry schedule from a manual replay. Delivery ids are not scoped by destination in the path — the organization is derived from the delivery's destination and checked on every call, so a mismatch is a 404.

Each attempt is given 30 seconds to receive a response; a destination that hasn't answered by then counts as a timeout — a retryable transport-level failure, same as a dropped connection. responseBody is stored as received, cut off at 10,000 characters with a "... (truncated)" suffix if the destination's response was longer.

Each attempt also carries renumberedFrom. It is null on every ordinary attempt. A non-null value means the attempt asked for that number and found it taken, because two drivers — an automatic retry and a manual replay, say — were sending this delivery at the same time: your endpoint may have received it twice, with the same webhook-id, and your idempotency key is what protects you.

A delivery's nextAttemptAt is when its next attempt is scheduled: it moves forward with the retry schedule while the delivery is pending or retrying, and is null once the delivery reaches a terminal status.

POST /events/deliveries/{id}/replay

Sends the delivery again, now: the debounce window is skipped, and the attempts this run records are marked manual_replay. See Reliability for the rules governing when a delivery can be replayed.

Returns 202 Accepted — the replay is dispatched, not completed, by the time this responds:

{
"success": true,
"data": { "deliveryId": "…", "workflowId": "…" }
}

workflowId identifies this replay run and is always distinct from the original delivery's run; poll GET /events/deliveries/{id} to see the new attempt land.

Catalog

GET /events/types

Returns every event type the platform knows about, with the JSON Schema of its data payload. Use these values in a subscription's eventTypes, either exactly or with a wildcard such as news.*. Generated directly from the platform's event catalog, so it can never omit or misdescribe a type — but not every listed type is delivered today: a reserved type such as event.item.updated is included with currentlyEmitted: false (see the "Not currently emitted" callout under Event Types below). Check that field before building on a type.

curl https://api.feeds.onhelix.ai/events/types \
-H "Authorization: Bearer YOUR_API_KEY"

Browse the schema for each type on its own page — see Event Types below — or the full taxonomy in Concepts.

Event Types

Not currently emitted

event.item.updated is reserved. It is valid in a subscription's eventTypes and is returned by GET /events/types, but nothing in the platform publishes it today, so subscribing to it will never deliver anything. The other six types above, including factcheck.status.changed, do fire.