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.
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:
| Code | Status | Meaning |
|---|---|---|
INVALID_BODY | 400, 413, 415 | The 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_PARAMS | 400 | A path parameter ({ref}, {subRef}, {id}) failed validation. |
INVALID_QUERY | 400 | A query parameter failed validation — most often a zone-less since/until, or an out-of-range limit/offset. |
VALIDATION_FAILED | 400 | A 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_FAILED | 401 | The API key is missing, malformed, or invalid. |
NOT_FOUND | 404 | The 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_EXISTS | 409 | Two 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. |
CONFLICT | 409 | A destination's active-secret cap (POST .../secrets/rotate) was hit; rotate again once the oldest overlapping secret expires. |
RATE_LIMITED | 429 | POST /events/destinations/validate exceeded 10 requests/minute/organization. |
SERVICE_UNAVAILABLE | 503 | A dependency this request needed — the workflow engine, the rate-limit counter store — couldn't be reached. The request itself was fine. |
INTERNAL_ERROR | 500 | An 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.
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:
| Field | Meaning |
|---|---|
status | enabled, disabled (you set this), or auto_disabled (the platform set this — see Auto-disable). |
statusReason | Why 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. |
consecutiveFailures | The 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. |
lastSuccessAt | When the destination last succeeded, ever — not cleared by a later failure. |
lastFailureAt / lastErrorMessage | When 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 event — type, 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
- news.item.added
- event.item.added
- event.item.updated
- event.item.removed
- factcheck.completed
- factcheck.failed
- factcheck.status.changed
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.