Skip to main content
Hookie exposes three HTTP surfaces (plus the public agent self-registration endpoints): the public ingest + streaming API (authenticated by a path-form endpoint URL or an ik_live_… ingest key), the token-authed customer portal API for your end customers, and the admin API that drives the console (authenticated by your session in the browser, by an OAuth access token when a connected agent is acting on your behalf, or by an API key for CI jobs, scripts and backends).

Ingest

Bodies may be JSON, application/x-www-form-urlencoded, or multipart/form-data, so an HTML form can post straight to an endpoint with no JavaScript (see the quickstart). Anything else is read as JSON first, regardless of its Content-Type, which is what most webhook providers send; a body that parses is stored exactly as it arrived. A body that is not JSON is kept as text when its Content-Type is textual: text/* (plain text, CSV, XML), application/xml and other +xml types, NDJSON / JSON Lines (application/x-ndjson), application/csv and YAML. That covers plain-text alerts, SOAP and other XML outbound messages, CSV exports and NDJSON batches. Hookie stores it as one event with the text verbatim:
Criteria and mappings address it as body and content_type. NDJSON is not split into one event per line. Malformed JSON sent as application/json (or with no Content-Type) is still refused with 400, and a non-JSON body of any other type, such as application/octet-stream, is refused with 415. Every body is capped at 1,000,000 bytes. Disabling an endpoint. In the console, Disable asks you to confirm first. While an endpoint is disabled its URL answers 503 with "reason": "endpoint_disabled" and stores nothing; a URL that names no endpoint is still 404. The difference matters to senders: providers treat a 404 as a dead URL and may switch the webhook off or drop events, but they retry a 503, so events sent while it is disabled can still arrive if you enable it again within the provider’s retry window. Rate limits. Each credential (one endpoint URL, or one ingest key) has a burst limit of about 100 requests per 10 seconds, on every plan. It is a best-effort burst guard, not an exact count: each Cloudflare location counts separately, and its count catches up within seconds, so a short burst can get through above 100 before the first 429, and a sender whose requests reach several locations is counted once at each. Your plan’s monthly event quota is separate from it, and exact. Past either one, ingest answers 429 with a Retry-After header: 10 seconds for the burst limit and 3600 for the quota. The same value is in the body as retry_after. The Data API’s per-key burst limit is the same kind of guard and answers the same way, with Retry-After: 10. See Limits and plans. Key matching. An ingest key is matched on the whole token, never on its visible prefix, and a revoked or expired key never matches, so no two keys can stand in for each other, on ingest or on /v1/stream. /v1/webhooks/{key}/{slug} looks the slug up in the key’s project only. A slug that belongs to another project, even in the same workspace, is 404. An endpoint applies its own criteria and mappings to every event it receives, on its public path-form URL as on /v1/webhooks/{key}/{slug}. Every criterion must hold, or the event is kept as a submission but makes no record, and routed comes back empty. Mappings pick fields into the record’s shape; with none, the payload is stored whole. Project mapping rules are not evaluated on an endpoint, and not on /v1/ingest/{ingest_key}/{dataset}, which stores the payload whole into that dataset. Rules run on exactly one shape: POST /v1/ingest/{ingest_key} with no dataset, where every matching rule writes its own record, and an event none of them matches is stored whole, as one record, in the key’s default dataset. A path-form endpoint writes only to its own dataset: a trailing {dataset} that names any other is refused with 403, so a public form’s URL cannot be used to write elsewhere in the project. To send one source into several datasets, use an ingest key. An endpoint can verify its provider’s signature (Stripe, GitHub, Shopify, Slack, Standard Webhooks or a generic HMAC) before anything is stored, an ingest key can require an HMAC signature, and a source-IP allowlist is enforced at every level that has one: the endpoint or key, the project and the workspace. An endpoint’s URL and an ingest key can each be rotated with an overlap, and a key can expire; see Ingest keys. The 201 answer. An accepted event answers 201 with submission_id, routed and records (the length of routed). routed holds the dataset the event was stored in, for an endpoint or a dataset named in the path, and the names of the rules that applied on POST /v1/ingest/{ingest_key}. That route alone also answers fallback (boolean): true when no mapping rule routed the event and it was stored whole in the key’s default dataset, with routed then holding that dataset’s name; false when rules routed it. An endpoint whose criteria reject the payload answers 201 with routed: [], records: 0 and filtered: true, and the submission is marked Filtered (forward_status: "filtered"); a payload that matches carries no filtered key. On the legacy /v1/webhooks/{ingest_key}/{slug} route, an endpoint whose stored rule cannot be read is a routing failure too, not a 500: the event is kept, listed on the project’s Errors tab with the reason “Endpoint rule is malformed”, and routed in the background once the endpoint is saved again. A routing failure is still a 201, with routed: []: the raw submission is kept, marked Routing failed.

Streaming

See Live streaming for SSE/WebSocket details and resume.

Agent self-registration

Public, with no cookie and no key. An AI agent creates its own Free-plan workspace and receives an admin API key and a one-time claim link, each shown once. See Agent self-registration for the proof of work, the refusals and the limits.

Customer portal API

A separate, token-authed surface for your end customers, scoped to one portal and customer. The token is not accepted in the query string, and each token may make about 60 calls a minute, a best-effort guard counted separately at each Cloudflare location. See Customer portal for issuing tokens and embedding.

Admin API

The console’s admin API lives at /admin/api/projects/{project_id}/… and drives everything you see in the app. Sign in to explore it. It accepts a browser session, or Authorization: Bearer with either an OAuth access token, which is how a connected agent reaches the very same routes, or an API key (hk_…). While a workspace is suspended, reads keep working and every write answers 403 with "reason": "workspace_suspended", for a connected agent or an API key exactly as for the console. The exceptions are billing, revoking an API key, and taking access away or moving elsewhere: revoking an invite, removing a member or leaving, accepting an invite to another workspace, and switching workspaces. Every credential may make about 100 requests every 10 seconds, after which the API answers 429 with Retry-After: 10. Like the ingest burst limit, this is a best-effort guard counted separately at each Cloudflare location, so a short burst can get through above it. A POST may carry an Idempotency-Key, so a retry is answered once for 24 hours rather than run twice, and every response carries the API version in Hookie-Version. API keys covers all three. The resources share one contract:
  • Get one by id. GET <resource>/{id} returns the object under the resource’s singular name (rule, webhook, destination, source, trigger, view, and so on), or 404. Ingest keys use ingest_key, because a create’s key is the plaintext token.
  • Creates return what they created, under that same name, alongside every field they already returned.
  • Lists page. Every configuration list takes limit (1 to 1,000, default 1,000) and offset, and adds total, limit and offset beside the array. The array keeps its name and shape, so a caller that sends neither gets every row, as before. A dataset’s records page by cursor instead; see Reading a whole dataset.
Project-scoped resources: Workspace-level resources sit directly under /admin/api/… with no {project_id} segment:

Reading a whole dataset

GET /admin/api/projects/{project_id}/datasets/{name} returns a dataset’s records, newest first, each flattened into columns and rows with _id, _received_at and _source. It pages by cursor, so a walk of the whole dataset is exact however many records arrive meanwhile, and can resume where it stopped:
  • limit is 1 to 1,000 (default 1,000).
  • Every page carries next_cursor: pass it back as cursor for the next page. It is null on the last page. A cursor is opaque; one this API did not issue is refused with 400.
  • The first page (no cursor) also carries total, so you can show progress. Later pages skip the count.
This is what the console’s CSV and XLSX export walks. For filtered, searchable reads, use search/events; for tools outside Hookie, the Data API.

Export, import and clone a project

A project’s configuration can leave it as one document, and that document can build the same setup in another project. Use it to promote a pipeline from staging to production, or to start a new customer’s project from a template. In the console, it is under the project’s Settings → Configuration: Export JSON, Export YAML, Import configuration (JSON) and Clone project.
The document is the same hookie.yml shape the CLI reads, so hookie export writes it and hookie apply applies it (see Configuration). Connected agents have export_project_config and import_project_config. What is in it: endpoints (the newest version of each, with its criteria, mappings, dataset, IP allowlist and signature verification scheme and settings, never the secret; an imported or cloned verifying endpoint refuses every request until you set its secret, and the response lists it under needs_verification_secret), rules, HTTPS webhook destinations (name, URL, dataset filter and delivery limits), AI agents, workflows, AI triggers, cron triggers and record views. What is never in it:
  • Endpoint URLs. An endpoint’s URL is its credential. The document carries the endpoint’s readable slug only, and every imported endpoint gets a new URL. A destination that delivers into one of this project’s own endpoints is written as endpoint: <slug>, and import points it at the new endpoint’s URL. A destination into another project’s endpoint, into one that has since been deleted, or with a dataset in its URL is left out and counted under not_exported.
  • Signing secrets. Every imported destination gets a new signing secret, so its receiver must be given the new one.
  • Ingest keys. Create new keys in the target project.
  • Slack and S3 destinations. Each is its credential: a Slack incoming-webhook URL or a secret access key. They are counted under not_exported; create them again in the target project.
  • Sources, WebSocket triggers and portals. Their connection settings are encrypted credentials, and a portal belongs to its customers and their tokens. They are listed under not_exported, with a count and the reason, rather than silently dropped. Set them up again in the target project.
  • Destinations that belong to a portal customer, and anything the project has received: records, submissions, deliveries and run history.
A workflow’s http step headers are exported as stored, like the rest of the definition, which is why they should never hold a secret. Import only adds. Each resource is created through its own create route, so it is validated, plan-limited and audited exactly as if you had created it by hand. A resource whose identity already exists in the project is skipped, never overwritten: an endpoint by its slug, a record view by its dataset and name, everything else by its name. An AI agent is exported with a ref, and a workflow’s agent_call that names that ref is pointed at the agent created (or found) in the target project. One request imports at most 250 resources. The console imports JSON files; import YAML with hookie apply. The response lists what was created, skipped and failed, with ok: false if anything failed. To bring an existing project in line with a document, changes included, use hookie apply, where you review the diff first. Endpoint slugs are unique in the workspace, not just the project. So an endpoint imported into another project of the same workspace, which is always the case for a clone, cannot keep its slug. It is created as {slug}-copy, or -copy-2 up to -copy-5 if that is taken too, and its entry in created carries a note saying so. A second import of the same document recognises the copy and skips it. The public URL is minted fresh either way, so the slug change affects only the endpoint’s label. Clone creates a new project of the same type, without the starter endpoint a new project normally gets, and imports the source project’s export into it. It answers 201 with the new project and the import result. Importing needs the developer role or above; cloning creates a project, so it needs owner or admin.

Request headers

Request details

Every submission keeps the request it arrived in: the method, the headers (at most 64, about 8 KB), the query string, the Content-Type and the source IP. Credentials are never kept: Authorization, Cookie, and any header or query parameter named like a key, token, secret, password or session is stored as "[redacted]", and Cloudflare’s own proxy headers are dropped. GET /admin/api/projects/{project_id}/records/{id} and the correlation chain return it as request.

HTML forms, CORS and URL verification

These are per-endpoint settings, set in the console’s Edit… form or with PATCH /admin/api/projects/{project_id}/webhooks/{id}. They apply to the endpoint’s public URL only; ingest-key routes never answer CORS.
  • redirect_url: an https:// page that a browser’s native form post is sent to (303) once it is stored.
  • cors_origins: up to 20 origins (https://example.com, with no path), or "*", whose fetch() may call the URL. The endpoint answers the OPTIONS preflight (Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: Content-Type, Idempotency-Key) for them. By default no origin is allowed.
  • handshake: the provider URL check that the endpoint answers without storing anything or counting it against your quota. Choose one of slack (url_verification), meta (GET ?hub.challenge, checked against your verify token), graph (Microsoft Graph’s ?validationToken), zoom (endpoint.url_validation, signed with your secret token) or twitch (webhook_callback_verification). handshake_secret holds Zoom’s secret token or Meta’s verify token. It is stored encrypted and never returned; list only says handshake_secret_set.

Refused requests

Every request that is refused once its endpoint or key is known is logged with its reason, status, time and source IP: IP allowlist, signature, quota, burst rate limit, disabled endpoint, body too large, unsupported or unparseable body, loop, failed URL verification, disallowed browser origin, and suspended workspace. Repeats of one reason are counted per minute. The log is kept for your plan’s retention window, and refused requests never count against your quota. GET /admin/api/projects/{project_id}/webhooks/{id}/activity (also the console’s Activity, the get_webhook_activity MCP tool and hookie endpoints activity) lists an endpoint’s received and refused requests for the last 7 days, newest first, with counts for the last 24 hours. A request to an unknown URL or key is not logged, because until it resolves there is no workspace to log it against.

Response codes

Outbound (to your destinations)

Deliveries are signed twice and carry Hookie-Signature, the Standard Webhooks headers webhook-id, webhook-timestamp and webhook-signature, Hookie-Event-Id, Hookie-Delivery-Id and Hookie-Hop. Delivery is at-least-once and unordered, so dedupe on the event ID and verify the signature. A 5xx, a network error, or a 404, 408, 409, 425 or 429 is retried after about 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h (eight attempts over roughly a day, with jitter, and never sooner than a Retry-After your service sends). Any other 4xx means your service refused the request, so it is not asked again: the delivery is marked dead after that one attempt, with the error HTTP 4xx (not retried: the receiver refused the request). A dead delivery can be replayed. A delivery waiting on a disabled destination is paused, not failed. It keeps its last response and is sent when the destination is enabled again. A delivery whose destination was deleted is cancelled. Neither counts as a failure. A destination’s dataset filter is a list of dataset names; leave it out (or send null) for every dataset. An empty list is refused, since it used to mean “every dataset” rather than “none”; to stop deliveries, disable the destination. Creating a destination whose URL is one of the same project’s own endpoint URLs or ingest keys, with a filter that admits the dataset it writes to, still succeeds but returns a warning: every event would loop back until Hookie-Hop stops it. An event acknowledged with 201 is fanned out even if the first attempt at that fails: it is re-driven every few minutes for about eight hours, and if it still cannot be, the workspace audit log records a fanout_abandoned entry. The same goes for routing: if the event was stored but its records were never written (the worker stopped part-way, or a database write failed), the re-drive first routes it exactly as the first attempt would have, then fans it out. That means an endpoint URL’s own criteria and mappings (a payload they reject is still not stored, and an endpoint deleted in the meantime routes nothing), an ingest key’s rules and then its default dataset, or a trigger’s dataset and mappings, with default for a trigger that has no dataset. An event that already has a record is never routed again, and a re-drive is not charged against your event quota. Rows polled by a database source, events from cron and WebSocket triggers, the record a workflow’s call_ai step writes to its output_dataset, a workflow’s emit_event records (unless the step sets deliver: false), and an AI trigger’s output record fan out exactly like ingested ones, including that re-drive. An AI trigger’s output fires no AI trigger and starts no workflow, and an emitted record starts no workflow, so neither can loop.

Replaying in bulk

GET /admin/api/projects/{project_id}/deliveries/{id} returns one delivery with its record_id, the record it sent (read it with GET …/records/{record_id}), its latest response body (16 KiB) and attempts: every attempt it made, oldest first, each with its time, ok, status_code (null when nothing answered: a timeout or a refused connection), response_ms, error and the first 512 characters of the response body. So a timeout, then a 502, then a 401 are three attempts, not one. attempts_not_recorded counts attempts made before attempt history was kept. Attempts are kept for your plan’s retention window. GET /admin/api/projects/{project_id}/deliveries/metrics?window=24h (or 7d, 30d; add destination_id for one destination) is delivery health per destination and in total: deliveries by outcome and their success_rate, attempts and attempt_success_rate, latency_ms percentiles (p50, p95, p99, over every attempt, so a timeout counts as its full timeout), failed attempts by class in errors (3xx, 4xx, 5xx, timeout, connection, other), and a series of ok and failed attempts per hour (per day for 30d). The Deliveries tab shows it as Destination health; the get_delivery_metrics MCP tool and hookie deliveries metrics read it. POST /admin/api/projects/{project_id}/deliveries/replay replays every delivery that did not arrive, by filter: destination_id, status (dead, failed, or both, the default), since and until (ISO 8601), and limit (1 to 500, default 100). Only dead deliveries and failed ones with no retry scheduled match, only the latest delivery of each event to each destination, and only while the event is stored. It works oldest first and answers {queued, matched, remaining, more}: call it again while more is true. Send dry_run: true to get {matched} without sending anything. Each replay is charged to the monthly delivery allowance; when it runs out the call stops (stopped: "delivery_allowance_exhausted"), or answers 429 if it could queue none. The Deliveries tab’s Replay matching button, hookie replay --since 1h --failed and the replay_deliveries MCP tool all use this route.

Delivery settings

PATCH /admin/api/projects/{project_id}/destinations/{id} also takes a destination’s delivery limits: timeout_seconds (1 to 30, default 10), max_per_second (0.1 to 1000 deliveries a second) and max_concurrency (1 to 100 requests in flight). null restores the default. A delivery over the rate or the concurrency cap is deferred: it waits on the queue for its turn, is not failed, and does not use up one of its eight attempts. POST …/destinations/{id}/rotate-secret keeps the previous signing secret signing for 24 hours (Verify signatures); POST …/destinations/{id}/expire-previous-secret ends that window early.

The monthly delivery allowance

A delivery is one event sent to one destination, and each plan includes a number of them a month: 1,000 on Free, 90,000 on Pro and 600,000 on Team, with a 10% grace on the paid plans. A delivery is counted when the fan-out creates it. Retries of it are not counted again, and neither is an event whose fan-out is re-driven. A replay is a new delivery and is counted; when nothing is left it is refused with 429 and reason: "delivery_allowance_exhausted". Past the allowance, events are still received, stored and routed. Their deliveries are held, never dropped: GET /admin/api/projects/{project_id}/deliveries/held lists held events oldest first, with the month’s allowance (used, quota, ceiling). POST /admin/api/projects/{project_id}/deliveries/held/release sends them once there is room (a new month, or a plan upgrade). It works oldest first, and it stops at the first event it cannot fully cover, so events go out in the order they arrived. The console shows the month’s count on Home and a Send held deliveries button on a project’s Deliveries tab; MCP agents have list_held_deliveries and release_held_deliveries. A held event that ages past your plan’s retention is pruned along with it, since there is nothing left to send.