# Agent self-registration Source: https://docs.hookie.ai/agent-self-registration Let an AI agent create its own Hookie account with no person involved, run its own projects on the Free plan, and hand a claim link to a person when it needs one. An AI agent can sign up for Hookie by itself, with no person involved, and then set up and run its own projects: endpoints, rules, destinations, workflows and the rest. The account starts on the **Free** plan. When a person later wants to upgrade it or manage it, they **claim** it with a link the agent was given at registration. If you already use Hookie and want your coding agent to work in **your** workspace, connect it over OAuth instead. See [Connected agents](/connected-agents). Self-registration is for an agent that should have an account of its own. Already use WorkOS Agent Auth? An agent can sign up through [WorkOS Agent Registration](/workos-agent-auth) instead and gets the same Free account. Registration takes two calls, with a small proof of work between them: `GET /v1/agents/register/challenge` returns a signed challenge and the current Terms version. Find a nonce whose hash with the challenge starts with enough zero bits. About a million hashes, one to three seconds of CPU. `POST /v1/agents/register` with the solution and your acceptance of the Terms. The response carries an admin API key and a claim link, each shown once. Both endpoints are public: no cookie, no key. They are in the [OpenAPI document](https://hookie.ai/openapi.json) under the **Agents** tag. ## 1. Get a challenge ```bash theme={"dark"} curl -s https://app.hookie.ai/v1/agents/register/challenge ``` ```json theme={"dark"} { "challenge": "v1.eyJpZCI6…", "algorithm": "sha256", "difficulty_bits": 20, "expires_at": "2026-10-01T12:10:00.000Z", "solve": "Find a nonce, a decimal string of at most 32 digits, such that SHA-256(challenge + \":\" + nonce) starts with 20 zero bits. …", "register_url": "https://app.hookie.ai/v1/agents/register", "terms_version": "2026-10-01", "terms_url": "https://hookie.ai/legal/terms" } ``` The challenge is cheap for one agent and costly for a script creating accounts in bulk. It expires in ten minutes and registers at most one account. It is signed by Hookie, so a client cannot mint its own, lower the difficulty, or extend the expiry. Always solve at the `difficulty_bits` the challenge states. It is 20 normally, and 2, 4 or 6 bits more (4, 16 or 64 times the work) on a day when registrations are close to Hookie's daily limit. ## 2. Solve the proof of work Find a `nonce`, a decimal string of at most 32 digits, such that: ```text theme={"dark"} SHA-256( challenge + ":" + nonce ) ``` starts with `difficulty_bits` zero bits. Hash the UTF-8 bytes of the challenge string exactly as you received it, a colon, and the nonce. Count up from `0`. At 20 bits that is about a million hashes. ```js Node.js theme={"dark"} import { createHash } from "node:crypto"; function solve(challenge, bits) { for (let n = 0; ; n++) { const d = createHash("sha256").update(`${challenge}:${n}`).digest(); let zeros = 0; for (const byte of d) { if (byte === 0) { zeros += 8; continue; } zeros += Math.clz32(byte) - 24; break; } if (zeros >= bits) return String(n); } } ``` ```python Python theme={"dark"} import hashlib def solve(challenge: str, bits: int) -> str: n = 0 while True: d = hashlib.sha256(f"{challenge}:{n}".encode()).digest() if int.from_bytes(d, "big") >> (256 - bits) == 0: return str(n) n += 1 ``` The server checks your answer with a single hash. ## 3. Register Read the [Terms of Use](https://hookie.ai/legal/terms) first. By sending `accept_terms`, the agent accepts them for the person or organization it acts for (its operator), who is responsible for what it does with the account. ```bash theme={"dark"} curl -s -X POST https://app.hookie.ai/v1/agents/register \ -H 'Content-Type: application/json' \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "agent_name": "release-bot", "operator_contact": "platform-team@example.com", "accept_terms": "2026-10-01", "pow": { "challenge": "v1.eyJpZCI6…", "nonce": "1048213" } }' ``` | Field | Required | Meaning | | - | - | - | | `agent_name` | Yes | Up to 80 characters. Names the agent and its workspace. | | `operator_contact` | No | The email address or `https://` URL of the person or organization responsible for the agent. | | `accept_terms` | Yes | The current Terms version: `terms_version` from step 1. Anything else is refused with `terms_not_accepted`. | | `pow` | Yes | `{ "challenge", "nonce" }`: the challenge from step 1, unchanged, and your solution. | `Idempotency-Key` is optional. Use a random value such as a UUID: it makes a retry safe across challenges. It counts only with a challenge that is unexpired and solved, and only for the same `agent_name` and `operator_contact`, so the same key with a different name is a different registration. If an attempt fails with `502 identity_provider_error`, the key is released: get a new challenge and send the same key again. **201 Created:** ```json theme={"dark"} { "account_id": "agt_0a1b2c3d4e5f60718293a4b5", "workspace": { "id": "…", "name": "release-bot's workspace", "slug": "release-bot-1a2b3c4d", "plan": "free" }, "project": { "id": "…", "name": "Default", "slug": "default" }, "api_key": { "id": "…", "key": "hk_…", "key_prefix": "hk_…", "role": "admin" }, "claim": { "token": "hkc_…", "url": "https://app.hookie.ai/#/claim/hkc_…" }, "endpoints": { "api": "https://app.hookie.ai/admin/api", "mcp": "https://app.hookie.ai/mcp" } } ``` **Store `api_key.key` and `claim.token` now.** They are shown once. Hookie keeps only their hashes and never returns them again: a retry of the same request gets `409 already_registered` with the `account_id` and no credential. ## 4. Use the API key The key is an ordinary admin [API key](/api-keys). Send it as `Authorization: Bearer hk_…` to the REST API, the hosted MCP server, or the CLI: ```bash REST theme={"dark"} curl -s https://app.hookie.ai/admin/api/projects \ -H "Authorization: Bearer $HOOKIE_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Orders","type":"webhook"}' ``` ```bash Claude Code (MCP) theme={"dark"} claude mcp add --transport http hookie https://app.hookie.ai/mcp \ --header "Authorization: Bearer $HOOKIE_KEY" ``` ```bash CLI theme={"dark"} HOOKIE_TOKEN=$HOOKIE_KEY HOOKIE_ALLOW_REMOTE=1 hookie projects list ``` ### What the key can do Everything an admin can do in a workspace, on `/admin/api/*`, `/mcp` and the CLI: create projects, endpoints, ingest keys, rules, triggers and workflows, read records, and replay deliveries. A Free workspace allows one destination and no database sources; a person can upgrade it after claiming. See the [API reference](/api-reference), [limits and plans](/limits-and-plans) and the [MCP tools](/connected-agents#tools). ### What it cannot do Some actions need a person. Until someone claims the workspace, the key is refused: * billing and upgrades * single sign-on and Directory Sync requests * inviting members * linking a Google Workspace domain * creating more API keys * minting Data API keys or changing what the Data API publishes Each answers `403` with `code: "agent_not_permitted"` and `claim_required: true`, and the message tells the agent to hand over its claim link: ```json theme={"dark"} { "error": "… needs a person. This workspace was registered by an AI agent and nobody has claimed it yet: give the claim link from your registration response (claim.url) to the person responsible for you. Once they claim the workspace, they can do this in the Hookie console.", "code": "agent_not_permitted", "claim_required": true } ``` After a claim, the same actions answer `agent_not_permitted` without `claim_required`: the person who claimed the workspace does them in the console. The agent identity itself can never sign in to the console or hold an OAuth token. ## 5. Hand the claim link to a person When the account needs a person, to upgrade it or to manage members, give the person responsible for the agent `claim.url`. Signed in to Hookie, they see which agent and workspace it is. They become the workspace's owner. The link works once. The agent's key keeps its admin role, now acting for the person. They can revoke it any time in **Settings → API keys**. From then on the workspace is an ordinary one: billing, members and the rest are the person's, in the console. The console's claim page calls `POST /admin/api/agent-accounts/claim-preview` and `POST /admin/api/agent-accounts/claim` with `{ "claim_token" }`; both need a signed-in person. ## Limits | Limit | What it means | | - | - | | **Free plan** | The workspace starts on Free, and every Free-plan quota applies in full: monthly events and deliveries, one destination, one member, seven-day retention. See [Limits and plans](/limits-and-plans). | | **Rate limit** | About five registration requests a minute per address (an IPv6 address counts by its `/64`). Past it, `429 rate_limited` with `Retry-After: 60`. | | **Daily cap** | Hookie limits how many agent accounts are registered per day, platform-wide. One network (an IPv4 address, or an IPv6 `/48`) may take a tenth of it. Past either, `429 daily_cap_reached` or `429 source_daily_limit`; retry after midnight UTC. | | **Difficulty** | Rises by 2, 4 and 6 bits as the day's registrations approach the cap. | | **Terms version** | `accept_terms` must equal the current `terms_version`. When the Terms change, the version changes, and the challenge always tells you the current one. | ## Refusals Every refusal carries a stable `reason` and a sentence saying what to do. | Status | `reason` | What to do | | - | - | - | | 400 | `terms_not_accepted` | Send the current `terms_version` as `accept_terms`. | | 400 | `invalid_proof_of_work`, `challenge_expired`, `invalid_challenge` | Get a new challenge and solve it. | | 409 | `already_registered` | The registration succeeded earlier; its credential was returned then. | | 409 | `registration_in_progress` | Retry the original request, with its original challenge, after `retry_after` seconds. A new challenge with the same `Idempotency-Key` does not finish it. | | 409 | `challenge_spent` | An earlier attempt with this challenge failed (or was abandoned for an hour); get a new one. | | 429 | `rate_limited` | Too many requests from this address; retry in a minute. | | 429 | `daily_cap_reached` | Hookie's daily limit on agent registrations is reached; retry after midnight UTC. | | 429 | `source_daily_limit` | Your network has made its share of the day's registrations; retry after midnight UTC. | | 502 | `identity_provider_error` | Nothing was registered; get a new challenge and retry (the same `Idempotency-Key` is fine). | | 503 | `registration_incomplete` | Retry the same request, with the same challenge, after `retry_after` seconds. | | 503 | `agent_registration_disabled` | Hookie has switched agent registration off for now. | ## For agents: where to start An agent that knows nothing about Hookie yet can read [hookie.ai/llms.txt](https://hookie.ai/llms.txt) (also served, byte for byte, at [app.hookie.ai/llms.txt](https://app.hookie.ai/llms.txt)). It describes Hookie, this registration flow, the MCP URL, the OpenAPI document, the CLI and the Free plan in one plain-text file. These docs have their own [llms.txt](https://docs.hookie.ai/llms.txt) too. [hookie.ai/auth.md](https://hookie.ai/auth.md) lists every route to a Hookie credential in one Markdown file: self-registration, a connected agent over OAuth, and WorkOS Agent Auth. The REST API is also listed in an [API catalog](https://hookie.ai/.well-known/api-catalog) (RFC 9727) for tools that find APIs through well-known URLs. # Analytics Source: https://docs.hookie.ai/analytics Every project's traffic, deliveries, workflow runs and stored data on one page, broken down by project, endpoint, workflow and dataset. **Analytics** puts the numbers for every project in your workspace on one page. Open it from **Get started → Analytics** in the console's side navigation. Two filters at the top apply to everything below them: * **Project** narrows the page to one project. The default, **All projects**, adds them up. * **Time range** is the last 24 hours, 7 days or 30 days. ## The summary The two charts show **Events received** and **Deliveries sent** over the range, each with its change on the range just before it. Under them, the cards give the totals: projects, endpoints (with the requests rejected before they were stored), the share of deliveries that failed, workflow runs, and datasets with the space they use. ## Break it down Use the tabs to cut the same range four ways. Every table sorts by clicking a column header and has its own search. | Tab | One row per | What you see | | - | - | - | | **Projects** | Project | Events, rejected requests, deliveries, delivery failure rate, average response time, workflow runs, stored size and the last event. | | **Endpoints** | Endpoint | Whether it is enabled, events received, requests rejected, rejection rate, the dataset it routes into and the last event. | | **Workflows** | Workflow | Whether it is active, runs started, completed and failed, failure rate, average duration and the dataset that starts it. | | **Datasets** | Dataset in a project | Records stored, records added in the range, size, its share of everything stored and the last record. | ## How the figures are counted * **Events** are records stored in the range. An endpoint's events are the records that arrived through it. * **Rejected** are requests Hookie refused after it knew whose they were, such as a failed signature, an IP not on the allowlist or a rate limit. They never count against your event quota. * **Delivery failure rate** is failed deliveries as a share of finished ones (delivered plus failed). Deliveries still queued or retrying are in neither. * **Deliveries are counted per project, not per endpoint.** A delivery belongs to a destination, not to the endpoint that received the event, so the Endpoints tab has no delivery column. * **Workflow failure rate** is failed and timed-out runs as a share of completed plus failed. Runs still in progress are in neither. **Average duration** covers completed runs only. * **Dataset size** is the stored payload of every record kept in the dataset, in decimal units (1 MB is 1,000,000 bytes). It is the size of your data, not of the database's own indexes or the raw copy of each request, so it is smaller than the storage the database reports. Endpoints, workflows and datasets show their first 500 rows. The page says when a list was cut. ## From the API The page reads `GET /admin/api/dashboard/analytics?window=24h|7d|30d`, and its charts read `GET /admin/api/dashboard/timeseries` with an optional `project_id`. Both are readable by any role, and by a connected agent with the read scope. Endpoint slugs, which are credentials, are never returned. See the [API reference](/api-reference). # API keys Source: https://docs.hookie.ai/api-keys Workspace keys for CI jobs, scripts and backends: roles, project scope, expiry, IP allowlists, rate limits, idempotent retries and API versions. An **API key** (`hk_…`) lets a system with no browser call the same admin API the console uses, and the hosted [MCP server](/connected-agents) at `/mcp`: a CI job, an infrastructure-as-code tool, a nightly script or your own backend. For a coding agent working with you interactively, [connect it over OAuth](/connected-agents) instead. A key is not a user of its own. It **acts as the person who created it**, and its role is a ceiling on that person's role. So a key can never do more than its creator can do in the console. It follows its creator: the key's role is checked against the creator's role on every request, and the key stops working if its creator is no longer in the workspace. ## Create a key In the console, open **Settings → API keys**. Owners and admins manage keys; anyone else is asked to go to one of them. Choose **Create API key** and fill in: * **Name**: the system that will use it, such as `GitHub Actions deploy`, so you know what to revoke later. * **Role**: viewer, developer or admin. See [Roles](#roles). * **Projects**: **All projects**, or one project. See [Project scope](#project-scope). * **Expires**: in 90 days (the default), 30 days, 7 days, 1 year, or never. * **Allowed source IPs** (optional): the addresses the key may be used from. The key is shown **once**. Hookie stores only a SHA-256 hash of the whole key and looks it up by that hash; the list shows the first 11 characters (`hk_` and 8 more) from then on. Store it in your CI system's secret store. Only a person signed in to the console can create a key. A connected agent, or another API key, is refused, so one leaked or over-granted credential cannot mint more. You cannot create a key with a higher role than your own. While the workspace is suspended, creating a key is refused but revoking one still works. Creating and revoking a key are recorded in **Settings → Audit log** (`create_api_key`, `revoke_api_key`). Every change a key makes is recorded as its creator, with the key named as the agent (`api_key:`). ## Roles | Role | The key can… | | - | - | | **Viewer** | List and read resources, records and deliveries. | | **Developer** | Also create, change and delete project resources. | | **Admin** | Also manage projects, cron and WebSocket triggers, the workspace IP allowlist and the audit log export. | There is no owner role for a key, so **billing is never reachable with one**. Like a connected agent, a key also cannot reveal or rotate a destination's signing secret, manage connected agents, create API keys, or invite people and change who the members are, whatever its role. Those stay with a person in the console. A key for all projects, of any role, can read the member list (`GET /admin/api/members`); see [Team members](/team-members#agents-and-api-keys). The role is applied on every request against the creator's role at that moment, so a key never acts above its creator's current role. If the creator's role is lowered, their keys are lowered with it; if they leave or are removed from the workspace, their keys stop working on the next request. ## Project scope A key for **one project** reaches: * `/admin/api/projects/{its project id}/…` * `/admin/api/me` * the destination and source presets Everything else answers `403` and names the routes the key can use. That includes the project list (it would name the other projects), other projects, the Default-project shortcuts such as `/admin/api/rules`, and workspace-wide routes such as the IP allowlist, API keys and the audit log. A key for **all projects** reaches whatever its role allows across the workspace. ## Expiry and IP allowlist An expired key is refused with `401`, exactly like a revoked one. Over the API, `expires_at` is an ISO 8601 time in the future. Leave it out, or send `null`, for a key that never expires. A key's **allowed source IPs** hold up to 64 IPv4 or IPv6 addresses or CIDR ranges. A request from any other address is refused with `403`; on `/admin/api` the refusal is recorded in the audit log as `access_denied`. A key with an allowlist is refused when the request's source address cannot be determined. An empty list accepts any address. The list shows each key's role, project, expiry, allowed IPs, creator, when it was created and when it was last used, with a status of **active**, **expired** or **revoked**. Last used is updated at most once a minute. ## Use a key Send it as a bearer token: ```bash theme={"dark"} curl "https://app.hookie.ai/admin/api/projects" \ -H "Authorization: Bearer $HOOKIE_API_KEY" ``` ```bash theme={"dark"} curl -X POST "https://app.hookie.ai/admin/api/projects/$PROJECT_ID/destinations" \ -H "Authorization: Bearer $HOOKIE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"name":"Warehouse","url":"https://example.com/hook"}' ``` A write made with a bearer token needs no CSRF header; that check applies only to the console's session cookie. `GET /admin/api/me` names the key the request was made with under `api_key` (`id`, and `project_id` for a key scoped to one project). The hosted MCP server accepts a key the same way, so an MCP client that cannot run the OAuth flow can send one as a header. The key's role decides which tools it can call: a viewer key reaches the `hookie:read` tools, a developer key also the `hookie:write` ones, and an admin key the `hookie:manage` ones too. ```bash Claude Code theme={"dark"} claude mcp add --transport http hookie https://app.hookie.ai/mcp \ --header "Authorization: Bearer $HOOKIE_API_KEY" ``` ```json .mcp.json theme={"dark"} { "mcpServers": { "hookie": { "type": "http", "url": "https://app.hookie.ai/mcp", "headers": { "Authorization": "Bearer ${HOOKIE_API_KEY}" } } } } ``` Never commit a key. Reference it from an environment variable, as above, and keep the value in your secret store. ## Rate limits Each credential may make **about 100 requests every 10 seconds** to `/admin/api` and `/mcp` combined. The limit is a best-effort burst guard, not an exact count: each Cloudflare location counts separately, so a short burst can get through above it. Each API key, each connected agent and each person signed in to the console is counted separately, so one runaway script cannot lock out the rest of your team. Over the limit, the request is refused with `429` and a `Retry-After: 10` header. On `/admin/api` the body is: ```json theme={"dark"} { "error": "Too many requests — retry in 10 seconds", "retry_after": 10 } ``` On `/mcp` the same `429` and `Retry-After` carry a JSON-RPC error (code `-32029`). Wait the number of seconds in `Retry-After`, then retry. ## Idempotent retries Send an `Idempotency-Key` header on a `POST` to `/admin/api`, and a retry of that request is answered once, not run twice. Use a new unique value, such as a UUID, for each distinct request, and the same value when you retry it. * The first request runs, and its response is stored, encrypted, for **24 hours**. * A retry with the same key, from the same credential, to the same path with the same body gets the stored response back with `Idempotent-Replayed: true`. Nothing runs again. * The same key with a **different body, path or credential** is refused with `422`. Use a new key for a new request. * A retry that arrives **while the first request is still running** gets `409` with `Retry-After: 1`. A request still unfinished after 60 seconds is treated as abandoned, and its key is free again. * A **server error (5xx)** is not stored, so you can retry with the same key. An error in the `4xx` range is stored and replayed like a success. The key is 1 to 255 printable characters with no spaces; anything else is `400`. It is ignored on methods other than `POST`. A response larger than 64 KiB, or one that is not JSON, is not stored. A workspace can hold 10,000 keys in any 24 hours; past that, a request with a new key is refused with `429` and `Retry-After: 3600`. `POST /admin/api/api-keys` refuses the header with `400`: its response is a key that is shown once, and a stored copy would be a second way to recover it. ## Versioning Every response from `/admin/api` and `/mcp` carries the API version, errors and `429`s included: ```http theme={"dark"} Hookie-Version: 2026-09-29 ``` Additive changes (a new route, a new response field, a new optional parameter) ship under the current version, so ignore fields you do not recognise. A breaking change gets a new dated version, and the previous version stays available for at least 12 months. To pin a version, send it as a `Hookie-Version` request header. A version this deployment does not serve is refused with `400`, and the body lists `supported_versions`. Today that is `2026-09-29` only. ## Revoke a key Choose **Revoke** on the key's row and confirm. Its next request is refused with `401`. Keys cannot be edited or rotated: to change a key's role, project, expiry or allowlist, create a new one, move your system over, then revoke the old one. ## API Owners and admins, or an admin-role credential: ```http theme={"dark"} GET /admin/api/api-keys # list keys: never the key itself POST /admin/api/api-keys # create: {name, role, project_id?, expires_at?, ip_allowlist?} DELETE /admin/api/api-keys/{id} # revoke ``` `role` is `viewer`, `developer` or `admin`. `project_id` is a project's id, or `null` for every project. The `201` response carries the key once, in `key`, with `Cache-Control: no-store`. Only a person signed in to the console can create a key; see [Create a key](#create-a-key). The full contract is in the [API reference](/api-reference). # API reference Source: https://docs.hookie.ai/api-reference Hookie public HTTP API — ingest and streaming endpoints. Hookie exposes three HTTP surfaces (plus the public [agent self-registration](#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](/connected-agents) is acting on your behalf, or by an [API key](/api-keys) 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](/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: ```json theme={"dark"} { "body": "disk full on db-1", "content_type": "text/plain" } ``` 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](/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](/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](/rules-and-mappings) 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](/endpoints-and-keys#ingest-keys). 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](/endpoint-verification) (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](/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](/datasets-and-records#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 ```http theme={"dark"} GET /v1/stream # live tail (SSE or WebSocket) ``` See [Live streaming](/streaming) for SSE/WebSocket details and resume. ## Agent self-registration ```http theme={"dark"} GET /v1/agents/register/challenge # a signed proof-of-work challenge + the current Terms version POST /v1/agents/register # {agent_name, operator_contact?, accept_terms, pow: {challenge, nonce}} ``` 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](/agent-self-registration) for the proof of work, the refusals and the limits. ## Customer portal API ```http theme={"dark"} # Authorization: Bearer (or the portal's session cookie) POST /portal/api/session # trade the token for an 8-hour session cookie GET /portal/api/me # portal name + branding, customer name, event types GET /portal/api/event-types # datasets the portal exposes GET /portal/api/examples # a redacted example per dataset + the signature scheme GET /portal/api/destinations # the customer's destinations POST /portal/api/destinations # create (https only; signing secret shown once) PATCH /portal/api/destinations/{id} # edit name, URL, event types; pause or resume DELETE /portal/api/destinations/{id} # delete GET /portal/api/destinations/{id}/secret # show the signing secret POST /portal/api/destinations/{id}/rotate-secret # rotate, with a 24-hour overlap POST /portal/api/destinations/{id}/expire-previous-secret # end the overlap early GET /portal/api/deliveries # deliveries to the customer's destinations GET /portal/api/deliveries/{id} # one delivery in full POST /portal/api/deliveries/{id}/replay # send it again ``` 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](/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](https://app.hookie.ai/) to explore it. It accepts a browser session, or `Authorization: Bearer` with either an OAuth access token, which is how a [connected agent](/connected-agents) reaches the very same routes, or an [API key](/api-keys) (`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](/api-keys#rate-limits) covers all three. The resources share one contract: * **Get one by id.** `GET /{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](#reading-a-whole-dataset). Project-scoped resources: | Resources | Covers | | - | - | | `datasets · submissions · rules · webhooks` | Routing: raw capture, mapping rules, versioned endpoints. `GET datasets/{name}` returns a dataset's records, flattened, a page at a time ([Reading a whole dataset](#reading-a-whole-dataset)). `POST webhooks` needs only a `name`: with no `slug`, one is made from the name (accents folded, Cyrillic and Greek transliterated, plus a short random suffix; a name with no Latin spelling gets `endpoint-`), and with no `dataset`, the name as an identifier or `default`. The slug is a readable label, never part of the public URL. `PATCH rules/{id}` changes any of `name`, `enabled`, `dataset`, `conditions` and `mappings`, validated as a create is; `POST rules/test` with `{conditions, mappings, payload}` answers `{matched, record}` and stores nothing. `PATCH webhooks/{id}` changes `enabled`, `name`, `dataset`, `ip_allowlist` and `verification`; `POST webhooks/{id}/rotate` gives the endpoint a new URL and keeps the old one for `overlap_hours`; `POST webhooks/{id}/rotate-verification-secret` replaces the provider signing secret and keeps the old one verifying for `overlap_hours` (see [Verify provider signatures](/endpoint-verification)). | | `ingest-keys · destinations · deliveries` | Credentials, outbound sinks, delivery log + replay. `POST destinations` takes a `type`, `webhook` (the default), `slack` or `s3`; a Slack or S3 destination is set by `config` instead of `url`, and comes back with `type`, `config` (never a credential) and `secrets_set` ([Destination types and options](/destinations#api)). `PATCH destinations/{id}` also takes `name`, and `url` or `config`: the destination keeps its id and signing secret, and a portal customer's destination keeps its URL (`409`). A destination's type cannot be changed (`400`), and a `hooks.slack.com` URL is refused as a webhook's `url` (`400`). `DELETE destinations/{id}` removes a destination that has never had a delivery outright; one with delivery history leaves the project for good, and its past deliveries keep its name. `PATCH ingest-keys/{id}` changes a key's `name`, `expires_at` and `ip_allowlist`; `POST ingest-keys/{id}/rotate` issues its replacement with an overlap. | | `sources · triggers · cron-triggers · ws-triggers` | DB polling, AI triggers, scheduled + WebSocket sources. | | `workflows · workflow-instances · ai-agents` | Multi-step workflows, instance history, reusable AI agents. `PATCH workflows/{id}` merges: fields you leave out keep their stored values, and the merged definition is validated like a new one; `PUT` replaces the whole definition. A rename or an on/off switch keeps the workflow's version; new steps, entry conditions or entry dataset make a new one. `DELETE ai-agents/{id}` retires the agent (inactive, and `PATCH` with `active: true` brings it back); `DELETE ai-agents/{id}?permanent=true` removes it, after which a workflow step that names it fails with not found. | | `search/{events,deliveries,ai-runs} · search/correlate/{id}` | Filtered search + cross-entity correlation. `q` matches the payload as literal text, ignoring case for letters, accented ones included (a query with more than 32 case combinations matches only as typed and in all lower or all upper case, and `ß` does not fold to `SS`). Each result's `payload` is a preview of the first 2,000 characters; `payload_truncated` and `payload_length` say when it was cut. Every search takes `since` and `until` (ISO 8601 timestamps, both inclusive; one that does not parse is refused with `400`); deliveries and AI runs take one `status` or an array of them, and delivery results carry `destination_name`. `correlate` accepts a record, submission, delivery, AI run or workflow run id and says which it `matched`. It answers the raw submission, each record with a `payload` preview, each delivery with its `destination_name` and `error`, each AI run with its `error`, and `workflow_runs`: every workflow run the event's records started, with its `state`, `last_error` and per-step `steps` (path, type, status, error). | | `search/ai-calls` · `search/ai-calls/{id}` | The AI call log: every AI call a trigger, `call_ai` or `agent_call` step made, refusals included. `q` matches the instructions, input or output; filter by `status`, `source_kind`, `source_id`, `model`, `since` / `until`. The list carries a 200-character `output_preview`; `GET` one call for its full instructions, input and output (each capped at 8,000 characters, flagged when cut). | | `portal · portals (+ /tokens)` | Customer portals and per-customer access tokens. `DELETE portal` with `{confirm: }` [removes the portal for good](/customer-portal#remove-a-portal): its tokens are revoked and its customers' pending deliveries cancelled, nothing is deleted, and the project can have a new portal. `POST portals/{id}/tokens` with `{revoke: }` revokes one token, and `{offboard: }` revokes all of a customer's tokens and switches off their destinations. See [Customer portal](/customer-portal#offboard-a-customer). | | `config · clone` | [Export, import and clone](#export-import-and-clone-a-project) the project's configuration. | | `telemetry (+ /test)` | [Broadcast Logs](/broadcast-logs): the project's one OTLP integration (Datadog, Grafana Cloud, OpenTelemetry Collector, PostHog or Sentry) and its test event. | Workspace-level resources sit directly under `/admin/api/…` with no `{project_id}` segment: | Resources | Covers | | - | - | | `me · dashboard/{summary,timeseries}` | Signed-in user, workspace + plan, usage rollups. `me` carries `signInMethod`: how this console session was signed in, one of `email_code`, `github`, `google`, `sso`, `password`, `passkey` or `other`. It is `null` for a connected agent or an API key, which do not sign in, and when WorkOS recorded no method. It names the first factor only. In `dashboard/summary`, `error_rate_7d` is failed deliveries as a share of **finished** ones (`failed_7d / (delivered_7d + failed_7d)`), per project too; deliveries still queued, retrying or held by a paused destination have no outcome yet and are counted apart in `in_flight_7d`. It also carries `plan_name` (such as "Pro") and the number of `destinations`. `dashboard/timeseries` counts every event, delivery and AI run in the window, with no cap, and carries `previous_total`, the same count over the window of the same length just before it, which the console's Home shows as the change on the previous period. | | `projects` | List, create and manage the projects everything else hangs off. | | `audit` | Audit log, filtered by `actor` (part of an email or subject), `action` (exact), `target`, `since` and `until`, with CSV export of the same filters (`format=csv`, owners and admins; UTF-8 with a BOM; cells a spreadsheet would run as a formula are neutralised). `audit/actions` lists the actions your workspace has recorded. Entries are kept for the longer of your plan's retention and one year, then pruned. | | `ip-allowlist` | The workspace IP allowlist: `GET` to read it, `PUT {"ip_allowlist": [...]}` to replace it (`[]` allows every address). Owners and admins. | | `agents` | Coding agents you have connected over OAuth — list and revoke. | | `api-keys` | [API keys](/api-keys): list, create (a person in the console only) and revoke. Owners and admins. | | `agent-accounts/claim-preview · agent-accounts/claim` | `POST` with `{claim_token}`: what claiming a [self-registered agent's](/agent-self-registration#5-hand-the-claim-link-to-a-person) workspace would take over, and taking it over. The signed-in person becomes the owner and the agent's key keeps working, acting for them. People only. | | `members (+ /transfer-ownership)` | [Team members](/team-members): `GET` lists them (any role, and agents at `hookie:read`), `PATCH members/{user_id}` changes a role, `DELETE members/{user_id}` removes a member or leaves, and `POST members/transfer-ownership` makes another member the owner. Every write is a person's in the console: agents and API keys get `agent_not_permitted`. | | `invites (+ /preview, /accept)` | Shareable invite links. `POST invites {role}` returns the link's `url` once (owners and admins; no `Idempotency-Key`), `GET invites` lists the pending ones without their tokens, and `DELETE invites/{id}` revokes one. `POST invites/preview` and `POST invites/accept` with `{token}` show what a link joins and join with it; a wrong, expired, revoked or used token is the same `404`. At the plan's member limit, creating or accepting answers `403` `member_limit_reached`. People only. | | `workspaces (+ /active)` | The workspaces the signed-in person belongs to, with their role in each, and `POST workspaces/active {tenant_id}` to switch. People only. | | `sso` | Single sign-on configuration. SSO is planned and not available yet: creating or changing a configuration (`POST sso`, `PUT` or `PATCH sso/{id}`) answers `501` with `"reason": "sso_not_available"`. `GET sso` lists any configuration saved before, none of which is active. | | `destination-presets · source-presets` | Integration presets for outbound sinks and sources. | | `billing · stream` | Subscription state and the live event tail. | ## 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. ```bash theme={"dark"} curl "https://app.hookie.ai/admin/api/projects/$PROJECT/datasets/orders?limit=1000" \ -H "Authorization: Bearer $HOOKIE_API_KEY" # { "dataset": "orders", "columns": [...], "rows": [...], "next_cursor": "…", "total": 8214 } ``` 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](/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**. ```http theme={"dark"} GET /admin/api/projects/{project_id}/config # the document, as JSON GET /admin/api/projects/{project_id}/config?format=yaml # the same, as YAML POST /admin/api/projects/{project_id}/config # create in this project what a document describes POST /admin/api/projects/{project_id}/clone # {name}: a new project with this one's configuration ``` The document is the same `hookie.yml` shape the CLI reads, so `hookie export` writes it and `hookie apply` applies it (see [Configuration](/cli/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](/endpoint-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: `, 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 | Header | Purpose | | - | - | | `Content-Type` | On ingest: application/json, application/x-www-form-urlencoded, multipart/form-data, or a text type (`text/*`, XML, CSV, NDJSON), which is stored as `{ body, content_type }` when the body is not JSON. Any body that parses as JSON is read as JSON. | | `Idempotency-Key` | Optional. Dedupes retries per endpoint (on `/v1/ingest`, per key); the same submission is returned and never double-counted. Without it, a provider's own delivery id is used: `webhook-id` (Standard Webhooks), `svix-id`, `X-GitHub-Delivery`, `X-Shopify-Webhook-Id`, `X-Gitlab-Event-UUID`, `I-Twilio-Idempotency-Token`, `Linear-Delivery`, `Twitch-Eventsub-Message-Id`, `X-Atlassian-Webhook-Identifier`, or Stripe's `evt_` id or Slack's `event_id` in the body. On a `POST` to `/admin/api`, it makes the call safe to retry: the first response is stored for 24 hours and replayed with `Idempotent-Replayed: true` (see [Idempotent retries](/api-keys#idempotent-retries)). | | `Hookie-Version` | Optional, on `/admin/api` and `/mcp`. Pins the API version; an unsupported one is refused with `400` and `supported_versions`. Every response carries the current one, `2026-09-29`. See [Versioning](/api-keys#versioning). | | `Origin` | On an endpoint URL: when it is one of the endpoint's allowed origins, the response carries `Access-Control-Allow-Origin`, and `OPTIONS` answers the preflight. | | `X-Hookie-Signature` | Required only for keys with signature enforcement. `t=,v1=` over `.`. `t` must be within 5 minutes of now, and several `v1=` values may be sent while you rotate the secret. | | `Hookie-Hop` | Optional. How many times this event has already passed through Hookie. Hookie sets it on its own deliveries; a relay that forwards a delivery back into Hookie should pass it on. At 8 the request is refused with `508`. | | `Authorization` | `Bearer ` for `GET /v1/stream` (or `?key=` for EventSource); `Bearer ` for `/admin/api/*` and `/mcp` as a connected agent; `Bearer ` for the same routes as an [API key](/api-keys). | ## 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 | Status | Meaning | | - | - | | `201 Created` | Event stored and routed. | | `200 OK` | Duplicate: the original submission is returned, with `deduplicated_by`. Also the answer to a provider's URL-verification challenge. | | `303 See Other` | A browser's native form post to an endpoint with a redirect URL. `Location` is that URL. | | `400 Bad Request` | The body declares JSON (or no `Content-Type`) and is not valid JSON, or is not a well-formed form encoding. | | `401 Unauthorized` | Missing or invalid inbound signature (when required), or missing stream key. On an endpoint with [signature verification](/endpoint-verification), `reason` is `missing_header`, `bad_signature` or `stale_timestamp` and `scheme` names the check. Nothing is stored or counted. | | `403 Forbidden` | Source IP not in the allowlist, or an agent token without the scope for this call. With `"reason": "workspace_suspended"`, the workspace has been suspended: nothing is stored or counted, so retry once it is reinstated. | | `404 Not Found` | Unknown, revoked or expired ingest key, or unknown endpoint (on `/v1/webhooks/{key}/{slug}`, one outside the key's project; on an endpoint URL, a rotated-away URL past its overlap). | | `415 Unsupported Media Type` | The body is not JSON and its `Content-Type` is neither a form encoding nor a text type. The error names the type. | | `422 Unprocessable Entity` | The JSON body has no fields (`{}`), so there is nothing to store. | | `429 Too Many Requests` | The ingest burst limit (about 100 requests per 10 seconds per endpoint URL or ingest key: a best-effort guard counted separately at each Cloudflare location, so a short burst can get through above it) or the monthly event quota, which is exact. Both send a `Retry-After` header (10 and 3600 seconds) and the same value as `retry_after` in the body. On `/v1/stream`, the plan's concurrent-connection cap. | | `503 Service Unavailable` | The endpoint is disabled (`"reason": "endpoint_disabled"`). Nothing is stored; enable it to resume. | | `508 Loop Detected` | The `Hookie-Hop` header says this event has already been through Hookie 8 times. That almost always means a destination is delivering back into an endpoint that feeds it. Nothing is stored. | ## Outbound (to your destinations) Deliveries are signed twice and carry `Hookie-Signature`, the [Standard Webhooks](https://www.standardwebhooks.com) 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](/signatures). 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](/signatures#rotating-the-secret)); `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. # Current user, workspace and role Source: https://docs.hookie.ai/api-reference/account/current-user-workspace-and-role /openapi.json get /admin/api/me Read-only identity echo. No project scope. Any role can call it. # Get a proof-of-work challenge to register an agent Source: https://docs.hookie.ai/api-reference/agents/get-a-proof-of-work-challenge-to-register-an-agent /openapi.json get /v1/agents/register/challenge Public: no cookie, no key. Issues a signed, stateless challenge that expires in ten minutes. Solve it by finding a `nonce` — a decimal string of at most 32 digits, found by counting up from 0 — such that SHA-256(`challenge` + `":"` + `nonce`) starts with `difficulty_bits` zero bits, then POST /v1/agents/register. The cost is small for one agent and large for a script creating accounts in bulk. `difficulty_bits` is the operator's setting (20 by default), plus 2, 4 or 6 bits once today's registrations pass 50, 75 or 90% of the daily cap: solve at the difficulty the challenge states. Refused when the operator has switched registration off. # Register an AI agent's own Hookie account Source: https://docs.hookie.ai/api-reference/agents/register-an-ai-agents-own-hookie-account /openapi.json post /v1/agents/register Public: no cookie, no key. Creates, all or nothing: the agent's identity in WorkOS (a User Management user with a non-deliverable, verified address on agents.hookie.ai, external_id = the account id; no WorkOS organization), its Hookie user, a workspace on the **Free plan** with its Default project and the agent as owner, an admin API key, and a one-time claim token. The key and the claim token are in this response and nowhere else: Hookie stores only their hashes, never logs them, and never returns them again. **Controls, in order:** a per-IP rate limit (an IPv6 address by its /64); the operator's kill switch; body validation including `accept_terms`; the proof of work (signed, unexpired, solved; harder as the day's cap fills); a platform-wide daily cap (default 200 a day, UTC) of which one network (an IPv4 address or an IPv6 /48) may take a tenth, both taken in the same statement that spends the challenge and before WorkOS is called. Free-plan quotas apply in full. **Idempotent.** A challenge registers at most one account. Sending the same request again — the same challenge, or the same `Idempotency-Key` header with a new, unexpired and solved challenge — never creates a second account: it answers 409 `already_registered` with the `account_id` and NO credential (the credential is returned once; if it was lost, register again with a new challenge and a new Idempotency-Key). A key is scoped to the request: the same key with a different `agent_name` or `operator_contact` is a different registration. While the first request is still running the answer is 409 `registration_in_progress`; a request that died part-way is finished by a retry of the SAME request (its original challenge) after a minute, adopting the WorkOS user it may already have made — a key with a new challenge never finishes it. One never retried is abandoned after an hour: its WorkOS user is deleted and the retry gets `challenge_spent`. An attempt that failed (502) releases its key, so a new challenge with the same key registers. **What the key may do.** Everything an admin can — projects, endpoints, rules, destinations, workflows, ingest keys, the rest — through /admin/api/*, the hosted MCP server at /mcp and the CLI. Billing, SSO and organization requests, invites, Google Workspace linking, platform administration, creating more API keys and revealing destination secrets answer 403 `agent_not_permitted` (with `claim_required: true`, except platform administration) until a person claims the workspace (and billing stays a person's in the console after that). Audited as `agent_registered`. # Change an AI agent (only the fields sent) Source: https://docs.hookie.ai/api-reference/ai-agents/change-an-ai-agent-only-the-fields-sent /openapi.json patch /admin/api/projects/{project_id}/ai-agents/{agent_id} # Create an AI agent Source: https://docs.hookie.ai/api-reference/ai-agents/create-an-ai-agent /openapi.json post /admin/api/projects/{project_id}/ai-agents # Get one AI agent Source: https://docs.hookie.ai/api-reference/ai-agents/get-one-ai-agent /openapi.json get /admin/api/projects/{project_id}/ai-agents/{agent_id} # List AI agents in a project Source: https://docs.hookie.ai/api-reference/ai-agents/list-ai-agents-in-a-project /openapi.json get /admin/api/projects/{project_id}/ai-agents Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Models an AI trigger, agent or workflow AI step may name Source: https://docs.hookie.ai/api-reference/ai-agents/models-an-ai-trigger-agent-or-workflow-ai-step-may-name /openapi.json get /admin/api/ai-models The curated Workers AI text-generation models (#196, FLOW-16). AI triggers, AI agents and workflow call_ai steps refuse a model not on this list. Read-only; any member. # Replace an AI agent Source: https://docs.hookie.ai/api-reference/ai-agents/replace-an-ai-agent /openapi.json put /admin/api/projects/{project_id}/ai-agents/{agent_id} # Retire, or permanently delete, an AI agent Source: https://docs.hookie.ai/api-reference/ai-agents/retire-or-permanently-delete-an-ai-agent /openapi.json delete /admin/api/projects/{project_id}/ai-agents/{agent_id} # List the actions recorded in the workspace audit log Source: https://docs.hookie.ai/api-reference/audit/list-the-actions-recorded-in-the-workspace-audit-log /openapi.json get /admin/api/audit/actions The distinct action names present in this workspace's audit log, sorted, for an action filter. Only actions that were actually recorded are listed, so every one matches at least one row of GET /admin/api/audit?action=. Workspace-level; open to any role, like the JSON list. # Read the workspace audit log Source: https://docs.hookie.ai/api-reference/audit/read-the-workspace-audit-log /openapi.json get /admin/api/audit Workspace-level (tenant-wide), NOT project-scoped - there is no /projects/{project_id}/audit form. JSON reads are open to any role; the CSV export is owner/admin. The console's list and its Export CSV send the same actor, action, since and until filters, so an export holds exactly the rows the list shows (up to the 5000-row cap). Rows are kept for the longer of the plan's data retention and 365 days, then pruned; the database refuses to delete a row younger than 365 days, and never rewrites one. # Current plan, subscription and display prices Source: https://docs.hookie.ai/api-reference/billing/current-plan-subscription-and-display-prices /openapi.json get /admin/api/billing/status # Open Stripe's Customer Portal for this workspace Source: https://docs.hookie.ai/api-reference/billing/open-stripes-customer-portal-for-this-workspace /openapi.json post /admin/api/billing/portal Owner only. Creates a Stripe Billing Portal session for the workspace's Stripe customer, returning to Settings → Plan & billing, so the owner changes the card, cancels or downloads receipts without Stripe's email-login step. Falls back to the no-code portal login link (`session: false`) when the workspace has no Stripe customer yet or Stripe refuses. # Renewal date, payment failures, a pending cancellation and invoices, from Stripe Source: https://docs.hookie.ai/api-reference/billing/renewal-date-payment-failures-a-pending-cancellation-and-invoices-from-stripe /openapi.json get /admin/api/billing/details Owner only (a connected agent is never the owner). Reads the workspace's subscription and its Stripe customer's 12 most recent invoices live from Stripe with the server's key, which never leaves the Worker. When Stripe is not configured or cannot be reached it answers with the state the webhook stored, `live: false`, and no invoices. # Start a new subscription via Stripe Checkout Source: https://docs.hookie.ai/api-reference/billing/start-a-new-subscription-via-stripe-checkout /openapi.json post /admin/api/billing/subscribe Owner only. Starts a Stripe Checkout session for a NEW subscription; a workspace with a live one (active, trialing, past_due, unpaid, paused) is refused with 409. A workspace on a Hookie trial that checks out the plan it is trying keeps the rest of the trial: with at least 48 hours and ten minutes left, the session carries `subscription_data[trial_end]` = the trial's end (unix seconds, rounded down, so never later), the subscription starts in Stripe's trial, and the card is first charged when the trial ends. Stripe refuses a trial end less than 48 hours out, so closer than that, for any other plan, or with no trial, billing starts today. # Change the integration's settings or secrets, or enable / disable it Source: https://docs.hookie.ai/api-reference/broadcast-logs/change-the-integrations-settings-or-secrets-or-enable-disable-it /openapi.json patch /admin/api/projects/{project_id}/telemetry The vendor cannot change here (that is a replace). Secrets left blank keep their stored value. Disabling stops forwarding at once, including anything already queued. Audited as update_telemetry_integration with the names (never the values) of changed secrets. # Remove the integration Source: https://docs.hookie.ai/api-reference/broadcast-logs/remove-the-integration /openapi.json delete /admin/api/projects/{project_id}/telemetry Deletes its settings, credentials and counts. Audited as delete_telemetry_integration. # Send one test log record (and span) to a draft or the saved configuration Source: https://docs.hookie.ai/api-reference/broadcast-logs/send-one-test-log-record-and-span-to-a-draft-or-the-saved-configuration /openapi.json post /admin/api/projects/{project_id}/telemetry/test With `vendor` (plus `settings` / `secrets`) in the body, tests that draft without saving it; a draft of the saved vendor may leave secrets blank to use the stored ones. With an empty body, tests the saved integration and records `last_test_at` / `last_test_ok`. Redirects are never followed. # Start forwarding the project's logs and traces over OTLP/HTTP (JSON) to one tool Source: https://docs.hookie.ai/api-reference/broadcast-logs/start-forwarding-the-projects-logs-and-traces-over-otlphttp-json-to-one-tool /openapi.json post /admin/api/projects/{project_id}/telemetry A project has ONE integration. When one exists the request is refused with 409 unless `replace` is exactly `true`; a replace deletes the old integration (settings, credentials and counts) and is audited as replace_telemetry_integration. Endpoint URLs must be https, public hosts (no localhost, private, link-local or metadata addresses) and carry no credentials. Audited as create_telemetry_integration. # The project's Broadcast Logs integration (or null) and the vendor catalog Source: https://docs.hookie.ai/api-reference/broadcast-logs/the-projects-broadcast-logs-integration-or-null-and-the-vendor-catalog /openapi.json get /admin/api/projects/{project_id}/telemetry Any member can read it. Secret values are never returned: `secrets_set` names the secret fields that are set. # Change what a connected agent may do Source: https://docs.hookie.ai/api-reference/connected-agents/change-what-a-connected-agent-may-do /openapi.json patch /admin/api/agents/{grant_id} Set the grant to one rung of the ladder read < write < manage. Send `level` (what the console sends), or `scopes` — a list is read by its HIGHEST scope and stored as the ladder up to it, so `["hookie:manage"]` stores all three. An empty `scopes` list leaves the agent connected with no access; to cut it off, revoke it. # List the signed-in user's OAuth-connected coding agents Source: https://docs.hookie.ai/api-reference/connected-agents/list-the-signed-in-users-oauth-connected-coding-agents /openapi.json get /admin/api/agents # Revoke a connected agent Source: https://docs.hookie.ai/api-reference/connected-agents/revoke-a-connected-agent /openapi.json delete /admin/api/agents/{grant_id} # Create a cron trigger Source: https://docs.hookie.ai/api-reference/cron-triggers/create-a-cron-trigger /openapi.json post /admin/api/projects/{project_id}/cron-triggers # Delete a cron trigger Source: https://docs.hookie.ai/api-reference/cron-triggers/delete-a-cron-trigger /openapi.json delete /admin/api/projects/{project_id}/cron-triggers/{cron_trigger_id} # Fire a cron trigger once, immediately Source: https://docs.hookie.ai/api-reference/cron-triggers/fire-a-cron-trigger-once-immediately /openapi.json post /admin/api/projects/{project_id}/cron-triggers/{cron_trigger_id}/test # Get one cron trigger Source: https://docs.hookie.ai/api-reference/cron-triggers/get-one-cron-trigger /openapi.json get /admin/api/projects/{project_id}/cron-triggers/{cron_trigger_id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List cron triggers in a project Source: https://docs.hookie.ai/api-reference/cron-triggers/list-cron-triggers-in-a-project /openapi.json get /admin/api/projects/{project_id}/cron-triggers Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Preview a cron schedule's next fire times Source: https://docs.hookie.ai/api-reference/cron-triggers/preview-a-cron-schedules-next-fire-times /openapi.json post /admin/api/projects/{project_id}/cron-triggers/preview The next five fire times of a schedule in a time zone, before it is saved. Checks the schedule exactly as create does. Any project member. # Update a cron trigger Source: https://docs.hookie.ai/api-reference/cron-triggers/update-a-cron-trigger /openapi.json patch /admin/api/projects/{project_id}/cron-triggers/{cron_trigger_id} Change any of name, enabled, schedule, timezone, payload and dataset (#165); each is checked as create checks it. A new schedule or time zone recomputes next_run_at, and so does resuming (enabled false to true), so a paused trigger does not fire at once on the next sweep for a time that passed while it was paused. # Create a destination for this customer Source: https://docs.hookie.ai/api-reference/customer-portal/create-a-destination-for-this-customer /openapi.json post /portal/api/destinations # Delete one of this customer's destinations Source: https://docs.hookie.ai/api-reference/customer-portal/delete-one-of-this-customers-destinations /openapi.json delete /portal/api/destinations/{id} The console's delete (#198): the row is severed from the project and switched off, since past deliveries still reference it. It disappears from every list, receives nothing, and cannot be switched back on. Audited as delete_destination. # Edit, pause or resume one of this customer's destinations Source: https://docs.hookie.ai/api-reference/customer-portal/edit-pause-or-resume-one-of-this-customers-destinations /openapi.json put /portal/api/destinations/{id} # Edit, pause or resume one of this customer's destinations Source: https://docs.hookie.ai/api-reference/customer-portal/edit-pause-or-resume-one-of-this-customers-destinations-1 /openapi.json patch /portal/api/destinations/{id} # End a rotation's overlap early Source: https://docs.hookie.ai/api-reference/customer-portal/end-a-rotations-overlap-early /openapi.json post /portal/api/destinations/{id}/expire-previous-secret Once the receiver is on the new secret, only it signs from here on. Audited as expire_destination_previous_secret. # Example payloads for each exposed event type, and how to verify a delivery (both signature schemes) Source: https://docs.hookie.ai/api-reference/customer-portal/example-payloads-for-each-exposed-event-type-and-how-to-verify-a-delivery-both-signature-schemes /openapi.json get /portal/api/examples For each dataset the portal exposes (#198, PORT-11): the JSON envelope Hookie POSTs, around the shape of the dataset's latest record with every value replaced (strings `""`, numbers 0, booleans false, arrays one element, at most 6 levels and 50 keys). A portal's customers share its datasets, so no value from a record is ever returned — only field names. A dataset with no record yet shows the envelope with empty `data`. The response documents both signatures every delivery carries over one timestamp: `signature` (Hookie-Signature) and, since #245, `standard_webhooks` (the Standard Webhooks `webhook-id` / `webhook-timestamp` / `webhook-signature` set added by #192, DLV-12), which a receiver can check with an official `standardwebhooks` library by passing the `whsec_` signing secret as is. # Get the portal context for the caller's token or session Source: https://docs.hookie.ai/api-reference/customer-portal/get-the-portal-context-for-the-callers-token-or-session /openapi.json get /portal/api/me # List recent delivery attempts to this customer's destinations Source: https://docs.hookie.ai/api-reference/customer-portal/list-recent-delivery-attempts-to-this-customers-destinations /openapi.json get /portal/api/deliveries # List the dataset/event types this portal exposes Source: https://docs.hookie.ai/api-reference/customer-portal/list-the-datasetevent-types-this-portal-exposes /openapi.json get /portal/api/event-types # List this customer's own destinations Source: https://docs.hookie.ai/api-reference/customer-portal/list-this-customers-own-destinations /openapi.json get /portal/api/destinations # One delivery, whole: why it failed Source: https://docs.hookie.ai/api-reference/customer-portal/one-delivery-whole:-why-it-failed /openapi.json get /portal/api/deliveries/{id} # One page of a shared view, for the signed-in customer Source: https://docs.hookie.ai/api-reference/customer-portal/one-page-of-a-shared-view-for-the-signed-in-customer /openapi.json get /portal/api/views/{id}/records Read-only (#376). Returns the project's rows of the view's dataset, shaped to the view's columns (no record id, no other fields), sorted by the view. At most 100 rows per page (default 25); `total` is capped at 10,000 (`total_capped`). 404 for a view that is unknown, not shared, or whose dataset the portal does not expose. # Rotate a destination's signing secret, with an overlap Source: https://docs.hookie.ai/api-reference/customer-portal/rotate-a-destinations-signing-secret-with-an-overlap /openapi.json post /portal/api/destinations/{id}/rotate-secret The console's rotation (#175, DLV-10): a new secret is minted and the one it replaces keeps signing for 24 hours, so every delivery in that window carries a v1 for each and the receiver can move over without dropping one. Rotating again inside a window replaces the older of the two. Audited as rotate_destination_secret. # Send a delivery again Source: https://docs.hookie.ai/api-reference/customer-portal/send-a-delivery-again /openapi.json post /portal/api/deliveries/{id}/replay The console's single replay (#198, same code): a new delivery of the same event to the same destination, charged to the vendor's monthly delivery allowance (#159) and refused, not held, when none is left. Audited as replay_delivery with the customer as the actor. # Show a destination's signing secret again Source: https://docs.hookie.ai/api-reference/customer-portal/show-a-destinations-signing-secret-again /openapi.json get /portal/api/destinations/{id}/secret The secret is AES-256-GCM encrypted at rest, so a customer who missed it at creation can read it again (#198) rather than create a second endpoint. Every reveal is audited as reveal_destination_secret. # Sign out of the portal Source: https://docs.hookie.ai/api-reference/customer-portal/sign-out-of-the-portal /openapi.json delete /portal/api/session Clears the session cookie. Needs `X-Requested-With: fetch`, and no valid session. The token itself stays valid until it is revoked or expires. # Table views the provider shared in the portal Source: https://docs.hookie.ai/api-reference/customer-portal/table-views-the-provider-shared-in-the-portal /openapi.json get /portal/api/views Read-only (#376). The saved record views the developer shared with the portal customer (`portal_shared`) whose dataset the portal exposes. Only the name, dataset, columns and sort are returned. # Trade a portal token for a session cookie Source: https://docs.hookie.ai/api-reference/customer-portal/trade-a-portal-token-for-a-session-cookie /openapi.json post /portal/api/session What the portal page does on first load: the link carries the token in its fragment (`#token=hpt_…`, never sent to a server), the page presents it here as a bearer, receives the `__Host-hookie_portal` session cookie, and forgets the token. The session lasts 8 hours or until the token expires, whichever is sooner. A session cannot be used to start another one. # Analytics breakdown by project, endpoint, workflow and dataset Source: https://docs.hookie.ai/api-reference/dashboard/analytics-breakdown-by-project-endpoint-workflow-and-dataset /openapi.json get /admin/api/dashboard/analytics One response for the console's Analytics page: every project's traffic, delivery, workflow and storage figures for the window. Readable by any role. Endpoint slugs are never returned. Delivery figures exist per project only. A dataset's bytes are the stored record payload (SUM of LENGTH(payload)), not the database's own overhead. Endpoints, workflows and datasets are capped at 500 rows each; truncated says when one was. # Bucketed activity series for the workspace Source: https://docs.hookie.ai/api-reference/dashboard/bucketed-activity-series-for-the-workspace /openapi.json get /admin/api/dashboard/timeseries Workspace-wide unless project_id is given. Readable by any role. Counted in SQL, one row per bucket: there is no cap on how many events a window may hold. # Workspace home dashboard rollup Source: https://docs.hookie.ai/api-reference/dashboard/workspace-home-dashboard-rollup /openapi.json get /admin/api/dashboard/summary Aggregated on read from records / deliveries / agent_runs / usage_rollups. Readable by any role. # Create a read-only Data API key (the plaintext is returned once) Source: https://docs.hookie.ai/api-reference/data-api/create-a-read-only-data-api-key-the-plaintext-is-returned-once /openapi.json post /admin/api/projects/{project_id}/data-api/keys # One page of an exposed dataset, exposed columns only Source: https://docs.hookie.ai/api-reference/data-api/one-page-of-an-exposed-dataset-exposed-columns-only /openapi.json get /v1/data/{dataset} # Replace what the project exposes Source: https://docs.hookie.ai/api-reference/data-api/replace-what-the-project-exposes /openapi.json put /admin/api/projects/{project_id}/data-api/exposures # Replace what the project exposes (PATCH, same as PUT) Source: https://docs.hookie.ai/api-reference/data-api/replace-what-the-project-exposes-patch-same-as-put /openapi.json patch /admin/api/projects/{project_id}/data-api/exposures PATCH is accepted on this path and behaves exactly as PUT does: the list sent replaces the project's exposures. # Revoke a Data API key; it stops working immediately Source: https://docs.hookie.ai/api-reference/data-api/revoke-a-data-api-key;-it-stops-working-immediately /openapi.json delete /admin/api/projects/{project_id}/data-api/keys/{key_id} # The datasets this key may read, and their columns Source: https://docs.hookie.ai/api-reference/data-api/the-datasets-this-key-may-read-and-their-columns /openapi.json get /v1/data # What the project exposes to the Data API, and its keys (metadata only) Source: https://docs.hookie.ai/api-reference/data-api/what-the-project-exposes-to-the-data-api-and-its-keys-metadata-only /openapi.json get /admin/api/projects/{project_id}/data-api # List datasets with record counts Source: https://docs.hookie.ai/api-reference/datasets/list-datasets-with-record-counts /openapi.json get /admin/api/projects/{project_id}/datasets Top-level alias: GET /admin/api/datasets (same handler, scoped to the workspace Default project). Readable by any role. # One page of a dataset's records, whole and flattened (export shape) Source: https://docs.hookie.ai/api-reference/datasets/one-page-of-a-datasets-records-whole-and-flattened-export-shape /openapi.json get /admin/api/projects/{project_id}/datasets/{dataset} Top-level alias: GET /admin/api/datasets/{dataset}. Keyset-paged, newest first, so an export can cover the WHOLE dataset in bounded requests: follow next_cursor until it is null. The cursor is a position (received_at, id), so records arriving during an export shift nothing, and a failed page can be retried with the same cursor. An unknown-but-valid dataset name returns 200 with empty rows, not 404. Readable by any role. # Delivery health per destination: success rate, latency percentiles, error classes, time series Source: https://docs.hookie.ai/api-reference/deliveries/delivery-health-per-destination:-success-rate-latency-percentiles-error-classes-time-series /openapi.json get /admin/api/projects/{project_id}/deliveries/metrics Every attempt in the window, the failed ones and the successful ones, per destination in the project and in total. Answers which destination is failing, whether it refuses requests (4xx) or fails to serve them (5xx, timeouts), and how slow it is. The series is hourly for 24h and 7d, daily for 30d, with every bucket present. # Get one delivery with its response body and every attempt Source: https://docs.hookie.ai/api-reference/deliveries/get-one-delivery-with-its-response-body-and-every-attempt /openapi.json get /admin/api/projects/{project_id}/deliveries/{id} Top-level alias: GET /admin/api/deliveries/{id}. Readable by any role. # List events whose deliveries are held past the monthly delivery allowance Source: https://docs.hookie.ai/api-reference/deliveries/list-events-whose-deliveries-are-held-past-the-monthly-delivery-allowance /openapi.json get /admin/api/projects/{project_id}/deliveries/held Past the plan's delivery ceiling the fan-out stores the event as usual and HOLDS its remaining destinations: one hold per event, oldest first. Nothing is dropped; release them with POST .../deliveries/held/release once there is room. # List outbound deliveries Source: https://docs.hookie.ai/api-reference/deliveries/list-outbound-deliveries /openapi.json get /admin/api/projects/{project_id}/deliveries Top-level alias: GET /admin/api/deliveries. Readable by any role. # Replay a delivery Source: https://docs.hookie.ai/api-reference/deliveries/replay-a-delivery /openapi.json post /admin/api/projects/{project_id}/deliveries/{id}/replay Top-level alias: POST /admin/api/deliveries/{id}/replay. Write role required. Any other method or sub-path on a delivery returns 405 Method not allowed. # Replay every delivery that did not arrive, by filter Source: https://docs.hookie.ai/api-reference/deliveries/replay-every-delivery-that-did-not-arrive-by-filter /openapi.json post /admin/api/projects/{project_id}/deliveries/replay Top-level alias: POST /admin/api/deliveries/replay. Write role required. The recovery after a receiver outage: replays every delivery in the project that matches the filter and did not arrive - 'dead', or 'failed' with no retry scheduled (one still being retried is left to its retry). Only the latest delivery of each event to each destination matches (an event's original and a failed replay of it are one event, sent once), and only while the event is stored; deliveries to a deleted destination, or to a customer-portal destination whose portal no longer exposes the dataset, never match. Oldest first. Bounded per call: queues up to limit and reports how many still match, so call again while more is true; a replayed delivery stops matching, so repeating (or racing) a call never sends one twice. Each replay is a new delivery (event_id #replay:) charged to the monthly delivery allowance, like a single replay: the call queues as many as the allowance grants and stops there. Audited once per call as replay_deliveries. # Send held deliveries now that there is room in the allowance Source: https://docs.hookie.ai/api-reference/deliveries/send-held-deliveries-now-that-there-is-room-in-the-allowance /openapi.json post /admin/api/projects/{project_id}/deliveries/held/release Re-runs the fan-out for held events, oldest first, creating the deliveries each event's destinations still lack and charging them to the allowance. Stops at the first event it cannot fully cover (that event keeps a hold for what is left). A hold whose event has been pruned by retention is dropped. Writes one release_held_deliveries audit row. # An outbound delivery to a destination Source: https://docs.hookie.ai/api-reference/delivery/an-outbound-delivery-to-a-destination /openapi.json webhook delivery What Hookie POSTs to a destination URL for each routed record. Delivery is at-least-once and unordered: dedupe on Hookie-Event-Id (the same value as Idempotency-Key). Redirects are never followed. Every request is signed TWICE over one timestamp, so a receiver can verify whichever it prefers: Hookie-Signature (Stripe-style, t=,v1=) and the Standard Webhooks headers webhook-id / webhook-timestamp / webhook-signature, which any Standard Webhooks library verifies unchanged. For 24 hours after a signing-secret rotation (until the destination's previous_secret_expires_at) each header carries two signatures, the new secret's first: two v1= values in Hookie-Signature and two space-separated v1, entries in webhook-signature. Accept the request if ANY signature matches a secret you hold, and reject a timestamp more than five minutes from now. The two schemes derive DIFFERENT HMAC keys from the same whsec_ signing secret: Hookie-Signature is keyed with the UTF-8 bytes of the whole secret string, whsec_ included; webhook-signature is keyed, as the Standard Webhooks spec says, with the base64-decoded bytes of what follows whsec_. Hookie's secrets are whsec_ + 48 hex characters, which is valid base64 (hex digits are base64 characters and 48 is a multiple of 4) and decodes to 36 bytes, so any Standard Webhooks library accepts the secret exactly as shown. # Create an outbound destination Source: https://docs.hookie.ai/api-reference/destinations/create-an-outbound-destination /openapi.json post /admin/api/projects/{project_id}/destinations Top-level alias: POST /admin/api/destinations. Write role required. The workspace-wide cap counts destinations with a non-null project_id and is checked before validation. # Delete a destination Source: https://docs.hookie.ai/api-reference/destinations/delete-a-destination /openapi.json delete /admin/api/projects/{project_id}/destinations/{id} Top-level alias: DELETE /admin/api/destinations/{id}. Write role required. # Edit a destination: name, URL or type settings, on/off, dataset filter, request options, alert URL or delivery limits Source: https://docs.hookie.ai/api-reference/destinations/edit-a-destination:-name-url-or-type-settings-onoff-dataset-filter-request-options-alert-url-or-delivery-limits /openapi.json patch /admin/api/projects/{project_id}/destinations/{id} Top-level alias: PATCH /admin/api/destinations/{id}. Write role required. # Get one destination Source: https://docs.hookie.ai/api-reference/destinations/get-one-destination /openapi.json get /admin/api/projects/{project_id}/destinations/{id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List outbound destinations Source: https://docs.hookie.ai/api-reference/destinations/list-outbound-destinations /openapi.json get /admin/api/projects/{project_id}/destinations Top-level alias: GET /admin/api/destinations. Readable by any role. Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Reveal a destination signing secret Source: https://docs.hookie.ai/api-reference/destinations/reveal-a-destination-signing-secret /openapi.json get /admin/api/projects/{project_id}/destinations/{id}/secret Top-level alias: GET /admin/api/destinations/{id}/secret. Any sub-path other than secret or rotate-secret returns 404 Not found. # Rotate a destination signing secret Source: https://docs.hookie.ai/api-reference/destinations/rotate-a-destination-signing-secret /openapi.json post /admin/api/projects/{project_id}/destinations/{id}/rotate-secret Top-level alias: POST /admin/api/destinations/{id}/rotate-secret. # Stop signing with a destination's previous secret Source: https://docs.hookie.ai/api-reference/destinations/stop-signing-with-a-destinations-previous-secret /openapi.json post /admin/api/projects/{project_id}/destinations/{id}/expire-previous-secret Top-level alias: POST /admin/api/destinations/{id}/expire-previous-secret. Ends a rotation's 24-hour overlap early: from the next delivery only the current secret signs. Owner or admin only, never an OAuth-connected agent; audited as expire_destination_previous_secret. Succeeds (and does nothing) when no previous secret is live. No request body is read. # Get one ingest key Source: https://docs.hookie.ai/api-reference/ingest-keys/get-one-ingest-key /openapi.json get /admin/api/projects/{project_id}/ingest-keys/{id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # Issue an ingest key Source: https://docs.hookie.ai/api-reference/ingest-keys/issue-an-ingest-key /openapi.json post /admin/api/projects/{project_id}/ingest-keys Top-level alias: POST /admin/api/ingest-keys. Write role required. # List ingest keys Source: https://docs.hookie.ai/api-reference/ingest-keys/list-ingest-keys /openapi.json get /admin/api/projects/{project_id}/ingest-keys Top-level alias: GET /admin/api/ingest-keys. Readable by any role. Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Rename an ingest key, or set its expiry or IP allowlist Source: https://docs.hookie.ai/api-reference/ingest-keys/rename-an-ingest-key-or-set-its-expiry-or-ip-allowlist /openapi.json patch /admin/api/projects/{project_id}/ingest-keys/{id} Top-level alias: PATCH /admin/api/ingest-keys/{id}. Write role required. Send at least one of name, expires_at and ip_allowlist. dataset_default and require_signature are fixed at creation. A revoked or expired key cannot be changed (409) - nothing revives a dead credential. On a rotated key, moving expires_at moves the end of the overlap. Audited as update_ingest_key with each change's from and to. # Revoke an ingest key Source: https://docs.hookie.ai/api-reference/ingest-keys/revoke-an-ingest-key /openapi.json delete /admin/api/projects/{project_id}/ingest-keys/{id} Top-level alias: DELETE /admin/api/ingest-keys/{id}. Write role required. The key stops authenticating on the next request, at ingest and on /v1/stream. To replace a key without an outage, rotate it instead. # Rotate an ingest key with an overlap Source: https://docs.hookie.ai/api-reference/ingest-keys/rotate-an-ingest-key-with-an-overlap /openapi.json post /admin/api/projects/{project_id}/ingest-keys/{id}/rotate Top-level alias: POST /admin/api/ingest-keys/{id}/rotate. Write role required. Issues a NEW key with the old one's name, dataset_default, require_signature and ip_allowlist, and gives the OLD key an expiry overlap_hours from now (an earlier expiry it already had is kept). Both authenticate until then, at ingest and on /v1/stream. A key that requires signatures gets a new signing secret. The new key and secret are returned once, exactly as on create. A key can be rotated once; rotate its replacement after that. Audited as rotate_ingest_key on the old key, naming the new one. # CORS preflight for a page's fetch() to the public webhook URL Source: https://docs.hookie.ai/api-reference/ingest/cors-preflight-for-a-pages-fetch-to-the-public-webhook-url /openapi.json options /{workspace}/{project}/{webhook} ING-8 (#193). Answered for an Origin in the endpoint's cors_origins (or when they include "*"): 204 with Access-Control-Allow-Origin (the origin, or * for any), Access-Control-Allow-Methods: POST, Access-Control-Allow-Headers: Content-Type, Idempotency-Key, and Access-Control-Max-Age: 600. Any other origin, or an endpoint with no allowed origins (the default), gets 403 and no CORS header, so the browser blocks the call. Nothing is stored or counted. Credentials are never allowed: the URL is the credential. # Ingest an event into a configured endpoint using an ingest key Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-into-a-configured-endpoint-using-an-ingest-key /openapi.json post /v1/webhooks/{ingest_key}/{slug} Key-authenticated form of an endpoint's URL. PUT is accepted identically (ingestWebhookEventPut); any other method returns 405 {"error":"Use POST"}. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. # Ingest an event into a named dataset (PUT alias of ingestEventToDataset) Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-into-a-named-dataset-put-alias-of-ingesteventtodataset /openapi.json put /v1/ingest/{ingest_key}/{dataset} PUT alias; behaviour is byte-for-byte the same as the POST operation. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. # Ingest an event through the public webhook URL Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-through-the-public-webhook-url /openapi.json post /{workspace}/{project}/{webhook} Public webhook URL, app.hookie.ai/{workspace}/{project}/{webhook}. POST, plus OPTIONS (the CORS preflight) and a GET carrying hub.challenge (Meta's URL verification); any other GET, and PUT, fall through to the console and never reach ingest. Only 3- or 4-segment paths match. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. HTML forms (ING-8, #193): when the endpoint has a redirect_url, a browser's native form POST (a form encoding with Sec-Fetch-Mode: navigate, or with an Accept that asks for text/html from an older browser) is answered 303 See Other to that URL once stored (a deduplicated retry too); fetch() calls and servers still get the JSON. CORS: when the request's Origin is in the endpoint's cors_origins (or they include "*"), every response carries Access-Control-Allow-Origin, Access-Control-Expose-Headers: Retry-After and Vary: Origin. URL verification (ING-10, #193): an endpoint whose handshake is slack, graph, zoom or twitch answers that provider's challenge with 200 before anything is stored or counted - Slack's url_verification body and Twitch's webhook_callback_verification (Twitch-Eventsub-Message-Type header) with the challenge as text/plain, Microsoft Graph's ?validationToken= with the token as text/plain, and Zoom's endpoint.url_validation with {plainToken, encryptedToken} (HMAC-SHA256 of plainToken under the endpoint's secret token). Hookie Forms (#341): an endpoint with a honeypot answers a submission that fills it 201 and drops it; one with a Turnstile secret refuses a submission without a valid cf-turnstile-response token 403 (reason turnstile_failed); one with form_fields refuses a payload below them 422 (reason form_invalid, fields: [{name, error}]); all three before the quota and the store. A browser's native form POST to an endpoint with no redirect_url is answered with an HTML thank-you page (200), or, for a 422 or a Turnstile refusal, an HTML page saying what to fix. Every other request is an ordinary event. # Ingest an event through the public webhook URL, naming its dataset Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-through-the-public-webhook-url-naming-its-dataset /openapi.json post /{workspace}/{project}/{webhook}/{dataset} Four-segment form of the public webhook URL. POST, plus the OPTIONS preflight and Meta's hub.challenge GET, which behave as on the three-segment URL; other methods fall through to the console. The fourth segment must be the endpoint's OWN dataset: any other value is refused with 403. It used to override the dataset with any dataset in the project, which let anyone holding a public endpoint URL (one embedded in an HTML form, say) write into every dataset in the project and set off its destinations, triggers and workflows. To route one source into several datasets, use an ingest key: POST /v1/ingest/{ingest_key}/{dataset}. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. HTML forms (ING-8, #193): when the endpoint has a redirect_url, a browser's native form POST (a form encoding with Sec-Fetch-Mode: navigate, or with an Accept that asks for text/html from an older browser) is answered 303 See Other to that URL once stored (a deduplicated retry too); fetch() calls and servers still get the JSON. CORS: when the request's Origin is in the endpoint's cors_origins (or they include "*"), every response carries Access-Control-Allow-Origin, Access-Control-Expose-Headers: Retry-After and Vary: Origin. URL verification (ING-10, #193): an endpoint whose handshake is slack, graph, zoom or twitch answers that provider's challenge with 200 before anything is stored or counted - Slack's url_verification body and Twitch's webhook_callback_verification (Twitch-Eventsub-Message-Type header) with the challenge as text/plain, Microsoft Graph's ?validationToken= with the token as text/plain, and Zoom's endpoint.url_validation with {plainToken, encryptedToken} (HMAC-SHA256 of plainToken under the endpoint's secret token). Hookie Forms (#341): an endpoint with a honeypot answers a submission that fills it 201 and drops it; one with a Turnstile secret refuses a submission without a valid cf-turnstile-response token 403 (reason turnstile_failed); one with form_fields refuses a payload below them 422 (reason form_invalid, fields: [{name, error}]); all three before the quota and the store. A browser's native form POST to an endpoint with no redirect_url is answered with an HTML thank-you page (200), or, for a 422 or a Turnstile refusal, an HTML page saying what to fix. Every other request is an ordinary event. # Ingest an event with an ingest key (PUT alias of ingestEvent) Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-with-an-ingest-key-put-alias-of-ingestevent /openapi.json put /v1/ingest/{ingest_key} PUT is accepted verbatim — the handler's only method check allows POST and PUT, and nothing downstream branches on the method. Not idempotent in the HTTP sense: each PUT stores a new submission unless an Idempotency-Key repeats. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. # Ingest an event with an ingest key, routed by the project's mapping rules, with the key's default dataset as the fallback Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-with-an-ingest-key-routed-by-the-projects-mapping-rules-with-the-keys-default-dataset-as-the-fallback /openapi.json post /v1/ingest/{ingest_key} Generic ingest. PUT is accepted on this same path and behaves identically (see ingestEventPut); every other method returns 405 {"error":"Use POST"}, including OPTIONS — there are no CORS headers on this route. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. # Ingest an event with an ingest key straight into a named dataset Source: https://docs.hookie.ai/api-reference/ingest/ingest-an-event-with-an-ingest-key-straight-into-a-named-dataset /openapi.json post /v1/ingest/{ingest_key}/{dataset} Same pipeline as ingestEvent but with identity routing into {dataset}. PUT is accepted identically (ingestEventToDatasetPut); any other method returns 405 {"error":"Use POST"}. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. # Ingest into a configured endpoint (PUT alias of ingestWebhookEvent) Source: https://docs.hookie.ai/api-reference/ingest/ingest-into-a-configured-endpoint-put-alias-of-ingestwebhookevent /openapi.json put /v1/webhooks/{ingest_key}/{slug} PUT alias; behaviour is identical to the POST operation. Every stored submission keeps the request's method, a filtered header map, the query string, the Content-Type and the source IP (ING-3, #193), shown on the record (GET .../records/{id}) and on correlate as `request`. Credentials are never stored: Authorization, Proxy-Authorization, Cookie and any header or query parameter named like a credential (key, api-key, token, secret, password, session, auth) is stored as "[redacted]"; Cloudflare and proxy hop headers (cf-*, x-forwarded-*, x-real-ip, true-client-ip, cdn-loop, host) are dropped. # Meta's URL verification (hub.challenge) on the public webhook URL Source: https://docs.hookie.ai/api-reference/ingest/metas-url-verification-hubchallenge-on-the-public-webhook-url /openapi.json get /{workspace}/{project}/{webhook} ING-10 (#193). Reaches ingest only when the query carries hub.challenge; any other GET is the console. For an endpoint whose handshake is meta: with hub.mode=subscribe and a hub.verify_token equal to the endpoint's verify token (handshake_secret), answers 200 text/plain with hub.challenge. A different token is 403 'handshake_failed'. Checked after the endpoint's disabled state, IP allowlists, suspension and burst limit. Nothing is stored or counted. # Read the workspace-level ingest IP allowlist Source: https://docs.hookie.ai/api-reference/ip-allowlist/read-the-workspace-level-ingest-ip-allowlist /openapi.json get /admin/api/ip-allowlist Owners and admins only. A GET never changes the list (before SEC-4 it cleared it). # Replace the workspace-level ingest IP allowlist Source: https://docs.hookie.ai/api-reference/ip-allowlist/replace-the-workspace-level-ingest-ip-allowlist /openapi.json put /admin/api/ip-allowlist # Replace the workspace-level ingest IP allowlist Source: https://docs.hookie.ai/api-reference/ip-allowlist/replace-the-workspace-level-ingest-ip-allowlist-1 /openapi.json patch /admin/api/ip-allowlist # A project's flagged events: filtered and routing failed Source: https://docs.hookie.ai/api-reference/observability/a-projects-flagged-events:-filtered-and-routing-failed /openapi.json get /admin/api/projects/{project_id}/errors #287. Any role. The project's Errors tab: submissions an endpoint's own criteria rejected (kind filtered, forward_status 'filtered') and submissions whose records could not be written (kind routing_failed, forward_status 'failed'), newest first. Each is still a submission: kept, counted against the quota when it arrived, searchable in Observability, and pruned by the plan's retention like any other; nothing here is a copy. Payloads rejected before #287 stay forward_status 'pending' and are not listed (no backfill). Paged on the server with limit/offset and a total. MCP: list_project_errors. # One flagged event, with its whole payload Source: https://docs.hookie.ai/api-reference/observability/one-flagged-event-with-its-whole-payload /openapi.json get /admin/api/projects/{project_id}/errors/{id} #287. Any role. One row of the Errors tab by its submission id, with `payload` whole rather than the list's 500-character preview. 404 for a submission that is not filtered or failed (for instance one routed since). # Create a customer portal Source: https://docs.hookie.ai/api-reference/portals/create-a-customer-portal /openapi.json post /admin/api/projects/{project_id}/portals # Create or update the project's customer portal Source: https://docs.hookie.ai/api-reference/portals/create-or-update-the-projects-customer-portal /openapi.json put /admin/api/projects/{project_id}/portal Creates the portal on the first call and updates it after that. A project has exactly one portal; this is what the console's Settings → Customer portal pane saves. # Create or update the project's customer portal (PATCH, same as PUT) Source: https://docs.hookie.ai/api-reference/portals/create-or-update-the-projects-customer-portal-patch-same-as-put /openapi.json patch /admin/api/projects/{project_id}/portal PATCH is accepted on this path and behaves exactly as PUT does: it creates the portal on the first call and updates it after that. # Disable a customer portal Source: https://docs.hookie.ai/api-reference/portals/disable-a-customer-portal /openapi.json delete /admin/api/projects/{project_id}/portals/{portal_id} # Get one customer portal Source: https://docs.hookie.ai/api-reference/portals/get-one-customer-portal /openapi.json get /admin/api/projects/{project_id}/portals/{portal_id} # Issue or revoke a portal access token Source: https://docs.hookie.ai/api-reference/portals/issue-or-revoke-a-portal-access-token /openapi.json post /admin/api/projects/{project_id}/portals/{portal_id}/tokens **One customer per portal (PORT-2g, #321).** A portal's events are not scoped to a customer: every customer endpoint on a portal receives every event of each dataset the portal exposes. So a portal serves exactly one customer, and a vendor isolates customers by giving each one its own project and portal. Issuing a token whose `customer_id` differs from the portal's current customer (anyone holding a live token, not revoked and not expired, or still having an endpoint that is on) is a `409` with `reason: "portal_customer_mismatch"`. The check is made by the insert itself, so two concurrent issues for different customers cannot both succeed. The same customer can hold several tokens. Revoking a customer's last token or offboarding them switches their endpoints off and frees the portal for a new customer. Switching an offboarded customer's endpoint back on (`enabled: true` on the destination) is refused the same way while the portal serves someone else, and a portal customer's own create or enable re-checks their token inside the write (401 once it is revoked). # List a portal's access tokens Source: https://docs.hookie.ai/api-reference/portals/list-a-portals-access-tokens /openapi.json get /admin/api/projects/{project_id}/portals/{portal_id}/tokens # List customer portals in a project Source: https://docs.hookie.ai/api-reference/portals/list-customer-portals-in-a-project /openapi.json get /admin/api/projects/{project_id}/portals # Remove the project's customer portal for good Source: https://docs.hookie.ai/api-reference/portals/remove-the-projects-customer-portal-for-good /openapi.json delete /admin/api/projects/{project_id}/portal Retires the portal: `retired_at` is set and it is switched off, every live access token is revoked, the pending deliveries (queued, retrying or paused) of its customers' destinations are cancelled, and one `remove_portal` audit row records it with counts. Nothing is deleted: the portal row, its customers' destinations and their delivery history stay. The project can then have a new portal. Unlike switching the portal off (`enabled: false`), this cannot be undone. The body must repeat the portal's name as `confirm`. # The project's one customer portal Source: https://docs.hookie.ai/api-reference/portals/the-projects-one-customer-portal /openapi.json get /admin/api/projects/{project_id}/portal # Update a customer portal Source: https://docs.hookie.ai/api-reference/portals/update-a-customer-portal /openapi.json put /admin/api/projects/{project_id}/portals/{portal_id} # Update a customer portal (PATCH, same as PUT) Source: https://docs.hookie.ai/api-reference/portals/update-a-customer-portal-patch-same-as-put /openapi.json patch /admin/api/projects/{project_id}/portals/{portal_id} PATCH is accepted on this path and behaves exactly as PUT does: only the fields sent change, and `slug` cannot be changed. # Inbound source preset catalog Source: https://docs.hookie.ai/api-reference/presets/inbound-source-preset-catalog /openapi.json get /admin/api/source-presets # Outbound destination preset catalog Source: https://docs.hookie.ai/api-reference/presets/outbound-destination-preset-catalog /openapi.json get /admin/api/destination-presets # Clone a project Source: https://docs.hookie.ai/api-reference/projects/clone-a-project /openapi.json post /admin/api/projects/{project_id}/clone A new project, of the same type, holding this project's configuration: an export imported into it (see importProjectConfig). No data is copied, no secret either — endpoints get new URLs, destinations new signing secrets, an endpoint that verifies a provider's signature keeps its scheme and settings but not its secret (so it refuses every request with 401 until one is set; listed under import.needs_verification_secret), and the new project gets no seeded endpoint. Owner or admin (DX-10, #201). # Create a project Source: https://docs.hookie.ai/api-reference/projects/create-a-project /openapi.json post /admin/api/projects Owner/admin only. # Delete a project Source: https://docs.hookie.ai/api-reference/projects/delete-a-project /openapi.json delete /admin/api/projects/{project_id} Owner only. An agent can never reach owner (manage scope clips to admin). # Export a project's configuration Source: https://docs.hookie.ai/api-reference/projects/export-a-projects-configuration /openapi.json get /admin/api/projects/{project_id}/config The project's configuration as JSON, or as YAML with ?format=yaml (the subset `hookie apply` reads). Readable by any role (DX-10, #201). # Get one project with resource counts Source: https://docs.hookie.ai/api-reference/projects/get-one-project-with-resource-counts /openapi.json get /admin/api/projects/{project_id} Readable by any role. # Import a configuration into a project Source: https://docs.hookie.ai/api-reference/projects/import-a-configuration-into-a-project /openapi.json post /admin/api/projects/{project_id}/config Create what the document describes, in dependency order (AI agents, record views, rules, endpoints, destinations, workflows, AI triggers, cron triggers), each through its own create route — so it is validated, plan-limited and audited as a create by hand is, and every endpoint gets a NEW URL and every destination a NEW signing secret (reveal it with GET destinations/{id}/secret). An endpoint that verifies a provider's signature is created with its scheme and settings but NO secret, so it refuses every request with 401 until one is set, and is listed under needs_verification_secret (#238). A resource whose identity already exists in the project is skipped, never overwritten. A destination's `endpoint` (an endpoint's base slug) is resolved to that endpoint's URL, in the document or in the project; one that names neither fails by name. A failed create is reported and the import carries on. JSON only; import YAML with `hookie apply`. Write role required; cron triggers need owner or admin. # List projects in the workspace Source: https://docs.hookie.ai/api-reference/projects/list-projects-in-the-workspace /openapi.json get /admin/api/projects Readable by any role. # Rename a project or set its IP allowlist Source: https://docs.hookie.ai/api-reference/projects/rename-a-project-or-set-its-ip-allowlist /openapi.json patch /admin/api/projects/{project_id} Owner/admin only. Slug and type are immutable. # Create a record view Source: https://docs.hookie.ai/api-reference/record-views/create-a-record-view /openapi.json post /admin/api/projects/{project_id}/record-views Top-level alias: POST /admin/api/record-views. Write role required. # Delete a record view Source: https://docs.hookie.ai/api-reference/record-views/delete-a-record-view /openapi.json delete /admin/api/projects/{project_id}/record-views/{id} Top-level alias: DELETE /admin/api/record-views/{id}. Write role required. # Get one view Source: https://docs.hookie.ai/api-reference/record-views/get-one-view /openapi.json get /admin/api/projects/{project_id}/record-views/{id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List saved record views Source: https://docs.hookie.ai/api-reference/record-views/list-saved-record-views /openapi.json get /admin/api/projects/{project_id}/record-views Top-level alias: GET /admin/api/record-views. Readable by any role. Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Update a record view Source: https://docs.hookie.ai/api-reference/record-views/update-a-record-view /openapi.json patch /admin/api/projects/{project_id}/record-views/{id} Top-level alias: PATCH /admin/api/record-views/{id}. Write role required. # Delete a record and every copy of it Source: https://docs.hookie.ai/api-reference/records/delete-a-record-and-every-copy-of-it /openapi.json delete /admin/api/projects/{project_id}/records/{id} Permanently deletes one record and every copy Hookie keeps of it, in one batch: its raw submission, its pending fan-out, its deliveries (replays included) with their response bodies and every failed attempt kept for them, its held deliveries, the workflow runs it started (live Cloudflare runs are terminated first) with their step log, waiters and AI calls, and for AI trigger runs on it the AI calls and the model's output (the run row stays as the monthly AI-run quota ledger; a run still queued is failed). Deliveries already sent are not recalled. Audited as `delete_record` with the counts, never the payload. Developer role and up; also the MCP tool `delete_record`. Top-level alias: DELETE /admin/api/records/{id} (Default project). # Delete every record in a dataset Source: https://docs.hookie.ai/api-reference/records/delete-every-record-in-a-dataset /openapi.json post /admin/api/projects/{project_id}/datasets/{dataset}/purge Deletes every record in the dataset received up to the moment of the request, each with everything DELETE records/{id} takes with it (submission, deliveries, holds, workflow runs, AI output). Records that arrive afterwards are kept. Runs up to 1,000 records inside the request; a larger purge answers 202 and the scheduled sweep (every 5 minutes) finishes it — poll GET for progress. One running purge per dataset. Owner or admin. Audited as `purge_dataset` (request) and `dataset_purged` (completion, with the count). Not available as an MCP tool. # Get one record with its full payload Source: https://docs.hookie.ai/api-reference/records/get-one-record-with-its-full-payload /openapi.json get /admin/api/projects/{project_id}/records/{id} Top-level alias: GET /admin/api/records/{id}. GET /admin/api/projects/{project_id}/records (no id) is not a route - it returns 404 Record id required. Readable by any role. # One page of a dataset's records, optionally through a saved view Source: https://docs.hookie.ai/api-reference/records/one-page-of-a-datasets-records-optionally-through-a-saved-view /openapi.json get /admin/api/projects/{project_id}/datasets/{dataset}/records Top-level alias: GET /admin/api/webhooks/{id}/records. Readable by any role. Only GET is routed here; other methods on this path fall through the webhook router to 405. # Retired: records are read by dataset Source: https://docs.hookie.ai/api-reference/records/retired:-records-are-read-by-dataset /openapi.json get /admin/api/projects/{project_id}/webhooks/{id}/records # The dataset's latest purge Source: https://docs.hookie.ai/api-reference/records/the-datasets-latest-purge /openapi.json get /admin/api/projects/{project_id}/datasets/{dataset}/purge Progress of the most recent purge of this dataset, or `purge: null` if it was never purged. Readable by any role. # Create a mapping rule Source: https://docs.hookie.ai/api-reference/rules/create-a-mapping-rule /openapi.json post /admin/api/projects/{project_id}/rules Top-level alias: POST /admin/api/rules. Write role required. # Delete a rule Source: https://docs.hookie.ai/api-reference/rules/delete-a-rule /openapi.json delete /admin/api/projects/{project_id}/rules/{id} Top-level alias: DELETE /admin/api/rules/{id}. Write role required. # Get one rule Source: https://docs.hookie.ai/api-reference/rules/get-one-rule /openapi.json get /admin/api/projects/{project_id}/rules/{id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List mapping rules Source: https://docs.hookie.ai/api-reference/rules/list-mapping-rules /openapi.json get /admin/api/projects/{project_id}/rules Top-level alias: GET /admin/api/rules. Readable by any role. Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Test conditions and mappings against a sample payload Source: https://docs.hookie.ai/api-reference/rules/test-conditions-and-mappings-against-a-sample-payload /openapi.json post /admin/api/projects/{project_id}/rules/test Whether these conditions match the payload and what the mappings would store, evaluated by the same engine ingest uses. Validated exactly as a save is; nothing is written. Readable by any role. The console's rule editor calls it (ING-12, #201). # Update a rule Source: https://docs.hookie.ai/api-reference/rules/update-a-rule /openapi.json patch /admin/api/projects/{project_id}/rules/{id} Change any of enabled, name, dataset, conditions and mappings in place (ING-12, #201); it used to toggle enabled only. Send only what changes; the result is validated as a create is. Top-level alias: PATCH /admin/api/rules/{id}. Write role required. # Correlate any pipeline id across the pipeline Source: https://docs.hookie.ai/api-reference/search/correlate-any-pipeline-id-across-the-pipeline /openapi.json get /admin/api/projects/{project_id}/search/correlate/{id} Top-level alias: GET /admin/api/search/correlate/{id}. Readable by any role. # Get one AI call in full Source: https://docs.hookie.ai/api-reference/search/get-one-ai-call-in-full /openapi.json get /admin/api/projects/{project_id}/search/ai-calls/{id} The instructions, input and output of one AI call, as logged. Top-level alias: GET /admin/api/search/ai-calls/{id}. # Search AI agent runs Source: https://docs.hookie.ai/api-reference/search/search-ai-agent-runs /openapi.json post /admin/api/projects/{project_id}/search/ai-runs Top-level alias: POST /admin/api/search/ai-runs. # Search deliveries Source: https://docs.hookie.ai/api-reference/search/search-deliveries /openapi.json post /admin/api/projects/{project_id}/search/deliveries Top-level alias: POST /admin/api/search/deliveries. # Search raw submissions Source: https://docs.hookie.ai/api-reference/search/search-raw-submissions /openapi.json post /admin/api/projects/{project_id}/search/submissions Top-level alias: POST /admin/api/search/submissions. # Search records (events) Source: https://docs.hookie.ai/api-reference/search/search-records-events /openapi.json post /admin/api/projects/{project_id}/search/events Top-level alias: POST /admin/api/search/events. No role above read is enforced. Any non-POST method returns 405. # Search the AI call log Source: https://docs.hookie.ai/api-reference/search/search-the-ai-call-log /openapi.json post /admin/api/projects/{project_id}/search/ai-calls Every AI call an AI trigger, workflow call_ai step or agent_call step made in this project, including calls refused for being over the monthly AI token allowance. Newest first. Lists carry a preview of the output; GET one call for the full texts. Top-level alias: POST /admin/api/search/ai-calls. # Create a source connector Source: https://docs.hookie.ai/api-reference/sources/create-a-source-connector /openapi.json post /admin/api/projects/{project_id}/sources PostgreSQL only (#196, FLOW-3). Type, config and cursor column are validated as the poller uses them, before the plan's source cap is checked; MySQL and HTTP poll were offered but never implemented and are refused. The first poll runs on the next 5-minute heartbeat. # Delete a source connector Source: https://docs.hookie.ai/api-reference/sources/delete-a-source-connector /openapi.json delete /admin/api/projects/{project_id}/sources/{source_id} # Get one source Source: https://docs.hookie.ai/api-reference/sources/get-one-source /openapi.json get /admin/api/projects/{project_id}/sources/{source_id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List inbound source connectors in a project Source: https://docs.hookie.ai/api-reference/sources/list-inbound-source-connectors-in-a-project /openapi.json get /admin/api/projects/{project_id}/sources Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Test a source connector's connection Source: https://docs.hookie.ai/api-reference/sources/test-a-source-connectors-connection /openapi.json post /admin/api/projects/{project_id}/sources/{source_id}/test Connects with the stored config and reads the cursor column from the table (`SELECT "" FROM LIMIT 0`), so a pass means a poll can run, not only that the server answers (#196). # Update a source connector Source: https://docs.hookie.ai/api-reference/sources/update-a-source-connector /openapi.json patch /admin/api/projects/{project_id}/sources/{source_id} Every field a poll depends on can be corrected in place (#196, FLOW-3). A partial `config` (for example only `table`) is merged over the stored, encrypted one, so the connection string need not be sent again. A new table or cursor column restarts the watermark (`last_cursor` is cleared); any edit clears `last_error`. # Cancel a pending SSO / Directory Sync request Source: https://docs.hookie.ai/api-reference/sso/cancel-a-pending-sso-directory-sync-request /openapi.json delete /admin/api/sso/requests/{request_id} Withdraws a request that is still `pending`. An approved request cannot be cancelled here. Owner only; allowed while the workspace is suspended. # Check whether an SSO configuration is complete Source: https://docs.hookie.ai/api-reference/sso/check-whether-an-sso-configuration-is-complete /openapi.json post /admin/api/sso/{sso_config_id}/test # Create a workspace SSO configuration (not available) Source: https://docs.hookie.ai/api-reference/sso/create-a-workspace-sso-configuration-not-available /openapi.json post /admin/api/sso Single sign-on is not available yet (SEC-13a, #257): there is no SAML or OIDC sign-in, so this always answers `501` with `reason: "sso_not_available"` and changes nothing. It answers before the plan, the role or the body is looked at. A configuration saved before then is kept as it was; list it with listSsoConfigs. # Delete a workspace SSO configuration Source: https://docs.hookie.ai/api-reference/sso/delete-a-workspace-sso-configuration /openapi.json delete /admin/api/sso/{sso_config_id} # List workspace SSO configurations Source: https://docs.hookie.ai/api-reference/sso/list-workspace-sso-configurations /openapi.json get /admin/api/sso The workspace's saved SSO configurations. Single sign-on is not available yet (SEC-13a, #257), so none of them is active: nobody signs in through a saved configuration, whatever its `enabled` says. Readable on every plan and by any role, so a configuration saved before then stays visible. # Open the WorkOS Admin Portal to set up SSO or Directory Sync Source: https://docs.hookie.ai/api-reference/sso/open-the-workos-admin-portal-to-set-up-sso-or-directory-sync /openapi.json post /admin/api/sso/portal-link Generates a WorkOS Admin Portal link for the workspace's organization, where the customer's IT admin connects their directory (`intent: "dsync"`). Refused unless a Hookie operator has approved the workspace's request, and the request included that intent. `intent: "sso"` is refused for every workspace, approved or not (`reason: "sso_signin_not_available"`) until Hookie's sign-in can start an SSO login: once an SSO connection is active and a domain verified, WorkOS refuses emailed codes, GitHub and Google for that domain across the whole environment, which would lock every Hookie user at it out. The link expires five minutes after it is made and is never stored, so the console opens it straight away. Owner only, on the Team plan. # Read the workspace's SSO / Directory Sync request Source: https://docs.hookie.ai/api-reference/sso/read-the-workspaces-sso-directory-sync-request /openapi.json get /admin/api/sso/requests The workspace's latest request for single sign-on or Directory Sync, or null, and whether its plan offers them (`eligible`: Team only). Owner and admin. Never available to connected agents or API keys. While a change is in review, `request` is the change and `approved` the request still in force. # Replace a workspace SSO configuration (not available) Source: https://docs.hookie.ai/api-reference/sso/replace-a-workspace-sso-configuration-not-available /openapi.json put /admin/api/sso/{sso_config_id} Single sign-on is not available yet (SEC-13a, #257): there is no SAML or OIDC sign-in, so this always answers `501` with `reason: "sso_not_available"` and changes nothing. It answers before the plan, the role or the body is looked at. A configuration saved before then is kept as it was; list it with listSsoConfigs. # Request single sign-on or Directory Sync Source: https://docs.hookie.ai/api-reference/sso/request-single-sign-on-or-directory-sync /openapi.json post /admin/api/sso/requests Files a request for a Hookie operator to review (#325). WorkOS bills every SSO and Directory Sync connection, so nothing is created in WorkOS or in Hookie's organization mapping until an operator approves it; this call only stores the request as `pending`. Owner only, on the Team plan. One request in review (pending) per workspace. Filed while a request is approved, it is a CHANGE request (`supersedes` set): it carries the whole new set of kinds and domains, is reviewed the same way, and on approval updates the same WorkOS organization rather than creating another; the approved request stays in force until then, and a rejected or cancelled change leaves it as it was. Consumer mail domains (gmail.com and the like) are refused. # Update a workspace SSO configuration (not available) Source: https://docs.hookie.ai/api-reference/sso/update-a-workspace-sso-configuration-not-available /openapi.json patch /admin/api/sso/{sso_config_id} Single sign-on is not available yet (SEC-13a, #257): there is no SAML or OIDC sign-in, so this always answers `501` with `reason: "sso_not_available"` and changes nothing. It answers before the plan, the role or the body is looked at. A configuration saved before then is kept as it was; list it with listSsoConfigs. # Traffic and delivery stats for one project Source: https://docs.hookie.ai/api-reference/stats/traffic-and-delivery-stats-for-one-project /openapi.json get /admin/api/projects/{project_id}/stats Nested form only - stats has NO top-level Default-project alias (GET /admin/api/stats returns 404 Unknown resource). Readable by any role. # CORS preflight for /health Source: https://docs.hookie.ai/api-reference/status/cors-preflight-for-health /openapi.json options /health Answers 204 without running any check. For an allowed origin (https://hookie.ai, https://www.hookie.ai, https://preview.hookie.ai) it carries Access-Control-Allow-Origin, Access-Control-Allow-Methods: GET, HEAD and Access-Control-Max-Age: 600; any other origin gets no CORS header. # Is the API up? Source: https://docs.hookie.ai/api-reference/status/is-the-api-up? /openapi.json get /health Public and unauthenticated (#323): whether this deployment can answer, from fast checks with no side effects — a trivial D1 read with a 2-second timeout, and whether the outbound queue is bound (nothing is sent). The body carries only `status`, `checks` and `time`: never a version, tenant data, a database error message or a secret. Every answer is `Cache-Control: no-store`. CORS: only the marketing origins (https://hookie.ai, https://www.hookie.ai and the preview site https://preview.hookie.ai) may read it from a browser — the request's Origin is echoed in Access-Control-Allow-Origin for those and no other, with `Vary: Origin` always and never with credentials. Rate limited per client IP (about 20 a minute, a best-effort guard counted separately at each Cloudflare location; fails open if the limiter is unavailable). `health` is a reserved root, so nothing under /health is ever read as /{workspace}/{project}/{webhook} ingest. HEAD returns the same status and headers with no body; OPTIONS answers a CORS preflight with 204. # Is the API up? (headers only) Source: https://docs.hookie.ai/api-reference/status/is-the-api-up?-headers-only /openapi.json head /health Public and unauthenticated (#323): whether this deployment can answer, from fast checks with no side effects — a trivial D1 read with a 2-second timeout, and whether the outbound queue is bound (nothing is sent). The body carries only `status`, `checks` and `time`: never a version, tenant data, a database error message or a secret. Every answer is `Cache-Control: no-store`. CORS: only the marketing origins (https://hookie.ai, https://www.hookie.ai and the preview site https://preview.hookie.ai) may read it from a browser — the request's Origin is echoed in Access-Control-Allow-Origin for those and no other, with `Vary: Origin` always and never with credentials. Rate limited per client IP (about 20 a minute, a best-effort guard counted separately at each Cloudflare location; fails open if the limiter is unavailable). `health` is a reserved root, so nothing under /health is ever read as /{workspace}/{project}/{webhook} ingest. HEAD returns the same status and headers with no body; OPTIONS answers a CORS preflight with 204. # Tail the current workspace's live event stream over SSE or WebSocket Source: https://docs.hookie.ai/api-reference/streaming/tail-the-current-workspaces-live-event-stream-over-sse-or-websocket /openapi.json get /admin/api/stream The same live tail as /v1/stream, for the console's Live mode on Deliveries and Observability (it passes project_id) and a connected agent's tooling (the stdio MCP server's tail_stream) rather than for a server-side integration: it authenticates with the browser session or a connected agent's bearer token instead of an ingest key, and the tenant comes from that credential rather than from the key. GET only — the route resolves before the admin CSRF guard, which only inspects mutating methods. The stream is BEST-EFFORT by contract. Durable delivery is the outbound-delivery queue; a tail is for watching, and the absence of an event here never proves the event was not received. The resume buffer holds 1000 events and 5 minutes, and a resume outside that window succeeds but is preceded by a stream-gap control event. An agent token needs hookie:read. Its 429 is the plan's concurrent-connection cap, counted across SSE responses and WebSockets together and across every client alike — a console tab left in Live mode counts as one, and falls back to refreshing every 10 s when refused. # Tail the ingest key's project live event stream over SSE or WebSocket Source: https://docs.hookie.ai/api-reference/streaming/tail-the-ingest-keys-project-live-event-stream-over-sse-or-websocket /openapi.json get /v1/stream Read-only live tail of every event fanned out in the key's PROJECT. It never exposes stored records or the admin API. An ingest key belongs to one project, and the stream it opens is pinned to that project: it cannot be widened by leaving project_id off, and naming a different project is a 403. Narrow further with dataset. GET only: any other method returns 405 {"error":"Use GET"}. If the workspace is suspended while a connection is open, the connection is closed: a WebSocket first receives {"type":"workspace-suspended","reason":"workspace_suspended","error":"..."} and then closes with code 1008 and reason `workspace_suspended`; an SSE stream receives a `workspace-suspended` event carrying the same object and ends. Reconnecting is refused with 403 until the workspace is reinstated. # List raw submissions Source: https://docs.hookie.ai/api-reference/submissions/list-raw-submissions /openapi.json get /admin/api/projects/{project_id}/submissions Top-level alias: GET /admin/api/submissions. Readable by any role. # Set a submission forward status Source: https://docs.hookie.ai/api-reference/submissions/set-a-submission-forward-status /openapi.json patch /admin/api/projects/{project_id}/submissions/{id} Top-level alias: PATCH /admin/api/submissions/{id}. Write role required. PATCH is the only method on this path: there is no GET-one-submission route, and any other method answers 405 with `Allow: PATCH` (#277). Setting a status by hand clears the reason routing gave the submission (the Errors tab's `reason`, #287). `filtered` is set only by routing. # Create an agentic-AI event trigger Source: https://docs.hookie.ai/api-reference/triggers/create-an-agentic-ai-event-trigger /openapi.json post /admin/api/projects/{project_id}/triggers # Delete an AI trigger Source: https://docs.hookie.ai/api-reference/triggers/delete-an-ai-trigger /openapi.json delete /admin/api/projects/{project_id}/triggers/{trigger_id} # Enable or disable an AI trigger Source: https://docs.hookie.ai/api-reference/triggers/enable-or-disable-an-ai-trigger /openapi.json patch /admin/api/projects/{project_id}/triggers/{trigger_id} # Get one trigger Source: https://docs.hookie.ai/api-reference/triggers/get-one-trigger /openapi.json get /admin/api/projects/{project_id}/triggers/{trigger_id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List agentic-AI event triggers in a project Source: https://docs.hookie.ai/api-reference/triggers/list-agentic-ai-event-triggers-in-a-project /openapi.json get /admin/api/projects/{project_id}/triggers Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Change an endpoint: enabled, name, dataset, IP allowlist, form redirect, CORS origins, URL verification, signature verification Source: https://docs.hookie.ai/api-reference/webhooks/change-an-endpoint:-enabled-name-dataset-ip-allowlist-form-redirect-cors-origins-url-verification-signature-verification /openapi.json patch /admin/api/projects/{project_id}/webhooks/{id} Top-level alias: PATCH /admin/api/webhooks/{id}. Write role required. Send at least one field; only those sent change, and the endpoint keeps its id and its URL. A new dataset applies to events from now on; records already stored keep the dataset they were filed under. The mirror mapping rule the legacy /v1/webhooks/{ingest_key}/{slug} route reads moves with name, dataset and enabled in the same batch. The URL is changed with POST .../rotate; criteria and mappings are not editable here (publish a new version). Since #193 it also sets redirect_url, cors_origins, handshake and handshake_secret (see POST .../webhooks); the audit row records them, and for the secret only that one was set. `verification` sets, changes or removes provider signature verification (#210): omit its secret to keep the one held (same scheme only); a new secret replaces the old at once and ends any rotation overlap; {scheme: "none"} turns it off and deletes the secret. Audited as update_webhook with each change's from and to (for verification: the scheme and secret_changed, never the secret). # Create an endpoint Source: https://docs.hookie.ai/api-reference/webhooks/create-an-endpoint /openapi.json post /admin/api/projects/{project_id}/webhooks Top-level alias: POST /admin/api/webhooks. Write role required. The cap is checked before validation, so a full project returns 402 even for an invalid body. `verification` optionally makes the endpoint check its provider's signature (#210); the create response echoes the scheme, never the secret. # Delete a webhook version Source: https://docs.hookie.ai/api-reference/webhooks/delete-a-webhook-version /openapi.json delete /admin/api/projects/{project_id}/webhooks/{id} Top-level alias: DELETE /admin/api/webhooks/{id}. Write role required. # Get one webhook Source: https://docs.hookie.ai/api-reference/webhooks/get-one-webhook /openapi.json get /admin/api/projects/{project_id}/webhooks/{id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # Give an endpoint a new public URL, with an overlap Source: https://docs.hookie.ai/api-reference/webhooks/give-an-endpoint-a-new-public-url-with-an-overlap /openapi.json post /admin/api/projects/{project_id}/webhooks/{id}/rotate Top-level alias: POST /admin/api/webhooks/{id}/rotate. Write role required. Mints a new high-entropy slug for the SAME endpoint: its id, dataset, settings and history are unchanged. The old URL keeps being accepted for overlap_hours (default 24), then answers 404. Rotating again inside an overlap ends the older URL at once - one previous URL is kept at a time. Audited as rotate_webhook_url (overlap and deadline only, never either slug). # List endpoints Source: https://docs.hookie.ai/api-reference/webhooks/list-endpoints /openapi.json get /admin/api/projects/{project_id}/webhooks Top-level alias: GET /admin/api/webhooks. Readable by any role. Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # One endpoint's received and refused requests Source: https://docs.hookie.ai/api-reference/webhooks/one-endpoints-received-and-refused-requests /openapi.json get /admin/api/projects/{project_id}/webhooks/{id}/activity OBS-3 (#193). Top-level alias: GET /admin/api/webhooks/{id}/activity. Any role. Newest first over the last 7 days: the endpoint's submissions (kind received) and its rows in the rejection log (kind rejected). The rejection log records every request refused after the endpoint (or ingest key) resolved - IP allowlist, signature, monthly quota, burst rate limit, disabled endpoint, 413, 415, unparseable or empty body, loop, failed URL verification, disallowed CORS origin, suspended workspace - with its reason, status, source IP, method and content type. A request to an unknown URL or key is not logged: there is no workspace to log it against. One row per credential, reason and UTC minute, with repeats counted; pruned on the plan's retention window and deleted with the project. Refusals never count against the event quota. `summary` covers the last 24 hours. # Publish a new version of a webhook Source: https://docs.hookie.ai/api-reference/webhooks/publish-a-new-version-of-a-webhook /openapi.json post /admin/api/projects/{project_id}/webhooks/{id}/versions Top-level alias: POST /admin/api/webhooks/{id}/versions. Write role required. The new version carries the source version's signature verification (scheme, settings and secret). # Replace an endpoint's provider signing secret, with an overlap Source: https://docs.hookie.ai/api-reference/webhooks/replace-an-endpoints-provider-signing-secret-with-an-overlap /openapi.json post /admin/api/projects/{project_id}/webhooks/{id}/rotate-verification-secret Top-level alias: POST /admin/api/webhooks/{id}/rotate-verification-secret. Write role required. For when the provider rolls its signing secret (#210). `secret` is the provider's NEW secret; the one it replaces keeps verifying until overlap_hours from now (default 24; 0 retires it at once), so events signed either way are accepted while the provider switches. Rotating again inside an overlap ends the older secret at once. Neither secret is returned or audited; audited as rotate_webhook_verification_secret with the scheme, overlap and deadline. # Create a WebSocket trigger Source: https://docs.hookie.ai/api-reference/websocket-triggers/create-a-websocket-trigger /openapi.json post /admin/api/projects/{project_id}/ws-triggers # Delete a WebSocket trigger Source: https://docs.hookie.ai/api-reference/websocket-triggers/delete-a-websocket-trigger /openapi.json delete /admin/api/projects/{project_id}/ws-triggers/{ws_trigger_id} # Edit, pause or resume a WebSocket trigger Source: https://docs.hookie.ai/api-reference/websocket-triggers/edit-pause-or-resume-a-websocket-trigger /openapi.json patch /admin/api/projects/{project_id}/ws-triggers/{ws_trigger_id} Change any of name, enabled, url, headers, subprotocols, connect_message, conditions and dataset (#177); each is checked as create checks it, conditions in the one dialect (#165). A change to the connection, the conditions or the dataset reconnects the listener, which reads them when it connects; a rename alone leaves the socket open. enabled false closes the socket and true opens it. The connection settings stay encrypted and are never returned. Moving url to a different host is refused unless the request also sends each stored headers / connect_message (null for none): credentials nobody can read back are never carried to a new host unseen. # Get one ws trigger Source: https://docs.hookie.ai/api-reference/websocket-triggers/get-one-ws-trigger /openapi.json get /admin/api/projects/{project_id}/ws-triggers/{ws_trigger_id} The object, as one row of the list returns it, or 404. This used to answer 200 with the whole list for any id (DX-6, #201). Readable by any role. # List WebSocket triggers in a project Source: https://docs.hookie.ai/api-reference/websocket-triggers/list-websocket-triggers-in-a-project /openapi.json get /admin/api/projects/{project_id}/ws-triggers Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Probe a WebSocket trigger's connection Source: https://docs.hookie.ai/api-reference/websocket-triggers/probe-a-websocket-triggers-connection /openapi.json post /admin/api/projects/{project_id}/ws-triggers/{ws_trigger_id}/test # Cancel a workflow instance Source: https://docs.hookie.ai/api-reference/workflows/cancel-a-workflow-instance /openapi.json post /admin/api/projects/{project_id}/workflow-instances/{instance_id}/cancel Cloudflare engine: terminates the Cloudflare Workflows instance, then marks the row cancelled and clears its wait. Legacy engine: marks it cancelled and removes its waiters, so the sweep never resumes it. Audited as cancel_workflow_instance. # Change part of a workflow (merge) Source: https://docs.hookie.ai/api-reference/workflows/change-part-of-a-workflow-merge /openapi.json patch /admin/api/projects/{project_id}/workflows/{workflow_id} Like PUT, but what is not sent keeps its stored value: {"name": ...} renames it and {"active": false} stops it starting new runs, without resending the steps. The merged definition is validated as on create. The version goes up only when the steps, entry dataset or entry conditions change. # Create a workflow definition Source: https://docs.hookie.ai/api-reference/workflows/create-a-workflow-definition /openapi.json post /admin/api/projects/{project_id}/workflows # Deactivate, or permanently delete, a workflow Source: https://docs.hookie.ai/api-reference/workflows/deactivate-or-permanently-delete-a-workflow /openapi.json delete /admin/api/projects/{project_id}/workflows/{workflow_id} By default the workflow is DEACTIVATED: `active` is set to 0, the definition and all its run history are kept, it stays in listWorkflows, and PATCH {"active": true} turns it back on. Runs already in progress carry on. With ?permanent=true it is deleted for good (#256): the definition (a workflow's version is a number on it, not a separate row) and its whole run history, meaning every run, its step log and any legacy-engine waiter. Nothing else goes: records and deliveries the runs produced, and the AI call log, are kept. A permanent delete is refused (409) while any run has not finished: pending, running, waiting, or paused by a workspace suspension. Cancel those runs first (POST workflow-instances/{id}/cancel). The workflow is deactivated before its history is deleted, so it starts no new runs meanwhile. The history goes in chunks, at most 10,000 runs a call; a longer one answers 202 with `done: false`, and the same DELETE again carries on until the one that removes the workflow answers 200. Each call that deletes something writes a `delete_workflow` audit row (details: permanent, done, name, project_id, runs_deleted, step_events_deleted); a deactivation writes one with `permanent: false`. Scoped to the caller's workspace and this project: a workflow of another project or workspace is not found. # Get a workflow instance with its step events and per-step status Source: https://docs.hookie.ai/api-reference/workflows/get-a-workflow-instance-with-its-step-events-and-per-step-status /openapi.json get /admin/api/projects/{project_id}/workflow-instances/{instance_id} For a Cloudflare Workflows instance (engine `cf`) the API asks Cloudflare for the instance's status (`cf_status`), and a row that missed the end of its run is brought into line with it. `steps` is derived from the step log of the current attempt, one entry per step path, for overlaying run status on the workflow graph. # Get one workflow definition Source: https://docs.hookie.ai/api-reference/workflows/get-one-workflow-definition /openapi.json get /admin/api/projects/{project_id}/workflows/{workflow_id} # List instances of one workflow Source: https://docs.hookie.ai/api-reference/workflows/list-instances-of-one-workflow /openapi.json get /admin/api/projects/{project_id}/workflows/{workflow_id}/instances # List workflow definitions in a project Source: https://docs.hookie.ai/api-reference/workflows/list-workflow-definitions-in-a-project /openapi.json get /admin/api/projects/{project_id}/workflows Pages with ?limit and ?offset and returns total, limit and offset beside the array, which is unchanged (DX-6, #201). # Replace a workflow definition Source: https://docs.hookie.ai/api-reference/workflows/replace-a-workflow-definition /openapi.json put /admin/api/projects/{project_id}/workflows/{workflow_id} The version goes up only when the steps, entry dataset or entry conditions change. # Retry a workflow instance Source: https://docs.hookie.ai/api-reference/workflows/retry-a-workflow-instance /openapi.json post /admin/api/projects/{project_id}/workflow-instances/{instance_id}/retry Cloudflare engine (failed, timed_out or cancelled): restart() runs the instance again from the first step under a new run_attempt, with the context reset to the entry record; records an earlier attempt emitted are not emitted again. Legacy engine (failed or timed_out): continues from the step that failed (a timed-out wait parks again). Audited as retry_workflow_instance. # Change a member's role Source: https://docs.hookie.ai/api-reference/workspace/change-a-members-role /openapi.json patch /admin/api/members/{user_id} Owner or admin, in the console (#308). The owner's role is never changed here, and `owner` is never set: ownership moves with POST /admin/api/members/transfer-ownership. Nobody grants above their own role: only the owner can make someone an admin. Nobody changes their own role. Demoting an admin below admin revokes the pending invite links they created. Audited as `member_role_changed` with from and to. Refused while the workspace is suspended. # Change the role people from the linked domain join with Source: https://docs.hookie.ai/api-reference/workspace/change-the-role-people-from-the-linked-domain-join-with /openapi.json patch /admin/api/workspace/google-domain Owner only, in the console; never through an OAuth-connected agent or an API key. Changes only `default_role` on the link this workspace already holds. It claims no domain, so unlike linking it needs no Google sign-in: an owner signed in with an email code or GitHub can change it. Members who already joined keep their roles. Audited as `google_workspace_updated` when the role actually changes. Refused while the workspace is suspended. # Close the workspace Source: https://docs.hookie.ai/api-reference/workspace/close-the-workspace /openapi.json post /admin/api/workspace/closure Owner only, and never through an OAuth-connected agent. First cancels a live Stripe subscription immediately (if Stripe refuses, nothing changes and the call answers 502); then suspends the workspace (ingest refused, deliveries held, settings read-only — see `workspace_suspended`), drops it to the Free plan, and schedules the permanent deletion of all of its data 14 days later. Undo with DELETE inside that period. After it, every project, record, submission, delivery, run, setting, key and membership is deleted; the audit log is kept for its own retention (at least a year) and the workspace row remains as a tombstone with its name removed. Audited as `workspace_closure_requested`. Allowed while the workspace is suspended. # Create a shareable invite link Source: https://docs.hookie.ai/api-reference/workspace/create-a-shareable-invite-link /openapi.json post /admin/api/invites Owner or admin, in the console (#308). Hookie sends no email: the response carries the link, which is shown this once and never again, and whoever opens it signed in joins the workspace with `role`. The token is 32 random bytes (base64url); only its SHA-256 is stored. The link works once and expires after seven days. Nobody grants above their own role: only the owner can invite an admin. Members per plan, the owner included: Free 1 (the owner only), Pro 3, Team unlimited (billed per seat). At the limit the invite is refused. At most 50 invites may be pending at once. Audited as `member_invited` (never the token). Refused while the workspace is suspended. An `Idempotency-Key` is refused with 400: the response holds a link shown once, which is never stored for replay. # Create an admin API key (shown once) Source: https://docs.hookie.ai/api-reference/workspace/create-an-admin-api-key-shown-once /openapi.json post /admin/api/api-keys A person in the console only: a connected agent or another API key is refused, so one credential cannot mint a lasting foothold. The key never has more access than its creator, is re-clipped to the creator's current role on every request, and stops working if they leave. Does not accept `Idempotency-Key` (400): the response is a secret that is never stored. # Export all of the workspace's data Source: https://docs.hookie.ai/api-reference/workspace/export-all-of-the-workspaces-data /openapi.json get /admin/api/workspace/export Owner or admin. One JSON Lines file (`Content-Disposition: attachment`), streamed as it is read so a workspace of any size downloads in constant memory. Lines, in order: `{"type":"header", "format":"hookie-workspace-export", "version":1, "workspace", "generated_at", "tables", "omitted_columns"}`; then per table `{"type":"row", "table", "row"}` lines and a `{"type":"table_end", "table", "rows"}`; and last `{"type":"end", "complete":true, "counts"}`. A file without the `end` line is incomplete; a read that fails writes `{"type":"error"}` instead. Covers configuration (projects, endpoints, rules, keys, destinations, sources, triggers, workflows, AI agents, portals, Data API, Broadcast Logs, SSO, subscription), members (with email and name), records, submissions, deliveries, holds, AI and workflow runs, AI calls, usage, former workspace slugs, the refused-request log and the audit log. Secrets are never included: signing secrets, destination credentials (`auth_secret`) and secret custom-header values (a secret header is written by name only), endpoint handshake secrets, encrypted source/listener configs and telemetry secrets, key and token hashes, and endpoint URL slugs are omitted, as `omitted_columns` lists. An endpoint's URL wherever a row holds one (a destination's url or alert URL, an endpoint's redirect URL, an audit row's details) is written as `endpoint:`, or `endpoint:removed` when no endpoint of the workspace holds its slug. A workflow run's `context` is written redacted the way the run-detail API shows it (#247): credential-named keys, Bearer/Basic values and URL passwords read `[redacted]`. Audited as `export_workspace`. # Join a workspace with an invite link's token Source: https://docs.hookie.ai/api-reference/workspace/join-a-workspace-with-an-invite-links-token /openapi.json post /admin/api/invites/accept Any signed-in person, in the console (#308); never an agent or an API key. The workspace joined is the one the invite belongs to, never one named in the request. This route is resolved WITHOUT the automatic workspace every other admin route creates for someone who has none, so an invitee joins this workspace without first getting an empty one of their own. The link is used once: accepting creates the membership with the invite's role, marks the invite accepted in the same transaction (two accepts racing for one link make one member), makes the workspace the caller's active one, and is audited as `member_invite_accepted`. A person who is already a member gets 200 with `joined: false` and is switched to the workspace; a pending link they open is not spent. A token that is wrong, expired, revoked or already used answers the same 404 with the same words, and so does an invite whose creator could no longer create it (removed, demoted below admin, or, for an admin invite, no longer the owner): an invite is only as good as its creator's authority when it is accepted. The console calls POST /admin/api/invites/preview first and accepts only when the person clicks Join; opening a link never joins by itself. The deployment's email-domain pin applies. On Team, the subscription's seat count follows (best-effort). Members per plan, the owner included: Free 1 (the owner only), Pro 3, Team unlimited (billed per seat). # Link the owner's Google Workspace domain Source: https://docs.hookie.ai/api-reference/workspace/link-the-owners-google-workspace-domain /openapi.json put /admin/api/workspace/google-domain Owner only, in the console, from a Google sign-in whose Google Workspace domain Google verified; never through an OAuth-connected agent or an API key. The domain linked is ALWAYS the session's, never one chosen by the request: `domain` in the body is only a check, refused with 409 `google_workspace_domain_mismatch` when it differs. Linking another domain replaces this workspace's link (members who joined stay); linking the same one again changes only `default_role` (PATCH changes the role without a Google sign-in). Audited as `google_workspace_linked` (or `google_workspace_updated`). Refused while the workspace is suspended. Google Workspace teams (#326). A workspace can be linked to one Google Workspace domain, and a domain to one workspace. Anyone who then signs in to the console with Google using an account in that domain joins the workspace automatically with the link's `default_role`, before Hookie would create an empty workspace for them, and, when it is their first workspace, it becomes their active one (someone already in other workspaces joins this one as well but is not moved: it appears in their workspace switcher). No WorkOS organization is created: the grouping is Hookie's. Linking is first come, first served among the domain's own Google accounts; a platform operator can unlink a domain or reassign it to another workspace, audited in both workspaces' logs. The domain is Google's `hd` claim, read once at sign-in from Google's OpenID Connect userinfo endpoint with the Google access token WorkOS returns, and sealed into an HttpOnly cookie bound to that session. It is never taken from the email address: a personal Google account on a company address, an email-code sign-in and a GitHub sign-in never join, and gmail.com / googlemail.com can never be linked. A join respects the plan's member limit (at the limit the person is not added, and GET /admin/api/me carries `googleWorkspaceNotice`, which names only their own domain, never the workspace or its headcount), never happens for a suspended or closing workspace, and never re-adds someone who left or was removed, including before this feature shipped (an invite still can). Joins are audited as `member_joined_google_workspace`, and on Team the subscription's seat count follows. Requires the WorkOS environment's Google provider to return OAuth tokens; without that no domain is ever verified, so nothing links and nobody joins. # List the workspace's admin API keys Source: https://docs.hookie.ai/api-reference/workspace/list-the-workspaces-admin-api-keys /openapi.json get /admin/api/api-keys # List the workspace's members Source: https://docs.hookie.ai/api-reference/workspace/list-the-workspaces-members /openapi.json get /admin/api/members Any role, and an agent or admin API key at `hookie:read` (MCP: `list_members`) (#308). Owner first, then by when each joined. Members per plan, the owner included: Free 1 (the owner only), Pro 3, Team unlimited (billed per seat). # List the workspace's pending invites Source: https://docs.hookie.ai/api-reference/workspace/list-the-workspaces-pending-invites /openapi.json get /admin/api/invites Owner or admin, in the console (#308). Pending only: not yet accepted, revoked or expired. Never a token or its hash: a link is shown once, when it is created. # List the workspaces the caller belongs to Source: https://docs.hookie.ai/api-reference/workspace/list-the-workspaces-the-caller-belongs-to /openapi.json get /admin/api/workspaces The console's workspace switcher (#308): every workspace the signed-in person is a member of, with their role there. A person's own list, in the console only: not an agent or an API key. # Remove a member, or leave the workspace Source: https://docs.hookie.ai/api-reference/workspace/remove-a-member-or-leave-the-workspace /openapi.json delete /admin/api/members/{user_id} An owner or admin removes a member; any member removes themselves (leaves) (#308). The owner can neither leave nor be removed: they transfer ownership first. The removed member's admin API keys for this workspace stop working at once (a key acts as its creator, who must still be a member), the pending invite links they created are revoked (so a link they kept cannot bring them, or anyone else, back in), and if this was their active workspace their next sign-in lands in another. Audited as `member_removed` or `member_left`. On Team, the subscription's seat count follows (best-effort). Allowed while the workspace is suspended. # Rename the workspace or change its slug Source: https://docs.hookie.ai/api-reference/workspace/rename-the-workspace-or-change-its-slug /openapi.json patch /admin/api/workspace The name needs the manage capability (owner or admin). The slug needs the OWNER: it is part of every public endpoint URL, so a connected agent, which is clipped to admin at most, can never change it. The slug given up becomes an alias that path-form ingest keeps accepting, and taking back one of the workspace's own former slugs makes it current again. Every change writes an `update_workspace` audit row with from and to. # Revoke a pending invite Source: https://docs.hookie.ai/api-reference/workspace/revoke-a-pending-invite /openapi.json delete /admin/api/invites/{invite_id} Owner or admin, in the console (#308). The link stops working at once. Audited as `member_invite_revoked`. Allowed while the workspace is suspended. # Revoke an admin API key Source: https://docs.hookie.ai/api-reference/workspace/revoke-an-admin-api-key /openapi.json delete /admin/api/api-keys/{key_id} Effective on the key's next request. Allowed while the workspace is suspended. # See what a claim token would take over Source: https://docs.hookie.ai/api-reference/workspace/see-what-a-claim-token-would-take-over /openapi.json post /admin/api/agent-accounts/claim-preview Read-only: the agent account and workspace the token claims (#331). Nothing is written. Any signed-in person, in the console; never an OAuth agent or an API key (403 `agent_not_permitted`). Resolved WITHOUT the automatic workspace every other admin route creates for someone who has none, as accepting an invite is. A token that is wrong or already used answers 404 with the same words. # See what an invite link's token would join, without joining Source: https://docs.hookie.ai/api-reference/workspace/see-what-an-invite-links-token-would-join-without-joining /openapi.json post /admin/api/invites/preview Any signed-in person, in the console (#308); never an agent or an API key. Read-only: nothing is joined, spent or written, and like accept it is resolved WITHOUT the automatic workspace every other admin route creates for someone who has none. The console shows this (the workspace, who sent the invite, the role) and joins only when the person clicks Join, so opening a link from anywhere never moves them into a workspace by itself. It is a POST so the token travels in the body, not the URL. Every token that accept would refuse as not found (wrong, expired, revoked, used by someone else, or whose creator could no longer create it) answers the same 404 with the same words. Whether the workspace is full or suspended is decided when joining. # Switch the caller's active workspace Source: https://docs.hookie.ai/api-reference/workspace/switch-the-callers-active-workspace /openapi.json post /admin/api/workspaces/active Sets the workspace every following console request is answered in (#308). The caller must be a member of `tenant_id`: it is checked against their own memberships, and a workspace they do not belong to answers the same 404 as one that does not exist. Cookie sessions only. Allowed while the current workspace is suspended. # Take ownership of a workspace an AI agent registered Source: https://docs.hookie.ai/api-reference/workspace/take-ownership-of-a-workspace-an-ai-agent-registered /openapi.json post /admin/api/agent-accounts/claim The person becomes the workspace's owner and it becomes their active workspace (#331). The agent identity's membership ends and the agent's API keys are re-pointed at the person, so the agent's key keeps working at its admin role, now acting for them (revoke it in Settings → API keys to cut the agent off). The Free plan's one-member limit holds. From then on the workspace is an ordinary one: billing, SSO requests, invites and the rest are the owner's. The token works once (two claims racing for it make one owner) and is audited as `agent_account_claimed`. A workspace an operator suspended answers 403 `workspace_suspended`. Any signed-in person, in the console; never an OAuth agent or an API key (403 `agent_not_permitted`). Resolved WITHOUT the automatic workspace every other admin route creates for someone who has none, as accepting an invite is. A token that is wrong or already used answers 404 with the same words. # The pending workspace closure, if any Source: https://docs.hookie.ai/api-reference/workspace/the-pending-workspace-closure-if-any /openapi.json get /admin/api/workspace/closure Any member. `closure` is null unless the owner has closed the workspace and the grace period is running. The same object is on GET /admin/api/me as `workspaceClosure`. # The workspace's Google Workspace domain link Source: https://docs.hookie.ai/api-reference/workspace/the-workspaces-google-workspace-domain-link /openapi.json get /admin/api/workspace/google-domain Any member, an agent or API key included. Google Workspace teams (#326). A workspace can be linked to one Google Workspace domain, and a domain to one workspace. Anyone who then signs in to the console with Google using an account in that domain joins the workspace automatically with the link's `default_role`, before Hookie would create an empty workspace for them, and, when it is their first workspace, it becomes their active one (someone already in other workspaces joins this one as well but is not moved: it appears in their workspace switcher). No WorkOS organization is created: the grouping is Hookie's. Linking is first come, first served among the domain's own Google accounts; a platform operator can unlink a domain or reassign it to another workspace, audited in both workspaces' logs. The domain is Google's `hd` claim, read once at sign-in from Google's OpenID Connect userinfo endpoint with the Google access token WorkOS returns, and sealed into an HttpOnly cookie bound to that session. It is never taken from the email address: a personal Google account on a company address, an email-code sign-in and a GitHub sign-in never join, and gmail.com / googlemail.com can never be linked. A join respects the plan's member limit (at the limit the person is not added, and GET /admin/api/me carries `googleWorkspaceNotice`, which names only their own domain, never the workspace or its headcount), never happens for a suspended or closing workspace, and never re-adds someone who left or was removed, including before this feature shipped (an invite still can). Joins are audited as `member_joined_google_workspace`, and on Team the subscription's seat count follows. Requires the WorkOS environment's Google provider to return OAuth tokens; without that no domain is ever verified, so nothing links and nobody joins. # The workspace's name, slug and former slugs Source: https://docs.hookie.ai/api-reference/workspace/the-workspaces-name-slug-and-former-slugs /openapi.json get /admin/api/workspace Readable by any member. # Transfer ownership to another member Source: https://docs.hookie.ai/api-reference/workspace/transfer-ownership-to-another-member /openapi.json post /admin/api/members/transfer-ownership The owner only, in the console (#308). The target must already be a member. In one transaction the target becomes the owner and the previous owner becomes an admin, so the workspace always has exactly one owner. Billing and the workspace slug go with it. An admin cannot invite an admin, so the previous owner's pending admin invite links are revoked; their other links stand. Audited as `ownership_transferred`. Refused while the workspace is suspended. # Undo a pending closure Source: https://docs.hookie.ai/api-reference/workspace/undo-a-pending-closure /openapi.json delete /admin/api/workspace/closure Owner only, not through an agent, inside the grace period. Restores the status the workspace had before it was closed (normally active; an operator's suspension is never lifted this way) and re-queues held deliveries. A subscription cancelled by the closure is not restarted. Audited as `workspace_closure_cancelled`. Allowed while the workspace is suspended. # Unlink the Google Workspace domain Source: https://docs.hookie.ai/api-reference/workspace/unlink-the-google-workspace-domain /openapi.json delete /admin/api/workspace/google-domain Owner only, in the console; never through an agent or API key. Stops new joins; everyone who already joined stays a member. Audited as `google_workspace_unlinked`. Allowed while the workspace is suspended, since it only takes access away. # Billing Source: https://docs.hookie.ai/billing Plans, the sign-up trial, upgrading and downgrading, and where the owner sees the subscription, payments and invoices. A plan belongs to a workspace, and only the workspace's **owner** manages its billing. Everyone in the workspace can see which plan it is on. Connected agents and API keys can never reach billing, whatever access they were given. Payments go through Stripe's hosted pages: your card details are entered with Stripe, not in Hookie. ## Plans | Plan | Billed | How to get it | | - | - | - | | **Free** | Nothing, and no card needed | Every workspace starts on it. | | **Starter** | \$2.99 a month, a flat price for the workspace | **Upgrade to Starter** on **Settings → Plan & billing**. | | **Pro** | \$9.99 a month, a flat price for the workspace | **Upgrade to Pro** on **Settings → Plan & billing**, or **Add payment method** during a Pro trial. | | **Team** | \$39.99 per member, per month: one seat for each person in the workspace | **Upgrade to Team** on **Settings → Plan & billing**, or **Add payment method** during a Team trial. | For current prices, see the [pricing page](https://hookie.ai/pricing). What each plan includes is in [Limits and plans](/limits-and-plans). Single sign-on (SAML or OIDC) is planned and not available yet, on any plan, so no plan includes it today. ## Plan & billing **Settings → Plan & billing** shows the workspace's **Current plan**, and **Your access** shows your own role. The owner also sees: * the subscription's **Status**: **Active**, **Trialing**, **Payment failed**, **Unpaid**, **Cancels at period end** or **Cancelled**; * when it **Renews**, or, while it is in Stripe's trial, the date of its **First charge**; once it has been cancelled, when it **Ends**; * on Team, the number of **Seats**, which follows the number of members (see [Team seats](#team-seats)); * **Invoices**: up to the 12 most recent, with the number, date, amount and status (**Paid**, **Due**, **Open**, **Void** or **Uncollectible**), and **View** and **PDF** links; * **Manage billing**, which opens Stripe's customer portal in a new tab, already signed in to the workspace's billing account. Change the card, cancel, and download receipts there. These details are read live from Stripe. If Stripe cannot be reached, the tab says **Showing saved billing state** and shows what Hookie last recorded. ## Upgrade Choose **Upgrade to Starter**, **Upgrade to Pro** or **Upgrade to Team**. Stripe's checkout page opens. A Team checkout starts with one seat for each member the workspace has. Once you have paid, you come back to **Plan & billing**, and the plan changes as soon as Stripe confirms the subscription to Hookie, normally within seconds. * The new limits apply at once, to this month too: the events and deliveries already counted this month are measured against the new plan's numbers. * Destinations and database sources that an earlier downgrade paused are resumed, oldest first, as far as the new plan allows. * Deliveries held past the old allowance are not sent on their own. Choose **Send held deliveries** on each project's **Deliveries** tab. A workspace has one subscription at a time. While one is live, a second checkout is refused: change or cancel the plan from **Manage billing**. If a paying Starter or Pro workspace changes to Team there, Hookie sets its seat count to the number of members. ## Team seats Team is billed per seat, and every member of the workspace is one seat, the owner included. You do not set the seat count yourself: it follows the member list. * When someone accepts an invite, is removed or leaves, Hookie sets the subscription's quantity in Stripe to the new number of members. Stripe prorates the change on your next invoice. * Changing someone's role does not change the count. * If Stripe cannot be reached, the membership change still happens, and the audit log records `seat_sync_failed`. The count is corrected on the next membership change, or when Stripe next reports the subscription to Hookie. A successful update is recorded as `seats_updated`. Inviting people is covered in [Team members](/team-members). Pro allows up to 3 members, and Free and Starter only the owner; neither is billed per seat. ## Cancel or downgrade Cancel from **Manage billing**. A subscription set to end with its billing period keeps its plan until then: **Plan & billing** shows **Cancels at period end** and the date it **Ends**, and you can renew it from **Manage billing** before that date. When the subscription ends, the workspace moves to Free. A move to a lower plan, whether from a cancellation, a failed payment or a trial ending, applies the new plan's limits straight away: * Destinations and database sources over the new plan's numbers are paused, newest first, and database source poll intervals are raised to the new minimum. See [When your plan changes](/destinations#when-your-plan-changes). * Nobody is removed from the workspace. A workspace with more members than the new plan allows keeps them all, but cannot invite anyone else until it is under the limit. See [Member limits](/team-members#member-limits). * The monthly quotas keep this month's counts. A workspace that has already used more than the new plan allows has its new events refused, and its deliveries held, until the 1st. * **Retention is the exception.** Your previous plan's retention window still applies for **7 days**. After that, event data older than the new plan's window is deleted. **Home** shows the date. See [Retention after a downgrade](/limits-and-plans#retention-after-a-downgrade). If you need the data, export it from **Settings → Export & closure** before downgrading, or within those 7 days. See [Export all data](/data-deletion#export-all-data). ## When a payment fails Stripe retries the charge, and meanwhile the workspace keeps its plan. **Plan & billing** shows **Payment failed**, or **Unpaid**, with a **Pay … now** link to the open invoice, and the audit log records `billing_payment_failed`. Pay the invoice, or change the card in **Manage billing**. If every retry fails, Stripe cancels the subscription and the workspace moves to Free. ## The sign-up trial When Hookie offers a trial, the sign-up page has a checkbox such as **Start a 14-day Pro trial**; the plan and the length are Hookie's to set. A workspace created with the box ticked runs on that plan until the trial ends, with no card. **Plan & billing** shows how many days are left. When a trial ends without a paid subscription, the workspace moves to Free and the new limits apply as for any downgrade, and **Upgrade to Pro** and **Upgrade to Team** are there as for any Free workspace. A trial is only offered when a workspace is created. ### Adding a payment method during a trial While your workspace is on a free trial, **Settings → Plan & billing** shows **Add payment method**, followed by the plan you are trying and its monthly price. It opens Stripe Checkout for the plan you are trying. **You keep the rest of your trial.** If your trial has more than 48 hours left, adding a card does not charge you. Your subscription starts in a trial that ends at the same moment as your Hookie trial, and your card is first charged then. The note under the button shows the date. The trial is never extended: it ends when it would have ended anyway. **With less than 48 hours left, billing starts today.** Stripe cannot start a trial that ends that soon. Checkout starts the paid subscription immediately, and your trial ends with it. The note under the button says so before you check out. Once the card is added, **Plan & billing** shows your subscription as **Trialing**, with the date of the **First charge**. You are on the paid plan from that moment. Everything the plan includes stays on, and nothing changes when the trial converts. To change your card or cancel before the first charge, use **Manage billing**. If you cancel during the trial, the workspace moves to Free, and subscribing again starts billing that day. As with any checkout, only the owner can complete it. On a Team trial, the checkout starts with one seat for each member, and the seat count follows the members from then on. ## Good to know * **Quotas and billing run on different calendars.** Monthly quotas reset at 00:00 UTC on the 1st, whatever day the subscription renews. * **Closing the workspace** cancels its subscription at once. See [Delete, export and close](/data-deletion#close-the-workspace). * **Only the owner** sees the subscription details and invoices, and can upgrade, add a payment method during a trial, or open **Manage billing**. Ownership, and billing with it, can be [transferred](/team-members#transfer-ownership) to another member. # Broadcast Logs Source: https://docs.hookie.ai/broadcast-logs Forward a project's logs and traces over OTLP to Datadog, Grafana Cloud, an OpenTelemetry Collector, PostHog or Sentry. **Broadcast Logs** sends what happens in a project to the observability tool you already use, as standard [OpenTelemetry](https://opentelemetry.io) logs and traces over **OTLP/HTTP (JSON)**. You can alert on failed deliveries in Datadog, chart AI latency in Grafana, or search every event one endpoint received in Sentry, next to the rest of your stack. A project broadcasts to **one** tool: Datadog, Grafana Cloud, an OpenTelemetry Collector, PostHog or Sentry. ## Set it up In the console, open **Project → Observability** and choose **Broadcast Logs**. You need to be an owner or admin of the workspace; everyone else sees the integration's status but cannot change it. Choose one of the five tools. If the project already broadcasts somewhere, the new tool **replaces** it. The wizard asks you to confirm before it does, because the old tool's settings, credentials and counts are deleted. Each tool asks for exactly what its own OTLP documentation asks for. See [the sections below](#datadog), and follow the docs link in the wizard. Secrets such as API keys and tokens are stored encrypted (AES-256-GCM). Hookie never shows them again, so to change one you type a new value. To keep a secret when you edit other settings, leave its field blank. **Send test event** posts one log record, and one span where the tool takes traces, with the settings you entered. The wizard shows what each endpoint answered. Save when the test is accepted. If it fails, you can still save, and the failure keeps showing on the Observability tab until the tool accepts the data. ## What is sent Every one of these produces a **span** (for `/v1/traces`) and a **log record** (for `/v1/logs`) carrying the same trace and span ids, so you can jump from a log line to its trace: | Event | Span name | Notable attributes | | - | - | - | | An event is received and stored | `hookie.ingest` | `hookie.submission.id`, `hookie.source`, `hookie.records`, `hookie.datasets` | | A record is routed into a dataset | `hookie.route` | `hookie.record.id`, `hookie.dataset` | | A delivery attempt | `hookie.delivery` | `http.response.status_code`, `hookie.delivery.attempt`, `hookie.delivery.max_attempts`, `hookie.delivery.latency_ms`, `server.address` | | A workflow step completes or fails | `hookie.workflow.step` | `hookie.workflow.id`, `hookie.workflow.instance_id`, `hookie.workflow.step_type` | | An AI call | `hookie.ai.call` | `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `hookie.ai.status` | | A trigger fires (cron, WebSocket, AI) | `hookie.trigger.fire` | `hookie.trigger.kind`, `hookie.trigger.id` | All spans for one event share its trace id: the submission id without dashes. The ingest, routing, delivery and AI-trigger spans for an event therefore show up as one trace. The resource carries `service.name = hookie`, your workspace as `service.namespace`, and `hookie.project.id`, `hookie.project.slug` and `hookie.project.name`. **Payloads are not sent.** Hookie forwards what happened to an event, never the event's body. AI calls report the model, token counts, latency and outcome. They never include the prompt, the input or the output, which stay in **Observability → AI calls**. Severity follows the outcome: `INFO` for success, `WARN` for a failure that will be retried (or an event that matched no rule), and `ERROR` for a final failure. ## Delivery, retries and status Forwarding never slows ingest. Spans are queued after the event is stored, and a queue consumer sends them in batches: one request per signal for all the spans of a project in a batch. Usually they arrive within seconds. * **Retries.** A `5xx`, a `404`, `408`, `409`, `425` or `429`, or a network error is retried with backoff at 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours. Only the signal that failed is resent, so logs that were already accepted are not duplicated when traces fail. * **Dropped.** Data the tool refuses outright (for example `401` or `403`) or that fails after the last retry is dropped and counted. * **Redirects are not followed.** A `3xx` answer is reported as an error. Point the integration at the final URL. * **Status.** The Observability tab shows the last success, the last error, how many log records and spans were exported, failed requests, dropped items and the last test. **Disable** pauses forwarding at once, including anything already queued, and keeps the settings. **Remove** deletes the integration. Every create, replace, edit, disable and removal is recorded in the audit log. ## Datadog Datadog's [OTLP intake endpoint](https://docs.datadoghq.com/opentelemetry/setup/otlp_ingest/) takes logs and traces directly, with no Agent or Collector. | Field | Where to find it | | - | - | | **Datadog site** | The site your organization is on: US1 (`datadoghq.com`), US3, US5, EU1 (`datadoghq.eu`), AP1, AP2, UK1, US1-FED or US2-FED. | | **API key** | **Organization settings → API keys**. Use an API key, not an application key. | Hookie posts to `https://otlp./v1/logs` and `https://otlp./v1/traces` with the `dd-api-key` header. It also sends `compute_stats: true` on traces so that Datadog computes trace metrics, which this intake does not do by default. A `403` usually means the site does not match your organization. ## Grafana Cloud Grafana Cloud's [OTLP endpoint](https://grafana.com/docs/grafana-cloud/send-data/otlp/send-data-otlp/) routes logs to Loki and traces to Tempo. | Field | Where to find it | | - | - | | **OTLP endpoint** | Your stack → **OpenTelemetry → Configure**, for example `https://otlp-gateway-prod-us-east-0.grafana.net/otlp`. Hookie appends `/v1/logs` and `/v1/traces`. | | **Instance ID** | The numeric instance ID on the same page. It is the basic-auth username. | | **Access policy token** | Generate one on the same page. It needs the `logs:write` and `traces:write` scopes. | Hookie authenticates with `Authorization: Basic base64(:)`, which is the header the OpenTelemetry tile generates. ## OpenTelemetry Collector Use this for your own [Collector](https://opentelemetry.io/docs/collector/), Grafana Alloy, or any other backend with an OTLP/HTTP receiver. | Field | Notes | | - | - | | **OTLP/HTTP endpoint** | The receiver's base URL over `https`, for example `https://otel.example.com:4318`. Hookie appends `/v1/logs` and `/v1/traces`, as `OTEL_EXPORTER_OTLP_ENDPOINT` does in the [exporter specification](https://opentelemetry.io/docs/specs/otel/protocol/exporter/). | | **Headers** (optional) | A JSON object of extra headers, such as `{"Authorization": "Bearer …"}`. Stored encrypted. | The receiver must accept JSON-encoded OTLP (`Content-Type: application/json`), which the standard `otlp` receiver's HTTP protocol does. ## PostHog [PostHog Logs](https://posthog.com/docs/logs/installation/other) accepts OTLP **logs**. | Field | Where to find it | | - | - | | **PostHog region** | US Cloud (`us.i.posthog.com`) or EU Cloud (`eu.i.posthog.com`), the region in your PostHog URL. | | **Project token** | **Project settings → Project token**. It starts `phc_`. A personal API key (`phx_`) does not work. | Hookie posts to `https://.i.posthog.com/i/v1/logs` with `Authorization: Bearer `. PostHog does not ingest OTLP traces, so a PostHog integration receives **log records only**. Each log record still carries its trace and span ids, so related events stay grouped. ## Sentry Sentry [ingests OpenTelemetry logs and traces over OTLP](https://docs.sentry.io/concepts/otlp/) directly. The feature is in open beta at Sentry. | Field | Where to find it | | - | - | | **DSN** | **Project settings → Client Keys (DSN)**. Its public key authenticates the export. | | **OTLP traces endpoint** (optional) | The same page, for example `https://o123.ingest.us.sentry.io/api/456/integration/otlp/v1/traces`. Leave it blank to derive it from the DSN. It must be for the DSN's project. | | **Headers** (optional) | A JSON object of extra headers. Stored encrypted. | Hookie sends traces to the traces endpoint and logs to the matching `…/v1/logs`, with `x-sentry-auth: sentry sentry_key=`. ## API The integration is managed under `/admin/api/projects/{project_id}/telemetry`. See the [API reference](/api-reference) under **Broadcast Logs**. | Method | Path | Does | | - | - | - | | `GET` | `/telemetry` | The integration (or `null`) and the vendor catalog. Secret values are never returned. `secrets_set` names the secret fields that are set. | | `POST` | `/telemetry` | Create one. If the project already has one, this answers `409` with `code: "integration_exists"` unless the body says `"replace": true`. | | `PATCH` | `/telemetry` | Change settings or secrets (a blank secret keeps its value), or `{"enabled": false}` to disable. | | `DELETE` | `/telemetry` | Remove it. | | `POST` | `/telemetry/test` | Send a test event to a draft (`vendor`, `settings`, `secrets` in the body) or, with an empty body, to the saved integration. | A [connected agent](/connected-agents) with the `hookie:manage` scope reaches the same routes with its OAuth access token: ```bash theme={"dark"} curl -X POST "https://app.hookie.ai/admin/api/projects/$PROJECT/telemetry" \ -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \ -d '{"vendor":"datadog","settings":{"site":"datadoghq.eu"},"secrets":{"api_key":"'"$DD_API_KEY"'"}}' ``` Endpoints must be public `https` URLs: localhost, private and link-local addresses (the cloud metadata address included) and URLs with credentials in them are refused. # Command reference Source: https://docs.hookie.ai/cli/commands Every hookie command, generated from the same tool catalog the CLI builds itself from. Most of this CLI is **generated**. Commands, flags, help text and validation all come from the tool catalog that also defines what a [connected agent](/connected-agents) can do — so the console, your agent and your terminal are one API, and a new capability appears in all three at once rather than in whichever one someone remembered. Which is also why this page is worth trusting: it is produced from that catalog rather than written alongside it. ## Reading it ```bash theme={"dark"} hookie [flags] hookie # means list hookie --help # the group's commands hookie --help # usage, the flags with their types and which are required, and an example hookie help # the same ``` Every command has its own help, the hand-written ones included: `hookie replay --help` shows `--since`, `--failed`, `--destination` and `--limit` with an example, and `hookie login --help` shows `--scope`, `--no-open`, `--name` and `--reregister`. * `*` marks a **required** flag. * ⚠️ marks a command that **asks before acting**. That gate reads the tool catalog, never the word "delete". `hookie workflows delete` only deactivates, and an update undoes it, so it asks only with `--permanent`. `hookie keys revoke` destroys a credential and has no "delete" in its name. * A flag name is the API field in kebab case, so `webhook_id` is `--webhook-id`. Where two sibling routes spell the same field differently — create takes `datasetFilter`, update takes `dataset_filter` — the **flag** is the same on both and the CLI sends whichever spelling that route wants. * **An unknown flag is an error**, with a suggestion. `--datset orders` would otherwise be a successful call that quietly did the wrong thing. Every command also takes the global flags: `--url`, `--project`, `--json`, `--reveal`, `--redact`, `--yes`, `--quiet`. `--project` takes a **slug or an id**, and with neither it uses your workspace's Default project. There is deliberately no `--project-id`: two ways to say the same thing, one of which silently 404s on a slug, is worse than one. ## Hand-written commands These are not single API calls, so they are not generated. [Operations](/cli/operations) covers them in full. | Command | | | - | - | | `hookie login` · `logout` · `whoami` · `status` | Connect this machine, and see what it is connected to. | | `hookie send --endpoint --data @f.json` | Send a test event to an endpoint's public URL. | | `hookie replay ` · `replay --since 1h --failed` | Redeliver one, or a window. | | `hookie open --project x --tab deliveries` | Open the console at a project. | | `hookie apply -f hookie.yml` · `hookie diff -f hookie.yml` | Converge a project on a file, or show what that would change. See [Configuration](/cli/configuration). | | `hookie export [--format yaml\|json] [-o file]` | Write a project's configuration as a file that `apply` and `diff` read. See [Configuration](/cli/configuration#start-from-what-a-project-already-has). | ## Generated commands ### ai-agents Named model configurations a workflow's `agent_call` step runs by id. `update` changes only the flags you pass. | Command | Flags | | | - | - | - | | `hookie ai-agents list` | `—` | This project's AI agents: named model configurations (model, system prompt, memory dataset, reply cap) that a workflow's agent\_call step runs by id. | | `hookie ai-agents get` | `--agent-id*` | One AI agent in full, including its system prompt. | | `hookie ai-agents create` | `--name* --system-prompt --model --memory-dataset --max-tokens --active` | Create an AI agent: a named model configuration that workflows invoke with an agent\_call step (`{type:"agent_call", agent_id}`). | | `hookie ai-agents update` | `--agent-id* --name --system-prompt --model --memory-dataset --max-tokens --active` | Change an AI agent in place. | | `hookie ai-agents delete` | `--agent-id*` | Retire an AI agent: it is set inactive, so a workflow step that names it fails until it is reactivated with update\_ai\_agent active:true. | ### cron-triggers Owner/admin in the console, so a connected agent needs `hookie:manage`. `--payload` is JSON; `{{now}}` in it becomes the fire time. | Command | Flags | | | - | - | - | | `hookie cron-triggers list` | `—` | This project's cron triggers: schedule, time zone, payload template, dataset, and the last and next run. | | `hookie cron-triggers preview` | `--schedule* --timezone` | The next five times a cron schedule would fire, before anything is saved — or the reason it cannot be stored. | | `hookie cron-triggers create` | `--name* --schedule* --timezone --payload --dataset` | Create a cron trigger: on its schedule it emits `payload` as an event, filed into `dataset` if given or routed by the project's rules if not (a run no rule matches is stored whole, as one record, in `default`), and fanned out like any other event. | | `hookie cron-triggers update` | `--cron-trigger-id* --enabled --name --schedule --timezone --payload --dataset` | Change a cron trigger in place: pause or resume it, rename it, or change its schedule, time zone, payload or dataset. With no dataset, a run no rule matches is stored whole, as one record, in `default`. | | `hookie cron-triggers test` | `--cron-trigger-id*` | Fire a cron trigger once, now. | | `hookie cron-triggers delete` ⚠️ | `--cron-trigger-id*` | Delete a cron trigger. | ### datasets | Command | Flags | | | - | - | - | | `hookie datasets list` | `—` | The datasets in a project, with per-dataset record counts. | | `hookie datasets query` | `--dataset* --view --limit --offset` | One page of a dataset's records (25 by default), newest first; `--view` shapes it through a saved view, and `total` says how many there are in all. | ### deliveries | Command | Flags | | | - | - | - | | `hookie deliveries list` | `—` | Recent outbound deliveries for a project — status, response code, destination. | | `hookie deliveries get` | `--delivery-id*` | One delivery in full: its latest response body and every attempt it made. | | `hookie deliveries metrics` | `--window --destination-id` | Delivery health per destination: success rate, p50/p95/p99 latency, failed attempts by class, and a time series. | | `hookie deliveries search` | `--status --destination-id --response-code --min-latency-ms --since --until --limit --offset` | Search deliveries with filters and paging. | | `hookie deliveries replay` | `--delivery-id*` | Re-queue a past delivery to its destination. | ### destinations | Command | Flags | | | - | - | - | | `hookie destinations list` | `—` | A project's outbound destinations. | | `hookie destinations create` | `--name* --type --url --config --dataset-filter --headers --auth --transform --alert-url` | Create an outbound destination. `--type` is `webhook` (the default), which needs `--url`, or `slack` or `s3`, which take `--config` instead and no `--url`, `--headers` or `--auth`. Optionally with custom headers, authentication, a body template and an alert URL (see [Destination types and options](/destinations)). | | `hookie destinations update` | `--destination-id* --name --url --config --enabled --dataset-filter --timeout-seconds --max-per-second --max-concurrency --headers --auth --transform --alert-url` | Rename a destination, move it to a new https URL or change a Slack or S3 destination's `--config` (settings left out keep their stored values), enable or disable it (enabling one Hookie switched off for failing clears its failure streak and sends what it held), change which datasets it receives, set its headers, authentication, body template or alert URL, or set its delivery limits: a request timeout, a maximum send rate and a concurrency cap (`null` restores the default: 10 s, unlimited, unlimited). | | `hookie destinations delete` ⚠️ | `--destination-id*` | Delete a destination. | ### endpoints | Command | Flags | | | - | - | - | | `hookie endpoints list` | `—` | A project's endpoints. | | `hookie endpoints activity` | `--webhook-id* --kind --limit --offset` | One endpoint's received and refused requests over the last 7 days, and why each was refused. | | `hookie endpoints create` | `--name* --slug --dataset --criteria --mappings --enabled --ip-allowlist --verification --redirect-url --cors-origins --handshake --handshake-secret` | Create an endpoint. `--slug` and `--dataset` are optional: the slug is made from the name, and the dataset is the name as an identifier, or `default`. `--verification` turns on [provider signature verification](/endpoint-verification). | | `hookie endpoints update` | `--webhook-id* --enabled --name --dataset --ip-allowlist --redirect-url --cors-origins --handshake --handshake-secret --verification` | Change an endpoint in place: enable or disable it, rename it, re-point its dataset, or set its IP allowlist, form redirect, allowed browser origins, URL verification or provider signature verification. Pass at least one. | | `hookie endpoints rotate` | `--webhook-id* --overlap-hours` | Give an endpoint a new URL; the old one keeps working for the overlap (default 24 hours). | | `hookie endpoints rotate-secret` | `--webhook-id* --secret* --overlap-hours` | Replace the provider signing secret an endpoint verifies with, when the provider rolls it; the old secret keeps verifying for the overlap (default 24 hours, 0 ends it at once). | | `hookie endpoints delete` ⚠️ | `--webhook-id*` | Delete an endpoint. | | `hookie endpoints version` | `--webhook-id* --name* --slug* --dataset* --criteria --mappings --enabled --major` | Publish a new version of an endpoint with different criteria, mappings or dataset, leaving the old version and its URL working so senders can move over. | ### events | Command | Flags | | | - | - | - | | `hookie events search` | `--q --dataset --since --until --limit --offset` | Search stored records across datasets, with a free-text term, a dataset filter, a time window and paging. | ### held-deliveries Deliveries held because the workspace used its monthly delivery allowance. `hookie held_deliveries` still works as an alias. | Command | Flags | | | - | - | - | | `hookie held-deliveries list` | `—` | Events whose deliveries are held because the workspace used its monthly delivery allowance, oldest first, with the allowance used this month. Held deliveries are not lost. | | `hookie held-deliveries release` | `—` | Send held deliveries, oldest first, while the monthly delivery allowance covers them (after the 1st UTC or an upgrade). Stops at the first event it cannot fully cover. | ### keys | Command | Flags | | | - | - | - | | `hookie keys list` | `—` | This project's ingest keys, metadata only — never the full token. | | `hookie keys create` | `--name* --dataset-default --require-signature --expires-at --ip-allowlist` | Create an ingest key. Returns the plaintext once; `--reveal` prints it. `--dataset-default` names the dataset an event sent with no dataset in the URL is stored in when no rule matches it (`default` if left out). | | `hookie keys update` | `--key-id* --name --expires-at --ip-allowlist` | Rename a key, or set or clear its expiry or IP allowlist. | | `hookie keys rotate` | `--key-id* --overlap-hours --expires-at` | Issue a replacement key; the old one keeps working for the overlap (default 24 hours). Returns the new key once; `--reveal` prints it. | | `hookie keys revoke` ⚠️ | `--key-id*` | Revoke an ingest key. | ### members Read-only, at `hookie:read`. Inviting, changing roles and removing people are for a person in the console: see [Team members](/team-members). | Command | Flags | | | - | - | - | | `hookie members list` | `—` | The people in the current workspace: user\_id, email, name, role (owner, admin, developer or viewer) and joined\_at, owner first. Also max\_members, the plan's member limit (null: unlimited). | ### portal-tokens Customer links to the project's portal. `create` returns the token once; `--reveal` prints it. | Command | Flags | | | - | - | - | | `hookie portal-tokens list` | `--portal-id*` | The access links issued for a customer portal: customer\_id, token\_prefix, expires\_at, revoked\_at. | | `hookie portal-tokens create` | `--portal-id* --customer-id* --expires-in-days` | Issue a customer an access link to the portal. | | `hookie portal-tokens revoke` ⚠️ | `--portal-id* --token-id*` | Revoke a customer's portal link. | ### portals The project's one customer portal. `update` creates it the first time. | Command | Flags | | | - | - | - | | `hookie portals get` | `—` | This project's customer portal, or null if it has none: its id, name, the datasets it exposes (event\_types) and whether it is enabled. | | `hookie portals update` | `--name --event-types --enabled` | Create this project's customer portal, or change it: its name, the datasets it exposes, and whether it is enabled. | | `hookie portals delete` | `--portal-id*` | Switch this project's customer portal off: every customer link stops working. | ### project-config The same document `hookie export` writes, as JSON. `import` takes it with `--config @file.json`. | Command | Flags | | | - | - | - | | `hookie project-config export` | `—` | A project's configuration as one document (the hookie.yml shape): endpoints, rules, destinations, AI agents, workflows, AI triggers, cron triggers and record views. Nothing secret is in it. | | `hookie project-config import` | `--config*` | Create in a project what an exported document describes. Each resource is created through its own route with new endpoint URLs and signing secrets; anything that already exists is skipped, never overwritten. | ### projects | Command | Flags | | | - | - | - | | `hookie projects list` | `—` | List the projects in the workspace. | | `hookie projects get` | `—` | A project's details plus its dataset summary. | | `hookie projects create` | `--name* --type` | Create a project. | ### records | Command | Flags | | | - | - | - | | `hookie records get` | `--record-id*` | One record in full, including fields no view selected. | | `hookie records delete` ⚠️ | `--record-id*` | Permanently delete one stored record and every copy of it: its raw submission, its deliveries (replays included) and their response bodies, held deliveries, the workflow runs it started, and AI trigger output derived from it. Cannot be undone; see [Delete, export and close](/data-deletion). | ### rules | Command | Flags | | | - | - | - | | `hookie rules list` | `—` | A project's mapping rules. | | `hookie rules create` | `--name* --dataset* --conditions --mappings --enabled` | Create a mapping rule. Empty conditions match everything; empty mappings pass the payload through. | | `hookie rules update` | `--rule-id* --enabled --name --dataset --conditions --mappings` | Change a mapping rule in place: enable or disable it, rename it, point it at another dataset, or replace its conditions or mappings. Send only what changes. | | `hookie rules delete` ⚠️ | `--rule-id*` | Delete a mapping rule. | ### sources Database sources: Hookie polls a Postgres table and files each new row as a record. `--config` is JSON, `{"url":"postgres://…","table":"public.orders"}` or `@source.json`; it holds the database credentials, is encrypted at rest and is never printed back. `--cursor-column` is required: a column that only grows, such as an id or `created_at`, so each poll takes only new rows. | Command | Flags | | | - | - | - | | `hookie sources list` | `—` | This project's source connectors (polled databases and HTTP endpoints), with their last run and last error. | | `hookie sources create` | `--name* --type --config* --dataset* --cursor-column* --poll-interval` | Connect a database source: Hookie polls a Postgres table and files each new row into `dataset` as a record, which then fans out to destinations, triggers and workflows like any event. | | `hookie sources update` | `--source-id* --enabled*` | Pause or resume a database source. | | `hookie sources test` | `--source-id*` | Check that a database source can connect and read its table, without storing anything. | | `hookie sources delete` ⚠️ | `--source-id*` | Delete a database source. | ### submissions | Command | Flags | | | - | - | - | | `hookie submissions search` | `--q --since --until --limit --offset` | Search raw submissions — the body exactly as it arrived, before any rule reshaped it. | ### triggers AI triggers. | Command | Flags | | | - | - | - | | `hookie triggers list` | `—` | This project's AI triggers — what each one matches and what it does when it fires. | | `hookie triggers create` | `--name* --dataset* --conditions --instructions* --output-dataset --model --max-tokens` | Create an AI trigger: every record that lands in `dataset` and meets `conditions` runs the model with `instructions` over it, and the reply is stored as a record in `output_dataset`. | | `hookie triggers update` | `--trigger-id* --enabled*` | Enable or disable an AI trigger. | | `hookie triggers delete` ⚠️ | `--trigger-id*` | Delete an AI trigger. | ### views | Command | Flags | | | - | - | - | | `hookie views list` | `--dataset` | The saved table views for a project, or for one dataset. | | `hookie views create` | `--dataset* --name* --columns* --sort-key --sort-dir --is-default` | Save a table view for a dataset. A column or sort key can be a dotted path into a nested field, such as `customer.email`; a field literally named that wins. | | `hookie views delete` ⚠️ | `--view-id*` | Delete a saved table view. | ### workflows | Command | Flags | | | - | - | - | | `hookie workflows list` | `—` | Workflow definitions in a project. | | `hookie workflows get` | `--workflow-id*` | One workflow definition in full: entry dataset, entry conditions and every step. | | `hookie workflows runs` | `--workflow-id* --state --since --until --limit --offset` | Instances of one workflow, newest first, 50 per page by default. | | `hookie workflows run` | `--instance-id*` | One instance with its per-step event log — which branch was taken, and where it stopped. | | `hookie workflows create` | `--name* --slug --entry-dataset* --entry-conditions --steps* --active` | Create a multi-step workflow. | | `hookie workflows update` | `--workflow-id* --name* --entry-dataset* --entry-conditions --steps* --active` | **Replace** a workflow's definition. | | `hookie workflows delete` | `--workflow-id* --permanent` | **Deactivate** a workflow; an update with `--active` turns it back on. ⚠️ With `--permanent`, delete it and its run history for good (refused while a run has not finished). | | `hookie workflows cancel` | `--instance-id*` | Cancel a running or waiting workflow instance. A finished one answers `409`. | | `hookie workflows retry` | `--instance-id*` | Retry a failed, timed-out or (Cloudflare engine) cancelled workflow instance. | ### workspace | Command | Flags | | | - | - | - | | `hookie workspace get` | `—` | The current workspace: its slug, id, your role and plan. | ### ws-triggers Owner/admin in the console, so a connected agent needs `hookie:manage`. `--headers` may carry the socket's credential; it is encrypted and never printed back. | Command | Flags | | | - | - | - | | `hookie ws-triggers list` | `—` | This project's WebSocket triggers: name, conditions, mappings, dataset, connection status and last error. | | `hookie ws-triggers create` | `--name* --url* --headers --subprotocols --connect-message --conditions --mappings --dataset` | Create a WebSocket trigger: Hookie holds an outbound connection to `url` and turns each frame that meets `conditions` into an event, filed into `dataset` or routed by the project's rules (a frame no rule matches is stored whole, as one record, in `default`). `--mappings` reshape each frame the way a rule's do; with none, the frame is kept as it arrived. | | `hookie ws-triggers update` | `--ws-trigger-id* --enabled*` | Connect or disconnect a WebSocket trigger. | | `hookie ws-triggers test` | `--ws-trigger-id*` | Open a short-lived connection with a WebSocket trigger's settings and return the first frames, without storing anything. | | `hookie ws-triggers delete` ⚠️ | `--ws-trigger-id*` | Delete a WebSocket trigger. | ## Two that are narrower than they look `hookie workflows update` is a **full replace**, not a patch — which is why `--name`, `--entry-dataset` and `--steps` are all required. Omitting one would drop it. Read the current definition first: ```bash theme={"dark"} hookie workflows get --workflow-id wf_123 --json > wf.json # edit wf.json, then send the whole thing back hookie workflows update --workflow-id wf_123 \ --name "$(jq -r .name wf.json)" \ --entry-dataset "$(jq -r .entry_dataset wf.json)" \ --steps @wf-steps.json ``` `hookie endpoints update` changes an endpoint's name, dataset, enabled state, IP allowlist, form redirect, allowed origins and URL verification, but never its slug, criteria or mappings. The slug is part of the credential in its public URL, and the criteria and mappings define what it has already ingested — changing those in place would quietly reinterpret history. To change them, publish a new version with `hookie endpoints version`. `hookie rules update`, by contrast, changes any field of a rule, conditions and mappings included, and sends only the flags you pass. Try a rule before you save it in the console's rule editor, or with `POST /admin/api/projects/{project_id}/rules/test`. ## JSON flags A flag whose value is a list or an object takes JSON, or `@file.json` to read it from disk — which is how a workflow's steps get past your shell's quoting: ```bash theme={"dark"} hookie rules create --name Orders --dataset orders \ --conditions '[{"path":"type","equals":"order.created"}]' \ --mappings @mappings.json hookie rules update --rule-id r_123 \ --conditions '[{"path":"total","op":"gte","value":100},{"path":"country","op":"in","value":["US","CA"]}]' hookie workflows create --name Scoring --entry-dataset leads --steps @steps.json ``` A Slack or S3 destination's `--config` holds a credential, so read it from a file rather than typing it, where it would land in your shell history: ```bash theme={"dark"} hookie destinations create --name "Orders channel" --type slack --config @slack.json --dataset-filter orders hookie destinations create --name Archive --type s3 --config @s3.json ``` A list of plain strings also takes a comma-separated list, because `'["orders","shipments"]'` is a quoting puzzle with no upside: ```bash theme={"dark"} hookie destinations create --name Warehouse --url https://wh.internal/h --dataset-filter orders,shipments hookie destinations update --destination-id d_1 --dataset-filter null # null means all datasets ``` # Configuration Source: https://docs.hookie.ai/cli/configuration hookie.yml, environment variables, profiles and exit codes. ## Your workspace in a file ```yaml hookie.yml theme={"dark"} project: default endpoints: - name: Checkout slug: checkout dataset: orders enabled: true criteria: [{"path": "type", "equals": "order.created"}] rules: - name: Normalise totals dataset: orders mappings: [{"path": "data.total_cents", "key": "total_cents"}] destinations: - name: Warehouse url: https://warehouse.internal/hook dataset_filter: orders,shipments ``` ```bash theme={"dark"} hookie diff -f hookie.yml # what apply would change (exit 7 if anything) hookie apply -f hookie.yml # do it, after asking hookie apply -f hookie.yml --prune # also delete what the file omits hookie export > hookie.yml # start from what a project already has ``` `project:` in the file wins over `--project`, so a config file is self-describing: you can run it without remembering which project it was for. ## What the file can describe Each kind is matched to what the project has by the field in the **Identity** column. A kind the file does not mention is left alone. | Key | Identity | Changed in place | Reported with `!` | | - | - | - | - | | `ai_agents` | `name` | the whole agent: `active`, `model`, `system_prompt`, `tools`, `memory_dataset`, `max_tokens` | — | | `record_views` | `dataset` + `name` | `columns`, `sort_key`, `sort_dir`, `is_default` | — | | `rules` | `name` | `dataset`, `enabled`, `conditions`, `mappings` | — | | `endpoints` | `slug` | `name`, `dataset`, `enabled`, `ip_allowlist`, `verification` (same scheme, or `none`) | `criteria`, `mappings`: publish a new version of the endpoint; `verification` with another scheme: set it with its secret | | `destinations` | `name` | `name`, `url` (or `endpoint: ` to deliver into one of this project's endpoints), `enabled`, `dataset_filter`, `timeout_seconds`, `max_per_second`, `max_concurrency` | — | | `workflows` | `name` | the whole definition: `slug`, `active`, `entry_dataset`, `entry_conditions`, `steps` | — | | `triggers` (AI) | `name` | `enabled` | `match`, `action_type`, `config`: delete and recreate | | `cron_triggers` | `name` | `enabled`, `schedule`, `timezone`, `payload`, `dataset` | — | A destination's `url` and `name` change in place, so it keeps its id, signing secret and delivery history. An entry under a name the project does not have is taken as a rename of the one destination the file no longer names that has the same URL, and `--prune` leaves it alone. If you change the name and the URL together, that is a create, so rename in one run and move in the next. A file describes **HTTPS webhook** destinations only. A [Slack or S3 destination](/destinations) is its credential, a webhook URL or a secret access key, which `hookie export` leaves out and counts under `not_exported`. A customer-portal destination belongs to that portal's customer. `apply` never compares, updates or prunes any of them: with `--prune`, or when the file names one, the plan lists it with `·` as *not managed by apply*, and `diff` does not count it as drift. Change those in the console or with `hookie destinations update`. `version`, `project` and `not_exported`, which `hookie export` writes, are accepted too. **Any other top-level key is an error** that names it, because `endpoint:` for `endpoints:` would otherwise be a file that "applies" and does nothing. Sources, WebSocket triggers, ingest keys and portals are not part of the file: they hold credentials. An endpoint's [signature verification](/endpoint-verification) is in the file as its scheme and settings (`verification: {"scheme": "stripe", "tolerance_seconds": 600}`), never its secret. `apply` never sends or prints a secret, and it refuses a file that holds `verification.secret`. A new endpoint with a verifying scheme is created without a secret and refuses every request until you set one; `apply` says so. The same scheme with other settings changes in place and keeps the secret it holds. A different verifying scheme is reported with `!`, because it needs that provider's secret: set it with `hookie endpoints update --verification`. ## What apply will and will not do The admin API is not a reconciler, and `apply` does not pretend otherwise. | | | | - | - | | `+` | **Create** — the resource is in the file and not in the project. | | `~` | **Update** — something the API can change in place: the **Changed in place** column above. | | `!` | **Cannot be changed in place** — reported with the reason and the way to change it, and left alone. | | `-` | **Delete** — only with `--prune`, and only for resources the file could have mentioned. | | `·` | **Not managed by apply** — a Slack, S3 or customer-portal destination. Listed, never changed, and not drift. | **Every field the file states is compared**, as canonical JSON. Key order does not matter, `{path, equals: x}` and `{path, op: "equals", value: x}` are the same condition, and a list can be written as a YAML list, inline JSON or a comma string (`dataset_filter: orders,shipments`). A field the file leaves out is not compared, so a file may say less than the server. An endpoint's criteria and mappings define what it has already ingested, so they cannot change in place: a changed `criteria` shows as `!` rather than silently not happening. Converging on what it can and never mentioning the rest would leave drift that no diff ever surfaces again — which is worse than saying so. **Nothing the file omits is deleted** unless you pass `--prune`. A config file describes part of a workspace far more often than all of it, and the failure mode of assuming otherwise is deleting a colleague's endpoint. A resource the file says nothing about is not even read; an explicit `endpoints: []` is how you say "there should be none". `apply` runs in dependency order: AI agents, record views, rules, endpoints, destinations, workflows, AI triggers, then cron triggers, with deletes last, in reverse. A run stops at the first failure rather than half-applying in scattered order. ## Gate CI on drift `hookie diff` **exits 7 when the project does not match the file**: anything to create, update or delete, or a `!`. It exits 0 when they match. Any other non-zero code is a failed run, not drift. It is 7 rather than terraform's 2 because 2 already means a usage error here, and a CI job has to be able to tell drift from a broken command. ```bash theme={"dark"} status=0 hookie diff -f hookie.yml || status=$? case $status in 0) echo "in sync" ;; 7) echo "drift: the project does not match hookie.yml" >&2; exit 1 ;; *) echo "diff failed" >&2; exit "$status" ;; esac ``` `--json` gives the plan as data: each change with its `op`, `kind`, the fields that `changed` and any that are `blocked`, and a `pending` count of the changes that are not `same`: ```bash theme={"dark"} hookie diff -f hookie.yml --json | jq '.pending' ``` ## Start from what a project already has ```bash theme={"dark"} hookie export > hookie.yml # YAML, to stdout hookie export --project staging --format json -o staging.json ``` `hookie export [--project X] [--format yaml|json] [-o file]` writes a project's configuration in exactly the format `apply` and `diff` read, so `hookie export > hookie.yml && hookie diff -f hookie.yml` reports no changes. It is how you put an existing project under version control without retyping it. Nothing secret is in it. Endpoint URLs (they are credentials), signing secrets, ingest keys, and Slack and S3 destinations stay on the server, and the file lists what it left out under `not_exported`. A destination into one of the project's own endpoints is written as `endpoint: ` in place of its URL, which `apply` resolves in the target project. A destination into any other endpoint is left out. Applying an export to a different project creates fresh URLs and signing secrets there. An AI agent is exported with a `ref`, its id in the source project, and `apply` points every workflow `agent_call` that names that ref at the agent of the same name in the target project, creating it first if need be. Endpoint slugs are unique across a workspace, not per project. Applying the same file to a second project in the same workspace still gets a `409` for its endpoints. To copy a project's setup within a workspace, use the console's **Clone project** or import, which name the copies `{slug}-copy`. See [Export, import and clone a project](/api-reference#export-import-and-clone-a-project). ## The YAML it reads Deliberately a **subset**, and it refuses the rest by name and line rather than guessing: | Supported | Refused | | - | - | | Nested maps, `- ` lists, quoted and bare scalars | Anchors and aliases (`&`, `*`) | | `true` / `false` / `null` / numbers | Tags (`!!str`) | | `#` comments, including after a value | Merge keys (`<<:`) | | Inline `[]` and `{}`, parsed as JSON | Block scalars (`\|`, `>`) | | A leading `---` | A second document | A parser that quietly mis-reads an anchor sends the wrong configuration to a live workspace, which is strictly worse than one that says it cannot read it. **`.json` is accepted too**, and is the escape hatch for anything this will not parse. ## Environment | Variable | | | - | - | | `HOOKIE_URL` | Which Hookie. Default `https://app.hookie.ai`. | | `HOOKIE_PROJECT_ID` | Default project, skipping the slug lookup. | | `HOOKIE_TOKEN` | A bearer token instead of the stored connection. | | `HOOKIE_CREDENTIALS` | Where the credential store lives. Default `~/.hookie/credentials.json`. | | `HOOKIE_ALLOW_REMOTE=1` | Use `HOOKIE_TOKEN` with no stored connection (a safeguard, so a stray token is never sent anywhere you did not approve). | | `HOOKIE_CALLBACK_PORTS` | Loopback ports `hookie login` may bind. | ### Profiles There is no profile flag, because `HOOKIE_CREDENTIALS` already is one. Give each Hookie account its own credential store, and sign in to each once: ```bash theme={"dark"} alias hookie-acme='HOOKIE_CREDENTIALS=~/.hookie/acme.json hookie' alias hookie-globex='HOOKIE_CREDENTIALS=~/.hookie/globex.json hookie' hookie-acme login hookie-globex endpoints list ``` For CI, keep the store out of the picture entirely and pass a token: ```bash theme={"dark"} HOOKIE_ALLOW_REMOTE=1 HOOKIE_TOKEN="$HOOKIE_CI_TOKEN" \ hookie diff -f hookie.yml --json ``` ## Exit codes | | | | | - | - | - | | 0 | ok | | | 1 | unexpected | Something the CLI did not anticipate. | | 2 | usage | A bad flag, a missing argument, a file that will not parse. | | 3 | auth | Nothing connected, the connection was revoked, or it is not approved for what the command does. The message then names the scope it needs and links to **Settings → Connected agents**, where it is widened; the next command picks the change up, with no need to log in again. | | 4 | not found | | | 5 | validation | The server rejected the request. | | 6 | network | The request never got a verdict — unreachable, `429`, or `5xx`. | | 7 | drift | `hookie diff` only: the project does not match the file. Not an error; see [Gate CI on drift](#gate-ci-on-drift). | `6` groups the three cases where retrying the identical command is the reasonable next move, so a script needs one test rather than three. ```bash theme={"dark"} if ! hookie deliveries search --status failed --since 1h --json > out.json; then case $? in 3) echo "not connected, or not approved for this — see the message" >&2 ;; 6) echo "transient — retrying" >&2; sleep 30; exec "$0" "$@" ;; *) exit $? ;; esac fi ``` A destructive command on a non-interactive stdin **refuses** rather than assuming yes. A CI job that means it passes `--yes`; one that did not mean to gets an error instead of a deletion. ## Credentials on disk `~/.hookie/credentials.json`, mode `0600`, one entry per origin, written through a temp file and a rename so a crash cannot leave a half-written store. It holds the access token, the refresh token and the OAuth client registration. `hookie logout` drops the tokens and **keeps** the client registration, so reconnecting reuses the same OAuth client instead of adding another row to **Settings → Connected agents**. `hookie logout --all --purge` is not a thing; to remove a registration too, delete the file. The access token is refreshed before it expires, and a refresh shared between concurrent commands is only spent once. If the token endpoint is unreachable you are told that, rather than being told to reconnect — reconnecting needs the same endpoint. # CLI Source: https://docs.hookie.ai/cli/index Manage your Hookie workspace and projects from a terminal, a script or an AI agent. The `hookie` CLI manages your Hookie account and its projects: everything the console and the [MCP tools](/connected-agents) can do, as commands. Use it from a terminal, a script, a CI job, or give it to an AI agent. Every command goes through your account in Hookie; nothing is received on, or forwarded to, the machine it runs on. * `--json` on every command and a real exit code when something fails, so a script or an agent can act on the result. * A `hookie.yml` you can review in a pull request: written from a project with `hookie export`, applied with `hookie apply`, and checked for drift with `hookie diff`. ```bash theme={"dark"} hookie login hookie projects list ``` ## Install Node 20 or newer. The CLI is a single file with no dependencies, served by Hookie itself: ```bash theme={"dark"} npm i -g @hookieai/cli hookie --version ``` Run the same command again to update. ## Sign in ```bash theme={"dark"} hookie login ``` That opens Hookie in your browser, waits for you to approve the connection, and stores the resulting token in `~/.hookie/credentials.json` with mode `0600`. Nothing is pasted anywhere: it is the standard OAuth 2.1 authorization-code flow with PKCE, the same one [connected agents](/connected-agents) use, so `hookie login` and an agent you connect share one credential store. A new connection starts **read-only**. Widen it in **Settings → Connected agents**, where you can also revoke it — immediately, and per connection. A CLI connection appears in that list exactly like an agent, because it is one. Three more commands round that out: | Command | What it does | | - | - | | `hookie whoami` | One request, and the one command that proves the whole chain works — credential, refresh, scopes, workspace. | | `hookie status` | What is stored on this machine. Offline, so it answers with the network down. | | `hookie logout` | Forgets the local token. Add `--all` for every origin. | `hookie logout` keeps the registered OAuth client so the next `hookie login` reuses it instead of adding another row to your agent list. Revoke it on the server too if you mean to cut it off there. ## Which Hookie, which project ```bash theme={"dark"} hookie --project growth endpoints list ``` Commands talk to `https://app.hookie.ai`. `--project` takes a **slug or an ID**; with neither, commands use your workspace's Default project. Until you run `hookie login`, a command stops before sending anything and says `Not connected to https://app.hookie.ai. Run: hookie login`. To use a token instead of a stored connection (in CI, for example), see [Configuration](/cli/configuration#environment). ## What it will and will not print stdout is **data**; stderr is progress, warnings and prompts. So `hookie endpoints list --json | jq` works, and so does redirecting stdout while still watching what happens. | Flag | Effect | | - | - | | `--json` | Machine-readable on stdout. | | `--reveal` | Print secret values. Ingest-key plaintext and destination signing secrets are redacted without it. | | `--redact` | Also hide endpoint URLs, for output that is going somewhere else. | | `--yes` | Do not ask before a destructive change. | | `--quiet` | Nothing on stderr. | An endpoint's public URL **is** a credential — the slug in it is what authenticates the request. It is printed by default anyway, because it is the point of `hookie endpoints create`, and the CLI says so on stderr when it does. Use `--redact` when the output is going into a ticket, a log or a chat. A destructive command asks first. On a non-interactive stdin it **refuses** rather than assuming yes — a CI job that means it passes `--yes`, and one that did not mean to gets an error instead of a deletion. ## Where to go next Send a test event, find what failed, and redeliver it. Every command, generated from the same catalog the CLI builds itself from. hookie.yml, environment variables and exit codes. The same OAuth connection, from your coding agent instead. # Operations Source: https://docs.hookie.ai/cli/operations Add a form, send a test event, find what failed, redeliver it, and open the console at a project, all through your account. These are the commands that are more than one API call. Like everything else in the CLI, each one acts on your account through Hookie, exactly as the console and the MCP tools do. Nothing is received on, or forwarded to, the machine the CLI runs on. ## Send a test event ```bash theme={"dark"} hookie send --endpoint checkout --data @event.json hookie send --endpoint checkout --data '{"type":"order.created"}' jq -n '{type:"order.created"}' | hookie send --endpoint checkout --data @- ``` `send` posts to the endpoint's **public URL**, the same one a provider posts to, so the event takes the same path through ingest, the endpoint's criteria and mappings, and delivery as any other. The answer says where it was routed. `--endpoint` takes an endpoint's readable base slug, its ID, its name, or a full `workspace/project/slug` path. ## Add a form ```bash theme={"dark"} hookie forms init contact --to slack --origin https://example.com hookie forms init waitlist --framework react --json hookie forms test contact-form ``` `forms init` creates the endpoint with the template's fields, a honeypot and the origins you give it, adds the destination you ask for, and writes the form into your project. `forms test` posts a sample submission and reports the record. Templates, flags and the checks Hookie runs are in [Forms](/forms). ## Find what arrived, and what failed ```bash theme={"dark"} hookie events search --dataset orders --since 1h hookie submissions search --q order.created --since 1h hookie deliveries search --status failed,dead --since 24h --json ``` Search reads what your account stored: records, the raw submissions behind them, and deliveries, with a time window and paging. `--since` takes a duration (`30m`, `2h`, `7d`) or a timestamp. ## Replay a bad window ```bash theme={"dark"} hookie replay dlv_01H8… # one delivery hookie replay --since 1h --failed # every failure in the last hour hookie replay --since 2026-09-26T09:00:00Z --until 2026-09-26T11:00:00Z --failed ``` The bulk form uses the server's bulk replay (`POST /admin/api/projects/{project_id}/deliveries/replay`). It asks the server how many deliveries match first and asks you to confirm that number, since every replay sends a real request to a real destination; with no one to ask (a script, an agent) it refuses unless given `--yes`. It then replays in passes of up to 500 until nothing matches or `--limit` (default 100) is reached. **Only what did not arrive, once per event.** `--failed` covers `dead` deliveries and `failed` ones with no retry scheduled; deliveries still being retried automatically are left to their retries. The server replays only the latest delivery of each event to each destination, so an event whose original and replay both failed is sent once, and running the command twice does not send anything twice. Events past your plan's retention are not included. Each replay is a new delivery and counts against the monthly delivery allowance. When the allowance runs out the run stops there, says how many are left, and exits non-zero. ## Open the console at a project ```bash theme={"dark"} hookie open --project growth --tab deliveries ``` Prints the URL and opens it. The print is what makes it work over SSH and on a headless box. # Connected agents Source: https://docs.hookie.ai/connected-agents Connect a coding agent to your Hookie account over OAuth and let it build and run your pipeline for you. Hookie runs a hosted [MCP](https://modelcontextprotocol.io) server. Point an AI coding agent (Claude Code, Cursor, or any MCP client) at one URL and it can do, on your behalf, the things you would otherwise do in the console: create endpoints, write routing rules, point events at a destination, read records back, and replay a failed delivery. Nothing is installed and nothing is pasted anywhere. The agent signs in as you, starts out read-only (unless you signed up through it), and you decide in the console what more it may do. You can cut it off again in one click. Hookie has no chat assistant of its own: you bring your agent. In the console, **Connect an agent** in the top bar (next to the theme switch and your profile) shows your MCP URL, the Claude connector, and the Claude Code command in one place. The [`hookie` CLI](/cli) is the same connection from a terminal instead: the same OAuth flow, the same credential store, and commands generated from the same catalog you see below — so anything an agent can call, you can run by hand. ## Connect one Hookie hosts the MCP server. There is nothing to install; point your agent at: ```http theme={"dark"} https://app.hookie.ai/mcp ``` For Claude Code that is one command; or, in an `.mcp.json` that you can commit to a repo: ```bash Claude Code theme={"dark"} claude mcp add --transport http hookie https://app.hookie.ai/mcp ``` ```json .mcp.json theme={"dark"} { "mcpServers": { "hookie": { "type": "http", "url": "https://app.hookie.ai/mcp" } } } ``` For Claude on the web or desktop, add Hookie as a custom connector: open [Settings → Connectors](https://claude.ai/settings/connectors), choose **Add custom connector**, and paste the URL above. Claude then signs in to Hookie as you, the same way. No credential goes in that file, because there isn't one to put there. The first request your agent makes is refused with a pointer to Hookie's authorization server (WorkOS AuthKit, the same place you sign in). The agent registers itself there, opens a sign-in in your browser, and from then on holds a token it refreshes on its own. That is the standard OAuth 2.1 authorization-code flow with PKCE, the same one you would get from any well-behaved MCP client. You do not need a Hookie account first. If you have never signed in to Hookie, sign up on that same page (email code, Google or GitHub): the first time your agent calls Hookie, your account and a Free workspace are set up for you, exactly as a first console sign-in would. Because that agent is how you joined, it starts able to build (create and change endpoints, rules, destinations and the rest), not read-only; administering the workspace still needs the console. Any agent you connect after that starts read-only as usual. Sign in to the console with the same method to change what each agent may do. For something that runs unattended, such as a CI job or your own backend, with nobody at a browser to sign in, create an [API key](/api-keys) instead. `/mcp` accepts it as a bearer token, and its role decides which tools it can call. For an agent that should have a Hookie account of its **own**, with no person signing in at all, see [Agent self-registration](/agent-self-registration). The agent registers itself on the Free plan and gets an admin API key, and a person can claim the workspace later. ## What it may do Signing in proves *who* the agent is acting for. *What* it may do is decided separately, by you, in **Settings → Connected agents**. A newly connected agent can only read until you grant it more, and the change takes effect on its next call. Access is **one level of three**, and each includes the one before it. Choose it with **Change access** beside the agent; the dialog says what each level allows. | Level | Scope | Lets the agent… | | - | - | - | | Read only | `hookie:read` | Read the workspace: projects, datasets, records, endpoints, rules, destinations, deliveries, triggers, workflows and the member list. | | Read and write | `hookie:write` | Also create, change and delete endpoints, rules, destinations, workflows, sources, AI triggers, AI agents and the customer portal. Equivalent to the developer role. | | Administer | `hookie:manage` | Also projects, cron and WebSocket triggers, and workspace administration: the IP allowlist and the audit export. Equivalent to admin, except that it never reaches invites or member changes. | An agent is never a principal of its own. It acts *as you*, and its authority is your workspace role narrowed to the scopes you granted; whichever is more restrictive wins. An agent granted `hookie:manage` by someone with the viewer role is still a viewer. ### Three things it can never do * **Touch billing.** Subscribing, cancelling, and changing a card are unreachable at every scope. Those stay decisions a person makes. * **Change who is in the workspace.** Inviting someone, changing a role, removing a member and transferring ownership are refused at every scope. An invite link gives a person access, so only a person in **Settings → Members** can create one. An agent can read the member list with `list_members`. See [Team members](/team-members#agents-and-api-keys). * **Destroy data in bulk.** There is no tool to delete a project, purge a dataset or close the workspace. An agent manages routing configuration, which is visible and undoable; bulk, irreversible deletion stays in the console, where it has to be typed out. Two tools can remove something for good, and both are marked destructive so your client asks you first: `delete_record`, which erases a **single** record by id (for an erasure request, or a secret posted by mistake), and `delete_workflow` with `permanent: true`, which deletes one workflow and its run history. Records and deliveries the workflow's runs produced are kept. Every action an agent takes is written to your audit log with **you** as the actor and the agent's client ID recorded alongside, so "who deleted this endpoint" always has an honest answer. ### When an agent is refused A call above the agent's level is refused with what to do about it, not a bare "Insufficient role". The 403 names the scope the call needs and the one the agent holds, and links to **Settings → Connected agents** opened on that agent: ```json theme={"dark"} { "error": "This needs hookie:write, and this agent is approved for hookie:read. Ask your user to widen its access in the Hookie console under Settings → Connected agents (https://app.hookie.ai/#/settings/agents?client=…); it applies on the next request, with no need to reconnect.", "code": "agent_scope_insufficient", "required_scope": "hookie:write", "granted_scopes": ["hookie:read"], "manage_url": "https://app.hookie.ai/#/settings/agents?client=…" } ``` Over MCP the same sentence comes back as the tool's error, so the agent can hand you the link. Widening applies to its very next call, with no reconnect. Every agent is shown the full tool list whatever its scope, so a read-only agent can see the write tools; calling one before it is widened gets this refusal. Two refusals carry no link, because widening would not help: * `code: "insufficient_role"` — **your own role** does not allow the call, and an agent can never do more than you, so widening its access will not help. A workspace owner or admin has to change your role first, in **Settings → Members**. The error names your role and links there: ```json theme={"dark"} { "error": "This needs hookie:manage, and your own role on this workspace (developer) does not allow it. An agent can never do more than its user, so widening this agent's access will not help: a workspace owner or admin has to change your role first, in the Hookie console under Settings → Members (https://app.hookie.ai/#/settings/members).", "code": "insufficient_role", "required_scope": "hookie:manage", "granted_scopes": ["hookie:read", "hookie:write", "hookie:manage"] } ``` * `code: "agent_not_permitted"` — no agent may do this at any level: billing, platform administration, managing connected agents, invites and member changes, switching workspaces, and revealing or rotating a destination's signing secret. ## Revoke **Settings → Connected agents** in the console lists everything you have connected, what each may do, and when it last acted. Revoking takes effect on the agent's next call, even though its token is still valid, because Hookie checks its own grant on every request. Agents that call Hookie with a user-delegated **WorkOS Agent Auth** token are listed here too, as **WorkOS agent**, and follow the same rules: read-only until you widen them, revocable at once. See [WorkOS Agent Auth](/workos-agent-auth). ## Tools Every agent is shown every tool. Each table names the scope a call needs, and a call above the agent's scope is refused as described under [When an agent is refused](#when-an-agent-is-refused). Needs `hookie:read`: | Tool | What it does | | - | - | | `get_workspace` | The workspace's slug, id, your role and plan. The slug is the first segment of every public endpoint URL. | | `list_members` | The people in the workspace: `user_id`, `email`, `name`, `role` and `joined_at`, owner first, and the plan's `max_members` (`null` for unlimited). Read-only: there is no tool to invite, change a role or remove anyone. | | `list_projects` | The workspace's projects. | | `get_project_overview` | A project's details plus its dataset summary. | | `list_datasets` | Datasets in a project, with record counts. | | `query_records` | One page of a dataset's records (optionally through a saved view), with the whole count, for `limit`/`offset` paging. | | `list_record_views` | Saved table views, per dataset: which fields matter, what they are called, how they sort. | | `get_record` | One record in full, including fields a view left out. | | `list_webhooks · list_rules · list_destinations` | A project's routing configuration. Endpoints come with a ready-to-send `public_path`. | | `get_webhook_activity` | One endpoint's received and refused requests over the last 7 days, with the reason each refusal got. The call to make when a provider reports an error. | | `list_sources · list_triggers` | Database sources (never their connection string), and AI triggers. | | `list_cron_triggers` | Cron triggers: schedule, time zone, payload, dataset, last and next run. | | `preview_cron_schedule` | The next five fire times of a schedule, before anything is saved — or why it cannot be stored. | | `list_ws_triggers` | WebSocket triggers and their connection status. Never the URL's headers. | | `list_ai_agents · get_ai_agent` | AI agents — the named model configurations a workflow's `agent_call` step runs. | | `get_portal · list_portal_tokens` | The project's customer portal, and the customer links issued for it (prefix only, never the token). | | `list_ingest_keys` | Ingest keys as metadata only — name, prefix, last used, expiry, status. Never the plaintext. | | `list_deliveries` | Recent outbound deliveries: status, response code, and destination. | | `list_held_deliveries` | Events whose deliveries are held because the monthly delivery allowance is used up, oldest first, with this month's usage. | | `get_delivery` | One delivery in full: the stored response body and every attempt it made. The only call that returns them. | | `get_delivery_metrics` | Delivery health per destination over 24 hours, 7 or 30 days: success rate, p50/p95/p99 latency, failed attempts by class, and a time series. | | `search_deliveries · search_events · search_submissions` | Filtered, paged search with a time window. Use these rather than the `list_` tools for anything but a glance. | | `list_workflows · get_workflow` | Workflow definitions in a project. | | `export_project_config` | A project's configuration as one document: endpoints, rules, destinations, AI agents, workflows, AI triggers, cron triggers and record views. No endpoint URLs, signing secrets or keys. | | `list_workflow_runs · get_workflow_run` | Execution history, including which branch each instance took. | | `send_test_event` | POST a sample payload to a public endpoint URL. | Needs `hookie:write`: | Tool | What it does | | - | - | | `create_webhook · update_webhook · delete_webhook` | Endpoints. Create needs only a `name`: the `slug` is made from it when left out, and the `dataset` defaults to the name as an identifier, or `default`. Update changes enabled, name, dataset, the IP allowlist, the form redirect URL, the allowed browser origins, the URL verification it answers and its [provider signature verification](/endpoint-verification). The tool tells the agent to ask you for the provider's signing secret rather than invent one; it is never returned. | | `rotate_webhook_verification_secret` | Replace the provider signing secret an endpoint verifies with, keeping the old one verifying for `overlap_hours` (default 24; 0 ends it at once). Neither secret is returned. | | `create_webhook_version` | A new version of an endpoint with its own URL, criteria, mappings or dataset; the old version keeps working. `slug` is the endpoint's `base_slug`. Change or retire a version with `update_webhook` / `delete_webhook` on its id. | | `rotate_webhook` | A new URL for an endpoint, with the old one kept for an overlap. The response carries the new URL, which is a credential. | | `create_rule · update_rule · delete_rule` | Mapping rules. Update changes any field — name, enabled, dataset, conditions or mappings — and sends only what changes. Conditions take all ten [operators](/workflows#conditions). | | `import_project_config` | Create in a project what an `export_project_config` document describes, with new endpoint URLs and signing secrets. Anything that already exists is skipped, never overwritten. See [Export, import and clone](/api-reference#export-import-and-clone-a-project). | | `create_record_view · delete_record_view` | Saved table views on a dataset, the same ones people see on the console's Datasets tab. | | `create_destination · update_destination · delete_destination` | Outbound sinks. Create takes a `type`: `webhook` (the default) needs an https `url`; `slack` and `s3` take `config` instead, and no `url`, `headers` or `auth`. Slack's `config` is `{webhook_url}`; S3's is `{endpoint?, region, bucket, prefix?, access_key_id, secret_access_key}` (see [Destination types and options](/destinations)). A `hooks.slack.com` URL is refused as a webhook's `url`. `config.webhook_url` and `config.secret_access_key` are write-only, and the tool tells the agent never to echo them. Update renames a destination or moves it to a new https URL (same id and signing secret; a portal customer's keeps its URL), changes a Slack or S3 destination's `config` (settings and credentials left out keep their stored values; the type cannot change), and changes enabled, the dataset filter and the delivery limits (`timeout_seconds`, `max_per_second`, `max_concurrency`; `null` restores the default). An empty filter is refused; leave it out for every dataset. | | `create_workflow · update_workflow` | A multi-step workflow: entry dataset, entry conditions, and the ordered steps. A multi-step workflow — entry dataset, entry conditions, and the ordered steps. Update is a **full replace**, not a patch: read with `get_workflow`, edit, send it all back. | | `delete_workflow` | By default this **deactivates**: the definition and its run history stay, and `update_workflow` with `active:true` turns it back on. With `permanent: true` it deletes the workflow and its run history for good. It is refused while a run has not finished, and the error names the runs to cancel with `cancel_workflow_run`. | | `cancel_workflow_run · retry_workflow_run` | Stop a running or waiting workflow run, or retry one that failed, timed out or was cancelled. | | `release_held_deliveries` | Send held deliveries, oldest first, while the allowance covers them. Stops at the first event it can't fully cover. | | `replay_delivery` | Re-queue a past delivery to its destination. | | `replay_deliveries` | Replay every delivery that did not arrive, by destination, status and time range, in bounded passes: call again while `more` is true. `dry_run` counts without sending. Charged to the delivery allowance and stops when it runs out. | | `create_ingest_key` | A new ingest key, optionally with an expiry and an IP allowlist. Returns the plaintext once, so treat it as a credential. | | `update_ingest_key` | Rename a key, or set or clear its expiry or IP allowlist. | | `rotate_ingest_key` | Issue a replacement key and keep the old one working for an overlap. Returns the new plaintext once. | | `revoke_ingest_key` | Kill a key. It stops authenticating on the next request; the row and its audit trail survive. Not reversible. | | `create_source · update_source · test_source · delete_source` | Database sources: a Postgres table (`config: {url, table}`) polled into a dataset. The connection string is encrypted and never returned. Update pauses or resumes; test checks the connection without storing anything. Needs the Pro plan. | | `create_trigger · update_trigger · delete_trigger` | AI triggers: records in a dataset that meet the conditions run the model with your `instructions`, and each reply is stored in `output_dataset`. Update toggles enabled. | | `create_ai_agent · update_ai_agent · delete_ai_agent` | AI agents. Update changes only the fields you pass; delete retires the agent (inactive), and update with `active: true` brings it back. | | `update_portal · delete_portal` | The project's customer portal: update creates it the first time, then changes its name, exposed datasets or enabled; delete switches it off, and update with `enabled: true` turns it back on. | | `create_portal_token · revoke_portal_token` | A customer's link to the portal, and cutting it off. Create returns the token **once**, so treat it as a credential. | | `delete_record` | Permanently erase **one** record and every copy Hookie keeps of it: its raw submission, deliveries and their responses, held deliveries, the workflow runs it started and AI output made from it. Returns what was deleted. Not reversible; see [Delete, export and close](/data-deletion). | The hosted server doesn't offer a tool that tails live events, because a tool call should return promptly rather than hold a connection open. To tail events, read the [live stream](/streaming) with an ingest key. Needs `hookie:manage` — the routes the console keeps to owners and admins: | Tool | What it does | | - | - | | `create_project` | A new project. | | `create_cron_trigger · update_cron_trigger · test_cron_trigger · delete_cron_trigger` | Cron triggers: a payload emitted on a schedule (five cron fields, at least 5 minutes apart, in an IANA time zone). Update changes any field; test fires it once, now, as a real event. | | `create_ws_trigger · update_ws_trigger · test_ws_trigger · delete_ws_trigger` | WebSocket triggers: an outbound connection whose matching frames become events, optionally reshaped by `mappings` the way a rule's are. Headers are encrypted and never returned. Update changes any of `name`, `enabled`, `url`, `headers`, `subprotocols`, `connect_message`, `conditions` and `dataset` in place, keeping the trigger's id, status and last error (send `null` to clear headers, subprotocols, connect message or dataset; `mappings` cannot be changed in place). Test returns the first frames without storing anything. | There is no `create_dataset`, because a dataset is not something you create. A dataset is a name records carry: it exists the moment the first record is routed into it, and the console has no way to make an empty one either. Name it in a rule, an endpoint, or a workflow's `entry_dataset` and it appears. Every tool carries MCP annotations, so a client can tell at a glance which calls are safe. `readOnlyHint` is derived from the tool's required scope rather than maintained by hand, so it cannot disagree with what the tool is allowed to do. `destructiveHint` is set where something can be unrecoverable: `delete_webhook`, `delete_rule`, `delete_destination`, `delete_record_view`, `delete_record`, `revoke_ingest_key`, and `delete_workflow`, whose `permanent: true` deletes for good. An annotation covers the whole tool, so a client may ask to confirm a plain deactivation too. It is deliberately **not** set on `replay_delivery` and `replay_deliveries`, which re-send: prompting for confirmation on a reversible action trains people to click through the ones that matter. ## Addressing an endpoint you just created An endpoint's public URL is `{workspace}/{project}/{endpoint-slug}`, and the endpoint slug is a high-entropy value the server mints — not the readable `slug` you passed in, which is stored as `base_slug` and never appears in a URL. You do not have to assemble any of that. `create_webhook` and `list_webhooks` both return `public_path` and `public_url`, and `public_path` is exactly the string `send_test_event` takes: ```json theme={"dark"} { "id": "97249267-…", "base_slug": "orders", "version": "1", "webhook_slug": "aBcXyz0123456789abcdefgh", "public_path": "acme-3f9a/orders/aBcXyz0123456789abcdefgh", "public_url": "https://app.hookie.ai/acme-3f9a/orders/aBcXyz0123456789abcdefgh" } ``` `webhook_slug` **is the credential** — anyone holding it can post events to your workspace, which is why the URL needs no other authentication. So are `public_path` and `public_url`, which contain it. `base_slug` and `version` are the readable identity and are safe to log. If you do need the pieces, `get_workspace` gives the first segment. A wrong path and a disabled endpoint both answer the same 404, so check `enabled` before assuming the URL is wrong. `update_webhook` is narrower than it sounds, deliberately. An endpoint's public URL is its credential, and its criteria and mappings define what it has already ingested; changing those in place would quietly reinterpret history. To change them, publish a new version with `create_webhook_version`, or rotate the URL with `rotate_webhook`. A destination is different: `update_destination` can rename it or move it to a new URL, and it keeps its id and signing secret, so its receiver keeps verifying. ## Bringing your own client The hosted server speaks MCP's Streamable HTTP transport at `https://app.hookie.ai/mcp`: POST JSON-RPC, no session header required. Authentication is standard OAuth 2.1, so any client that speaks it can connect without an MCP library. Start with our protected-resource document, which names the authorization server to register with: ```http theme={"dark"} GET https://app.hookie.ai/.well-known/oauth-protected-resource # RFC 9728 — names the authorization server GET /.well-known/oauth-authorization-server # RFC 8414 — endpoints, PKCE methods POST /oauth2/register # RFC 7591 — dynamic client registration GET /oauth2/authorize # authorization code + PKCE (S256 required) POST /oauth2/token # code → access + refresh token ``` Ask the authorization server only for the scopes it advertises (`openid`, `profile`, `email`, `offline_access`). Hookie's own `hookie:*` scopes are **not** requested there. They are granted by you in the console, and asking WorkOS for them fails the authorization outright. PKCE is required, because agents are public clients that cannot keep a secret and the authorization code travels through a terminal. Registration is open because agents are installed per developer rather than registered by us ahead of time; a freshly registered client can only read until a human grants it more in the console. Hookie is the resource server: it verifies the access token offline against WorkOS's signing keys, then applies the grant you set. The token authenticates the same `/admin/api/*` routes the console itself calls, so there is no second, weaker API to learn. # Customer portal Source: https://docs.hookie.ai/customer-portal Give your customers a self-serve portal to manage the destinations your webhooks go to, verify signatures and inspect and replay deliveries, branded and embedded in your app. The **customer portal** is an embeddable, token-authed surface your *end customers* use to manage their own destinations, the URLs your webhooks are delivered to, and inspect deliveries, so "ship webhooks to our customers" stops generating support tickets. No separate auth backend: you issue an access token per customer, and the destinations a token can see and manage are that customer's own. Each project has exactly **one** customer portal. In the console, open the project's **Settings → Customer portal**, switch it on, give it a name, and pick which of the project's datasets it exposes. Those datasets are the boundary of what the portal reaches: customers' destinations receive them and nothing else. The boundary is checked on every event, against the portal as it is now. Stop exposing a dataset and it stops arriving at every customer destination, including ones created before the change; a delivery already queued for it is cancelled rather than sent. A portal that exposes **no** datasets delivers nothing, and customers cannot add a destination to it. A portal that is switched off delivers nothing and refuses its tokens. Optionally, set your [branding](#branding) and the [sites allowed to embed the portal](#embed-it-in-your-app). In the same pane, under **Access tokens**, issue one per customer using your own ID for them, and optionally a **customer name**. You get a one-time link: ```http theme={"dark"} https://app.hookie.ai/portal/{workspace}/{portal-slug}#token={token} ``` The token (`hpt_…`) is shown once and stored hashed. It sits in the URL **fragment** (after `#`), which a browser never sends to a server, so it stays out of request logs. Tokens issued from the console expire after 30 days unless you change that when issuing; revoke from the same list at any time. Issuing and revoking a token are both recorded in **Settings → Audit log**. With a customer name (up to 80 characters), the portal says **Signed in as **. Without one it names nobody. It never shows your customer ID. Links issued before this change carry `?token=…` instead. They still open the portal. Send the link directly, or [embed the portal](#embed-it-in-your-app) in your own app. On first load the portal removes the token from the address bar and trades it for a **session**: an HttpOnly cookie that lasts 8 hours, or until the token expires if that is sooner. The token is not stored in the browser. Revoking the token ends its sessions at once. **Sign out** clears the session. If the browser blocks the cookie (for example, third-party cookies are blocked in an embedded frame), the portal keeps the token in memory for that page only. ## What your customers can do Everything is scoped to the customer's token: your workspace, the project, the portal and their customer ID. * **See what they will receive.** Each exposed dataset is listed with an **example payload**: the envelope Hookie sends, around the shape of that dataset's latest record with every value replaced (strings become `""`, numbers `0`, booleans `false`). A portal's customers share its datasets, so a real value from another customer's event never appears, only field names. * **Verify signatures.** A guide to checking either signature every delivery carries (see [Verifying deliveries](#verifying-deliveries) below). * **Manage their endpoints.** Add one for chosen event types (HTTPS only, within the exposed datasets), edit its name, URL and event types, pause and resume it, and delete it. The event types must be a non-empty subset of what the portal exposes; an empty list is refused rather than read as "all". * **Manage signing secrets.** Show an endpoint's signing secret again, and **rotate** it. The old secret keeps signing alongside the new one for 24 hours so their receiver can move over, and they can end that overlap early. * **Read their own data in tables you design.** Any [saved view](#share-a-table-with-your-customers) you share appears as a read-only **Your data** table, with a picker when you share more than one, paged 25 rows at a time. They see only the columns you chose and only the rows that belong to them. * **Inspect deliveries.** The delivery log shows the destination, status, response code, error, the next retry and a preview of the first 300 characters of what their endpoint answered. Open a delivery for its full response, endpoint URL and content type. * **Replay a delivery.** A replay is a new delivery, charged to **your** monthly delivery allowance like a replay from the console. It is refused, not held, when the allowance is used up. Every change a customer makes, and every time they reveal a signing secret, is recorded in **Settings → Audit log** with `portal|{customer_id}` as the actor. ## Share a table with your customers A saved view of a dataset (see [Datasets and records](/datasets-and-records)) can be shared in the portal as part of your service: an order history, a list of tickets, a usage report. 1. Open the dataset in the console, then create or edit a view. Pick the columns, their headings and the sort as usual. 2. Tick **Share in the customer portal**. 3. Save. Customers see the table the next time they open the portal. Clear the checkbox to stop sharing; the view stays in your console. A project is one customer's space with one portal, so the customer sees the project's records of that dataset, and only the columns of the view; the record ID and every other field stay private. The table is read-only. The portal must expose the dataset (its event types), or the view stays hidden. Over the API, set `portal_shared: true` when you [create or update a record view](/api-reference) (agents can pass the same field to `create_record_view`). Customers read the tables with `GET /portal/api/views` and `GET /portal/api/views/{id}/records`, which take the same portal token or session as the rest of the portal API. ## Verifying deliveries The portal's **Example payloads** section ends with a verification guide. Every delivery is signed twice with the endpoint's signing secret (`whsec_…`), both over the same timestamp, so your customers can check either signature. ### Option 1: Hookie-Signature The `Hookie-Signature` header looks like `t=,v1=`. Compute an HMAC-SHA256 of `.`, using the whole `whsec_…` secret as the key. Accept the request when any `v1` matches and `t` is recent. During a secret rotation the header carries one `v1` for each live secret, for 24 hours. The portal shows a Node.js snippet, and [Verify signatures](/signatures) has more languages. ### Option 2: Standard Webhooks Every delivery also carries the [Standard Webhooks](https://www.standardwebhooks.com) headers `webhook-id`, `webhook-timestamp` and `webhook-signature`. Rather than writing the check themselves, your customers can use an [official `standardwebhooks` library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries). There are libraries for JavaScript, Python, Go, Ruby, PHP, Java, Rust, C# and more. Pass the `whsec_` signing secret to the library **exactly as shown**. Don't strip the prefix or base64-decode it; the library does both. ```js theme={"dark"} import { Webhook } from "standardwebhooks"; const wh = new Webhook(secret); // the whsec_… secret, as is const event = wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"], "webhook-timestamp": req.headers["webhook-timestamp"], "webhook-signature": req.headers["webhook-signature"], }); ``` * **Signature format.** `webhook-signature` is `v1,` over `..`. During a rotation it carries one space-separated `v1,` value for each live secret. * **Retries.** `webhook-id` is the delivery id, so a retry repeats it. To ignore an event you've already processed, deduplicate on `Hookie-Event-Id`. `GET /portal/api/examples` returns both schemes: `signature` for Hookie-Signature, and `standard_webhooks` for the Standard Webhooks set. ## Branding Under **Settings → Customer portal**, set: * **Logo URL**: an `https` image URL, shown in the portal's header. * **Primary colour**: a `#rrggbb` colour for the portal's buttons and focus ring. Button labels switch between black and white, whichever stays readable on it. Anything else is refused with `400`. There is no free-form CSS. ## Embed it in your app Browsers show the portal inside another site's `iframe` only when that site is on the portal's allowlist. The portal sends `Content-Security-Policy: frame-ancestors` with exactly that list, and `'none'` when the list is empty. Embedding is **off** until you list your site. If you were already embedding the portal before this change, add your site's origin under **Sites allowed to embed the portal**, or browsers will refuse to show the frame. Direct links are unaffected. Under **Settings → Customer portal → Sites allowed to embed the portal**, enter one origin per line, up to 10, such as `https://app.example.com`. Each must be an exact `https` origin: no path and no wildcard. Copy the snippet from **Embed the portal** in the same pane. It looks like this: ```html theme={"dark"} ``` Issue `ACCESS_TOKEN` from your server (`POST /admin/api/projects/{project_id}/portals/{id}/tokens`) for the signed-in customer. Never put a token in page source. The portal posts three messages to the page that embeds it, and only to the origins you allowed: | Message | When | | - | - | | `{ source: "hookie-portal", type: "ready" }` | The portal has loaded. | | `{ source: "hookie-portal", type: "resize", height }` | Its content changed size. Set the frame's height to fit. | | `{ source: "hookie-portal", type: "expired" }` | The session or the token has ended. Load a fresh link. | The messages carry only a type and a height, never a token or event data. The session cookie is `SameSite=None; Partitioned`, so it works inside your frame. ## Offboard a customer Two ways to cut a customer off, both under **Access tokens**: * **Revoke** a token. If it was the customer's last live token on the portal, their destinations are switched off in the same step. * **Offboard customer** revokes every token they hold and switches off every destination they created, at once. It asks you to confirm first, and is recorded in the audit log as `offboard_portal_customer`. Either way they stop receiving events. If you issue them a new token later, their destinations are still off, for them to turn back on. In **Connections**, a customer's destination carries a **Portal customer · ** tag, so it never reads as one of your own. `GET /admin/api/projects/{project_id}/destinations` returns `portal_id`, `customer_id` and `customer_name` for them. ## Remove a portal Switching a portal off is reversible: its tokens stop working and it delivers nothing, but everything comes back when you switch it on. To be done with a portal for good, open **Settings → Customer portal → Remove portal** and type the portal's name to confirm. Removing it: * revokes every access link at once; * cancels the deliveries still waiting to go to its customers' destinations (queued, retrying or paused), and sends those destinations nothing more; * keeps the destinations and their delivery history, and deletes nothing; * is recorded in **Settings → Audit log** as `remove_portal`, with how many tokens, destinations and deliveries it cut off. It cannot be undone. The project can then have a new portal, with a new link. Removing a portal needs the developer role or above, and is refused while the workspace is suspended. From the API it is `DELETE /admin/api/projects/{project_id}/portal` with `{"confirm": ""}`. The name must match exactly, so a stray `DELETE` cannot remove a portal; without it the answer is `400`. A project with no portal, or one already removed, answers `404`. To switch a portal off instead, which you can undo, update it with `enabled: false` (or `hookie portals delete`, which only switches it off). ## Portal API Everything the portal UI does is also available as a small token-authed API: ```http theme={"dark"} # All requests: Authorization: Bearer (or the portal's session cookie) POST /portal/api/session # trade the token for an 8-hour session cookie DELETE /portal/api/session # sign out (clears the cookie) GET /portal/api/me # portal name + branding, customer name, event types GET /portal/api/event-types # datasets the portal exposes GET /portal/api/examples # a redacted example per dataset + both signature schemes GET /portal/api/destinations # the customer's own destinations POST /portal/api/destinations # create one (https URL; signing secret shown once) PATCH /portal/api/destinations/{id} # name, url, dataset_filter and/or enabled DELETE /portal/api/destinations/{id} # delete it GET /portal/api/destinations/{id}/secret # show its signing secret POST /portal/api/destinations/{id}/rotate-secret # rotate, keeping the old secret for 24 hours POST /portal/api/destinations/{id}/expire-previous-secret # end that overlap now GET /portal/api/deliveries # recent deliveries to the customer's destinations GET /portal/api/deliveries/{id} # one delivery in full POST /portal/api/deliveries/{id}/replay # send it again ``` `PUT` is accepted as well as `PATCH` on a destination. The token is not accepted in the query string (`?token=…`): a token there ends up in request logs. Each token can make about 60 calls a minute. That is a best-effort guard, not an exact count: each Cloudflare location counts separately, so a short burst can get through above it. Over it, the API answers `429` with `Retry-After: 60`. Writes made with the session cookie, rather than a bearer token, must send `X-Requested-With: fetch`. | Status | When | | - | - | | `402` | Turning on a destination that is paused because your workspace is over its plan's limit, or adding one past that limit. Only you can upgrade, so the customer is told to contact you. | | `409` | Replaying a delivery that is still being sent, whose destination is paused or deleted, or whose dataset the portal no longer exposes. Adding a destination to a portal that exposes no datasets. | | `429` | Over the token's limit of about 60 calls a minute, or a replay when your monthly delivery allowance is used up (`"reason": "delivery_allowance_exhausted"`). | Why one portal per project: a project is one customer's space. Two portals over the same project would show the same events to two audiences, so Hookie keeps it to one — creating a second is refused. Workspaces that had several portals in one project kept the most recently updated; the others were retired (tokens revoked, their destinations paused, nothing deleted), and each retirement is listed in **Settings → Audit log**. ## Scoping guarantees | Boundary | Guarantee | | - | - | | **Tenant + project** | A project has one portal, and a token is bound to it: the portal only ever reads that project's data. | | **Customer** | A customer only ever sees and manages destinations and deliveries tagged with their own customer\_id. Another customer's gets `404`. | | **Datasets** | Customers' destinations receive only the datasets the portal exposes at the time of each event. Narrowing the portal narrows existing destinations too; a portal exposing none delivers nothing. | | **Examples** | Built from field names only. Every value is replaced, so no customer sees another's data. | | **Transport** | Destination URLs must be HTTPS; each gets its own HMAC signing secret, shown once and then only through the audited reveal. | | **Tokens** | Stored hashed (SHA-256) and looked up by that hash, never recoverable; 30-day expiry by default from the console; revocable at any time, which also ends the token's sessions. Carried in the link's fragment, never the query string. | | **Framing** | Only the sites you list may embed the portal; with none listed, no site can. | Deliveries made to customer destinations behave exactly like every other Hookie delivery: HMAC-signed, retried with backoff, dead-lettered, and [verifiable](/signatures). [Sign in](https://app.hookie.ai/) and open a project's **Settings → Customer portal** to try it. # Data API Source: https://docs.hookie.ai/data-api Read the datasets and columns a project chooses to publish from BI tools, spreadsheets and scripts, with read-only project keys. The **Data API** gives tools outside Hookie (a BI dashboard, a spreadsheet, a nightly script) read-only access to your records. A project decides exactly what leaves: which datasets, and within each dataset which columns. Everything else stays private, and looks, to a key, as if it does not exist. In the console, open the project's **Settings → Data API** (owners and admins). Pick the datasets to publish and, for each one, the columns: top-level fields, dotted paths into nested objects such as `customer.email`, or the record's own `_id`, `_received_at` and `_source`. Nothing is exposed until you choose it. Under **Keys**, create one per tool and name it after the tool. The key (`hdk_…`) is shown **once**, so copy it then; Hookie stores only a hash. Each key belongs to this one project, and shows when it was last used. Send the key as a bearer token. ```bash theme={"dark"} curl -H "Authorization: Bearer $HOOKIE_DATA_KEY" \ "https://app.hookie.ai/v1/data/orders?limit=100" ``` ## Endpoints | Request | Returns | | - | - | | `GET /v1/data` | The datasets this key can read, each with its columns. | | `GET /v1/data/{dataset}` | One page of that dataset, **exposed columns only**, newest first: `{dataset, columns, rows, total, limit, offset}`. | `/v1/data/{dataset}` takes: | Parameter | Meaning | | - | - | | `limit` | Rows per page: 25 by default, up to 1,000. | | `offset` | Rows to skip. Page with `offset += limit` until you pass `total`. | | `since`, `until` | ISO 8601 timestamps bounding when the records were received (`since` inclusive, `until` exclusive). | | `format=csv` | A UTF-8 CSV (with a BOM, so Excel reads it correctly) instead of JSON. The total is in the `X-Total-Count` header. Cells that a spreadsheet would run as a formula are neutralised. | ```json theme={"dark"} { "dataset": "orders", "columns": ["id", "email", "customer.tier", "_received_at"], "rows": [{ "id": 1042, "email": "ada@example.com", "customer.tier": "gold", "_received_at": "2026-09-28T09:17:07.000Z" }], "total": 1284, "limit": 25, "offset": 0 } ``` ## Guarantees | | | | - | - | | **Only what you exposed** | Rows are built from the exposed columns, one by one. A field added to your payloads later does not appear until you expose it. | | **Unexposed looks nonexistent** | A dataset you did not expose answers exactly like one that does not exist: `404`. | | **One project per key** | The workspace and project come only from the key. A key can never read another project, whatever the request says. | | **Matched on the whole key** | A key is looked up by the SHA-256 hash of the whole token, never by its visible prefix, and a revoked key is excluded in the same lookup. Two keys that happen to share a prefix each work, and neither can stand in for the other. | | **Revocable at once** | Revoke a key in Settings and its next request is a `401`. | | **Stops with the workspace** | While the workspace is suspended, every request answers `403` with `"reason": "workspace_suspended"`. | | **Rate-limited** | Each key has the same burst limit as an ingest key: about 100 requests per 10 seconds, a best-effort guard counted separately at each Cloudflare location, so a short burst can get through above it. Over it, `429` with a `Retry-After: 10` header and `retry_after: 10` in the body. Wait that long, then retry. | # Delete, export and close Source: https://docs.hookie.ai/data-deletion Erase one record, purge a dataset, download everything a workspace holds, or close the workspace for good. Hookie keeps event data for your plan's retention window and then deletes it on its own. When you need something gone **sooner** (an erasure request from one of your users, a secret someone posted to the wrong endpoint, a pile of test events) or you want all of your data out, use the tools on this page. Everything here is permanent. The audit log records **that** a record was deleted, a dataset purged or the workspace closed, and by whom, but never what the data said. ## Delete one record Open the record (a project's **Datasets** tab → a dataset → the eye icon on a row), choose **Delete record**, type `delete` and confirm. Developers, admins and owners can do this. Deleting a record also deletes every copy Hookie keeps of it: * its **raw submission**, the body exactly as it arrived. If that submission was routed into several records, the others keep their own data; * its **deliveries**, replays included, with the responses your destinations returned; * deliveries **held** for it because the monthly allowance was used up; * the **workflow runs** it started, with their step log and the AI calls they made. A run that is still going is stopped first; * the output of **AI triggers** that ran on it. A trigger run that had not started yet will not run. Copies already delivered to your destinations are theirs: Hookie cannot recall them. From the API: ```bash theme={"dark"} curl -X DELETE https://app.hookie.ai/admin/api/projects/$PROJECT_ID/records/$RECORD_ID \ -H "Authorization: Bearer $TOKEN" ``` ```json theme={"dark"} { "ok": true, "deleted": { "records": 1, "submissions": 1, "deliveries": 2, "workflow_runs": 1 } } ``` A connected coding agent with `hookie:write` can do the same with the `delete_record` tool. It is marked destructive, so your MCP client asks before running it. ## Purge a dataset On a project's **Datasets** tab, open the menu on a dataset's row, choose **Purge dataset** and type the dataset's name. Owners and admins can do this. A purge deletes every record in the dataset **received up to that moment**, each with the copies listed above. Records that arrive while it runs are new data and are kept. A small dataset is gone before the dialog closes; a large one is deleted in the background, normally within minutes, and the dataset's record count falls as it goes. ```bash theme={"dark"} curl -X POST https://app.hookie.ai/admin/api/projects/$PROJECT_ID/datasets/orders/purge \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"confirm": "orders"}' ``` The answer is `200` with `"status": "done"`, or `202` with `"status": "running"` and `records_remaining`. `GET` the same path to follow it. There is no MCP tool for this: a purge has to be typed out by a person. ## Export all data **Settings → Export & closure → Export all data** downloads one file with everything the workspace holds: projects, endpoints, rules, keys, destinations, sources, triggers, workflows, members and pending invites (never an invite's link), every stored record and raw submission, delivery history, workflow and AI runs, and the audit log. Owners and admins can export. The file is [JSON Lines](https://jsonlines.org): one JSON object per line. ```json theme={"dark"} {"type":"header","format":"hookie-workspace-export","version":1,"workspace":{…},"tables":[…],"omitted_columns":{…}} {"type":"row","table":"projects","row":{…}} {"type":"table_end","table":"projects","rows":3} … {"type":"end","complete":true,"counts":{…}} ``` It is written as it downloads, so a workspace of any size exports without a wait, and your browser keeps downloading in the background. A file **without** the final `end` line is incomplete: download it again. Secrets are never exported, not even encrypted: signing secrets, database and WebSocket credentials, Broadcast Logs keys, key and token hashes, and the secret part of each endpoint URL are left out. The header's `omitted_columns` lists exactly which fields. ```bash theme={"dark"} curl -OJ https://app.hookie.ai/admin/api/workspace/export -H "Authorization: Bearer $TOKEN" ``` ## Close the workspace **Settings → Export & closure → Close workspace**, then type the workspace's slug. Only the owner can close a workspace, and only from the console: a connected agent cannot. Closing does three things: A paid plan is cancelled in Stripe immediately, so nothing more is charged. If Stripe cannot cancel it, nothing else happens and the console says so. Endpoints and ingest keys answer `403` with `reason: "workspace_suspended"`, nothing is delivered, schedules and sources stop, and settings are read-only. Members can still sign in, read everything and export. Every project, record, raw submission, delivery, workflow run, AI call, setting, secret, key and membership is permanently deleted. The audit log is kept for its own retention (at least a year), and the workspace's name is removed. During those 14 days every page shows when the data will be deleted, and the owner can choose **Keep this workspace** to reopen it exactly as it was. A subscription the closure cancelled is not restarted: subscribe again from **Plan & billing**. Closing a workspace does not delete your personal account, because you may belong to other workspaces. To delete it, email [privacy@hookie.ai](mailto:privacy@hookie.ai). The [privacy policy](https://hookie.ai/legal/privacy) describes what is kept and for how long. # Database sources Source: https://docs.hookie.ai/database-sources Poll a PostgreSQL table on a schedule and store each new row as an event, counted once and never skipped because of an error. A **database source** reads a PostgreSQL table on a schedule, for systems that will not call you. Each row that is new since the last poll is stored as an event: one record in the source's dataset, holding the row's columns as its fields. That record fans out like any other: to destinations, the live stream, AI triggers and workflows. Rules do not run on source rows. Sources need the Pro or Team plan. See [Limits and plans](/limits-and-plans#per-plan) for how many a workspace runs and how often each may poll. ## Add a source On a project's **Connections** tab, under **Sources · inbound**, choose **Add source** and fill in: * **Name**. * **Connection URL**. It is stored encrypted and never shown again. Use a read-only login. * **Table**: a table or view, optionally schema-qualified. * **Cursor column**: a column whose values only increase, such as an id. Each poll reads the rows past the last value it saw, in cursor order, up to 200 rows a poll. Changing the cursor column or the table starts again from the first row. * **Dataset**: where each row is stored. * **Poll interval (seconds)**. An interval shorter than your plan allows is saved as the plan's minimum. The list shows each source's cursor column, interval, last poll and status. A source whose last poll failed shows an **error** tag with the error under it, and the error clears after the next successful poll. Owners, admins and developers can add and change sources. Over the API, sources live at `/admin/api/projects/{project_id}/sources`, and `POST …/sources/{source_id}/test` checks that the source can connect and read its table and cursor column, without storing anything. The CLI has the same as [`hookie sources`](/cli/commands#sources). ## Quota and retries Each row Hookie reads from your table is stored whole: the event, its record in the source's dataset and its hand-off to your destinations are saved together, or not at all. * **A row is counted once, when it is stored.** It counts as one event against your monthly event quota at that moment. A row is identified by the source and the value of its cursor column, so a row that is read again is not stored twice and is not counted again. * **A row Hookie cannot store is not counted.** If a poll fails part-way, because of a brief database error or a row too large to store, Hookie keeps the rows it already stored and stops at the row that failed. The source shows the error as its last error, and the next poll starts again from that row, so no row is skipped because of an error. A row that fails every time holds the source at that row, with its error showing, until it can be stored. * **At the quota, the source waits.** Once the workspace reaches its monthly event quota, the poll stops at the first row it cannot count, and the source shows the refusal as its last error. The cursor does not move past rows that were not stored, so they are read once the quota resets or the plan is upgraded. * **Choose a cursor column with unique values.** Two rows with the same cursor value are treated as the same row: the second is skipped and not counted. Choose a column whose values are unique and always increasing, such as an id. With a timestamp, of two rows written in the same instant only one is stored. # Datasets and records Source: https://docs.hookie.ai/datasets-and-records What Hookie keeps for each event: the raw submission, the records routing makes from it, the datasets they are filed in, and how long each is kept. For every event it accepts, Hookie keeps two things: the **submission**, which is the request exactly as it arrived, and the **records** that routing made from it, each filed in a **dataset**. The two are kept apart on purpose. The submission is the evidence that something arrived; a record is what you meant to keep, in the shape you gave it. ## Submissions A submission is the body as it arrived, stored before anything reshapes it, together with the request it came in: the method, the headers, 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]`. Two kinds of body are stored in a normalised form: a multipart form keeps its fields, with each file as a `{filename, type, size}` descriptor and its bytes discarded, and a text body that is not JSON is kept as `{ "body": "…", "content_type": "…" }`. The `201` answer to a request names its submission as `submission_id`. Each submission counts once against your monthly event quota, however many records it makes. A refused request, or a duplicate Hookie recognised, does not count. A submission's routing status tells "nothing matched" apart from "never arrived". Filter by it on the **Observability** tab, under **Submissions**: | Status | Meaning | | - | - | | **Routed** | At least one record was written. | | **Filtered** | The endpoint's own [criteria](/endpoints-and-keys#criteria-and-mappings) turned the event away. It is kept as a submission, counted against the monthly events, and makes no record, so no destination, AI trigger, workflow or live stream receives it. It is listed on the project's [Errors tab](#errors-tab) with the criterion it did not meet. Events filtered before 30 September 2026 still read **Pending**. | | **Pending (no rule matched yet)** | No record has been written yet. An event sent with an ingest key and no dataset does not end up here: when no rule matches it, it is stored in the key's [default dataset](/endpoints-and-keys#ingest-keys). Nor does an event from a cron or WebSocket trigger with no dataset: when no rule matches it, it is stored in the `default` dataset (see [Triggers with no dataset](/workflows#triggers-with-no-dataset)). | | **Routing failed** | Routing hit an error. The submission itself is safe, and Hookie keeps retrying it in the background. This includes a trigger event that no rule matched and that could not be stored in `default` either, such as an empty `{}` payload. | ### Errors tab Some events are received and kept but never reach a dataset. A project's **Errors** tab lists them, newest first. There are two kinds: * **Filtered.** The endpoint's criteria rejected the payload. For example, an endpoint that accepts only `type = order.created` receives an `order.cancelled`. The sender still gets `201`, with `routed: []`, `records: 0` and `filtered: true`. The **Reason** column names the criterion the payload did not meet. * **Routing failed.** The event was stored, but its records could not be written. Hookie keeps retrying it in the background, and it leaves this list once it is routed. Filter the table by kind, and page through 25, 50 or 100 rows. Open a row to see the whole payload, with a link to its trace in Observability. Flagged events follow the plan's retention like any other event. An endpoint's **Activity** (Endpoints → ⋮ → Activity) shows how many requests its criteria filtered in the last 24 hours, next to how many it refused. Over the API, `GET /admin/api/projects/{project_id}/errors?kind=all|filtered|routing_failed&limit=25&offset=0` returns `{ rows, total, limit, offset }`, and `GET /admin/api/projects/{project_id}/errors/{id}` returns one event with its whole payload. A connected agent can use `list_project_errors`, and the CLI has `hookie errors list`. ## Records A record is one routed event in a dataset: a JSON object holding the mapped fields, or the whole payload when nothing mapped it. A payload that is not an object, such as a bare array, is stored as `{ "value": … }`. Every record has an id, the time it was received and where it came from, listed as `_id`, `_received_at` and `_source` beside its fields. One submission can make no record, one, or several, because every rule that matches writes its own. On an ingest key with no dataset in the URL, an event no rule matches still makes one: the key's default dataset gets the whole payload. The same holds for a cron or WebSocket trigger with no dataset, whose unmatched events go to the `default` dataset. Records, not submissions, are what fan out. Each new record is: * delivered to every enabled destination in its project whose dataset filter admits it; * published on the project's live stream; * offered to the project's AI triggers and workflows. A record's id is the `Hookie-Event-Id` on every delivery of it. Records also come from cron and WebSocket triggers, database sources, a workflow's `emit_event` step and an AI trigger's output. They fan out the same way, except that a record a workflow or an AI trigger wrote never starts a workflow, and an AI trigger's output never fires an AI trigger, so neither can loop. Events from cron and WebSocket triggers and database sources count against the monthly event quota like ingested ones. ## Datasets A dataset is a name that records are filed under, such as `orders`. There is no step to create one: it appears on the project's **Datasets** tab the moment the first record is routed into it, whether from an endpoint, an ingest key's path, a rule, a trigger or a workflow. A dataset name starts with a letter and uses only letters, digits and underscores, up to 63 characters. A dataset belongs to its project: an `orders` dataset in two projects is two datasets. The **Datasets** tab lists each dataset with its number of records and when the last one arrived. Open one to page through its records, 25, 50 or 100 at a time, and save views that pick the fields to show, rename them, and set how the records sort. A view can also be [shared with your customers in the portal](/customer-portal#share-a-table-with-your-customers). The eye icon on a row (**Open record**) shows a record whole, with the **Request** it arrived in. **Export CSV** and **Export XLSX** export the whole dataset, not just the page; an XLSX file stops at a spreadsheet's 1,048,575 rows, and a CSV has no limit. Outside the console, read records with: * `GET /admin/api/projects/{project_id}/datasets/{name}`, which pages by cursor (see [Reading a whole dataset](/api-reference#reading-a-whole-dataset)); * `search/events`, for filtered searches with a time window; * the `query_records` and `get_record` tools of a [connected agent](/connected-agents); * the read-only [Data API](/data-api), for tools outside Hookie. ## Retention Event data is kept for your plan's retention window, then deleted automatically: | Plan | Retention | | - | - | | Free | 7 days | | Pro | 30 days | | Team | 90 days | The window covers records, submissions, deliveries and their attempts, held deliveries, the refused-request log, workflow runs, AI trigger runs and the AI call log. A cleanup runs every five minutes and deletes what has aged past the window, counted from when it was received or created. A delivery that is still queued, retrying or paused is kept until it finishes. The audit log has its own, longer window: the longer of your plan's retention and one year. After a downgrade to a plan with a shorter window, your previous plan's window still applies for 7 days. After that, the next cleanup deletes data older than the new plan's window. **Home** shows the date until then. See [Retention after a downgrade](/limits-and-plans#retention-after-a-downgrade). A delivery whose record has been pruned cannot be replayed. To delete data sooner, one record or a whole dataset, or to export everything, see [Delete, export and close](/data-deletion). # Destination types and options Source: https://docs.hookie.ai/destinations Send events to an HTTPS endpoint, a Slack channel or an S3-compatible bucket, shape the body, add headers and authentication, and know when a destination keeps failing. A destination sends every matching event somewhere, retried until it arrives. Each destination has a **type**, chosen when you create it: | Type | What each event becomes | Credential (stored encrypted, never returned) | | - | - | - | | **HTTPS webhook** (`webhook`, the default) | A signed `POST` to your https URL | Optional: a token, password or API key, and secret headers | | **Slack** (`slack`) | A readable message in a Slack channel, through an incoming webhook | The incoming-webhook URL itself | | **S3-compatible bucket** (`s3`) | One JSON object in AWS S3, Cloudflare R2, Google Cloud Storage or MinIO | The secret access key | All three use the same delivery machinery: the retry schedule, the send rate and concurrency limits, the monthly delivery allowance and its holds, replay, the delivery log, and the failure streak that switches a failing destination off. Only the request differs. See [Destinations and deliveries](/destinations-and-deliveries). By default a webhook's body is Hookie's envelope, `{id, dataset, received_at, data}`. A webhook can also carry its own headers, a credential and a body template, so you can send straight to services such as Datadog or an email API without a relay in between. Choose the type under **Type** when you choose **Add destination** on a project's **Connections** tab. A destination's type is fixed once it exists: to change it, create a new destination of the type you want. Set the rest on the destination's tile under **Request options**, or when you create it. **From preset** fills them in for Slack, Datadog and email. ## Slack A Slack destination posts each event to a channel through a Slack **incoming webhook**. If the **Connections** tab shows **Add to Slack**, choose it and pick a channel on Slack's screen: Hookie creates the destination for you, with no URL to copy. See [Add to Slack](/slack-app#add-to-slack). Otherwise: 1. Install the [Hookie Slack app](/slack-app) to the channel, and copy its **Incoming Webhooks** URL. It starts `https://hooks.slack.com/services/`. Anyone who holds the URL can post to the channel, so treat it like a password. Any Slack app with an incoming webhook works too. 2. In the console, open the project's **Connections** tab. Choose **Add destination**, then **Slack** under **Type**. Or choose **From preset**, then **Slack (Incoming Webhook)**. 3. Paste the URL into **Slack incoming-webhook URL**, and give the destination a name. 4. Optional: set a **Dataset filter**, so only the datasets you want in the channel are sent. 5. Choose **Create**. Only a `https://hooks.slack.com/` URL is accepted (`/services/…`, `/workflows/…` or `/triggers/…`). Hookie stores it encrypted and only ever shows a masked hint, such as `https://hooks.slack.com/services/••••abcd`, on the tile, in the API and in the delivery log. It is never exported. To replace it, choose **Edit…** on the tile and paste the new URL; leave the field blank to keep the saved one. ### The message Each event becomes an alert card: * a title from the payload, such as its `title` or `summary`, or **New event in** and the dataset; * a severity badge and a coloured bar, when the payload's `severity`, `level`, `priority`, `status` or `state` says how bad it is; * up to 10 of the payload's fields; * the payload as pretty-printed JSON, only when the fields left something out. A payload over 2,500 characters is cut there, with a note of its full length, so Slack never refuses a large record; * a **View in Hookie** button that opens the dataset's records; * the dataset, the event id and when the event arrived. The notification text is the severity, the title and the dataset. See [What a card shows](/slack-app#what-a-card-shows) for the details. To write your own message instead, choose **JSON template** under **Body**. A template replaces the default message, so it must be a Slack message body: ```json theme={"dark"} { "text": "Order {{data.order_id}} paid by {{data.customer.email}}" } ``` Placeholders work as they do for any [body template](#body). ### What Slack is sent A `POST` with `Content-Type: application/json` and `User-Agent: Hookie/1.0`. There are no signature headers, because Slack checks none: the URL is the credential. A Slack destination takes no custom headers and no authentication. ### Slack's answers | Slack answers | Hookie | | - | - | | `200` | Marks the delivery `delivered`. | | `429`, a `5xx`, or nothing in time | Tries again on the [retry schedule](/destinations-and-deliveries#attempts-and-retries). | | `400 invalid_payload` | Marks it `dead` at once: Slack cannot read the body. Check your template; the delivery's **Details** show Slack's answer. | | `403 action_prohibited`, `404 no_service` or `no_team`, `410 channel_is_archived` | Marks it `dead` at once. The incoming webhook was revoked, its workspace removed or the channel archived, and waiting will not change that. | | `508` | Marks it `dead` at once: the event has already passed through Hookie eight times, so it is looping. | Unlike a webhook, a Slack destination does not retry a `404`. After 10 dead deliveries in a row, over at least an hour, Hookie switches the destination off, as for any destination. Fix the URL with **Edit…**, then choose **Re-enable** to send what it held. A Slack URL is refused as an HTTPS webhook's URL, with `400`, and as a [customer portal](/customer-portal) destination's URL. That keeps every Slack URL encrypted: create a Slack destination instead. ## S3-compatible storage An S3 destination writes each event as one JSON object in a bucket. It works with AWS S3, Cloudflare R2, Google Cloud Storage in interoperability mode, and MinIO. Each request is signed with AWS Signature Version 4. | Setting | AWS S3 | Cloudflare R2 | | - | - | - | | **Endpoint** | Leave empty | `https://.r2.cloudflarestorage.com` | | **Region** | The bucket's region, such as `us-east-1` | `auto`, the default when an endpoint is set | | **Bucket** | The bucket's name | The bucket's name | | **Key prefix** (optional) | Such as `hookie/` | Such as `hookie/` | | **Access key id** and **Secret access key** | An IAM access key | An R2 API token's S3 credentials | The endpoint is the service's origin only, with no path: the bucket goes in **Bucket**. It must be a public `https` address. For Google Cloud Storage, the endpoint is `https://storage.googleapis.com`, the region `auto`, and the keys an HMAC key from the bucket's **Interoperability** settings. ### Set up AWS S3 1. Create the bucket, or choose an existing one, and note its region. 2. In IAM, create a user for Hookie and attach a policy that allows `s3:PutObject` on the bucket and prefix, and nothing more: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::my-hookie-bucket/hookie/*" } ] } ``` Hookie only writes. It never reads, lists or deletes objects. If the bucket encrypts with a customer-managed KMS key, the user also needs `kms:GenerateDataKey` on that key. 3. Create an access key for the user. 4. In the console, open the project's **Connections** tab. Choose **Add destination**, then **S3-compatible bucket** under **Type**. 5. Leave **Endpoint** empty. Enter the **Region**, the **Bucket**, an optional **Key prefix**, the **Access key id** and the **Secret access key**, then choose **Create**. ### Set up Cloudflare R2 1. In the Cloudflare dashboard, create the R2 bucket, or choose an existing one. 2. Under **R2 → Manage API tokens**, create an API token with **Object Read & Write** permission, applied to that bucket only. R2 has no write-only permission, and Hookie only writes. Copy the token's **Access Key ID** and **Secret Access Key**. 3. Note your account's S3 API endpoint, `https://.r2.cloudflarestorage.com`. The bucket's settings show it with the bucket name on the end: leave that part off. 4. In the console, choose **Add destination**, then **S3-compatible bucket**. Enter the **Endpoint**, leave **Region** empty for `auto`, and enter the **Bucket**, an optional **Key prefix** and the two keys. Choose **Create**. ### Where each object goes Each event is written to: ```text theme={"dark"} {prefix}{dataset}/{yyyy}/{mm}/{dd}/{event id}.json ``` The date is when Hookie received the event, in UTC. With the prefix `hookie/`, an `orders` event received on 1 October 2026 is written to `hookie/orders/2026/10/01/rec_….json`. The prefix is put in front exactly as you write it, so end it with `/` to make it a folder: `hookie` would give `hookieorders/…`. It may be up to 256 characters, and may not start with `/` or contain `.` or `..` segments, backslashes or control characters. The object is the [body](#body) Hookie would send: the envelope by default, or your template or field mappings. It is stored as `application/json`, with the metadata `x-amz-meta-hookie-event-id` and `x-amz-meta-hookie-dataset`. There are no Hookie signature headers; the request is signed with your key instead. Hookie addresses AWS buckets as `https://.s3..amazonaws.com/`, and uses path-style addresses (`//`) for a custom endpoint and for a bucket name with a dot in it. ### Retries and replays overwrite The object key comes from the event, never from the attempt: its prefix, dataset, received date and id. So a retry, a replay, or an event Hookie hands on twice writes the **same object** again, rather than a second copy. Each event leaves one object per destination. If the bucket has versioning on, a rewrite adds a version. | The bucket answers | Hookie | | - | - | | `200` | Marks the delivery `delivered`. | | `408`, `409`, `425`, `429`, a `5xx`, or nothing in time | Tries again on the [retry schedule](/destinations-and-deliveries#attempts-and-retries). | | `403 AccessDenied`, `404 NoSuchBucket`, or any other `4xx` | Marks it `dead` at once. Fix the key's permissions or the bucket name with **Edit…**, then replay. | | `508` | Marks it `dead` at once: the event has already passed through Hookie eight times, so it is looping. | Unlike a webhook, an S3 destination does not retry a `404`. The secret access key is stored encrypted, never returned, exported or logged, and is removed from any error text before it reaches the delivery log. The access key id is not secret and is shown. To rotate the key, choose **Edit…** and enter the new access key id and secret access key; leave the secret blank to keep the saved one. An S3 destination takes no custom headers and no authentication. ## Signing secret on Slack and S3 destinations Every destination has a signing secret (`whsec_…`). Slack and S3 deliveries are not signed with it, so the console labels it **Signing secret (alert notices only)**: it signs the notices sent to the destination's [alert URL](#alert-webhook). Owners and admins can still reveal and rotate it on the tile. ## Custom headers HTTPS webhook destinations only. Add up to 20 headers. Mark a header **Secret** when its value is a credential: it is stored encrypted and is never shown again, in the console, the API or the audit log. To keep a saved secret header while you edit others, leave its value blank. Hookie sets some headers itself, and you cannot override them: * `Hookie-*`, `X-Hookie-*` and `webhook-*` * `Content-Type`, `Content-Length`, `Host`, `User-Agent` and `Idempotency-Key` * hop-by-hop headers such as `Connection`, `Transfer-Encoding` and `Upgrade` ## Authentication HTTPS webhook destinations only. Choose one: | Type | What is sent | | - | - | | **Bearer token** | `Authorization: Bearer ` | | **Basic** | `Authorization: Basic ` | | **API key in a header** | `: `, for example `DD-API-KEY` | The credential is stored encrypted and is never returned. The destination shows only the type, and for an API key, the header name. To change the header without re-entering the key, leave the key blank. ## Body Every type takes a body. For a Slack destination, the default is the [Slack message](#the-message) rather than the envelope. | Body | What is sent | | - | - | | **Hookie envelope** (default) | `{"id", "dataset", "received_at", "data"}` | | **JSON template** | Your JSON, with `{{path}}` placeholders filled from the envelope | | **Field mappings** | A flat object, one `key = path` per line, built the way routing rules build a record | A placeholder is a dotted path into the envelope: `{{dataset}}`, `{{id}}`, `{{received_at}}`, `{{data.customer.email}}`. `{{data}}` alone is the whole payload. ```json theme={"dark"} { "text": "Order {{data.order_id}} paid by {{data.customer.email}}", "amount": "{{data.total}}", "raw": "{{data}}" } ``` A string that is exactly one placeholder keeps the value's type, so `"{{data.total}}"` sends a number. A placeholder inside a longer string becomes text, and a missing value becomes an empty string. Templates are data, never code. A placeholder is a path lookup, and nothing is evaluated. The body is always JSON. For an HTTPS webhook, `Hookie-Signature` is computed over the body exactly as sent. Verify a templated body the same way as the envelope (see [Verify signatures](/signatures)). ## When a destination keeps failing A delivery goes **dead** when its retries run out, or at once when your receiver refuses it with a 4xx that is never retried, such as 400, 401 or 403. Retrying deliveries do not count. 1. At the first dead delivery, the destination is flagged as **failing**, with the last error, on its tile and on **Home**. 2. After **10 dead deliveries in a row over at least an hour**, Hookie switches the destination off. The reason is shown on its tile and on Home, and the audit log records `destination_auto_disabled`. 3. While it is off, new deliveries are **held**, not lost. Click **Re-enable** on Home or on the tile to send them and start the count again. A single delivery that arrives ends a failing streak at any point. ### Alert webhook Set an **Alert URL** on the destination to be told when Hookie switches it off. Hookie POSTs one notice to it: ```json theme={"dark"} { "type": "destination.disabled", "occurred_at": "2026-09-29T12:00:00.000Z", "destination": { "id": "…", "name": "Datadog events", "url": "https://api.datadoghq.com/api/v1/events", "project_id": "…" }, "reason": "Disabled automatically: …", "consecutive_dead": 10, "failing_since": "2026-09-29T10:41:07.000Z", "last_failure": "HTTP 403 (not retried: the receiver refused the request)" } ``` For a Slack destination, `destination.url` is the masked hint; for an S3 destination, it is the bucket's address. The notice carries `Hookie-Signature`, signed with the destination's own signing secret, and `Hookie-Alert: destination.disabled`. It is sent once and is not retried. The alert URL must be a public `https` address. Hookie does not send email alerts. ## When your plan changes Each plan caps how many destinations and database sources a workspace runs, across all its projects, and how often a source may poll: | Plan | Destinations | Database sources | Fastest source poll | | - | - | - | - | | Free | 1 | 0 | every hour | | Pro | 10 | 3 | every 60 seconds | | Team | 25 | 10 | every 30 seconds | When the workspace moves to a plan with lower caps (a downgrade, a cancellation, or a trial ending), Hookie brings what is running in line with the new plan straight away: * **Destinations and sources over the cap are paused, newest first.** The oldest keep running. A paused one shows a **Paused: over plan limit** tag, with a link to billing. A paused destination's deliveries wait as `paused`, like any disabled destination's, rather than being lost. * **Poll intervals below the new minimum are raised to it.** They are not lowered again if you upgrade later; set them yourself. * One `plan_limits_applied` entry in **Settings → Audit log** lists everything that was paused, resumed or slowed. When the plan rises again, only what Hookie paused is resumed, oldest first, as far as the new caps allow. A resumed destination sends its waiting deliveries, and a resumed source starts polling again. **What you switched off stays off.** Hookie never turns on a destination or source you disabled yourself, on any plan change. Switching one on or off yourself also takes it out of Hookie's hands, so it is not resumed automatically later. Turning one on while the workspace already runs its cap is refused with `402`: disable another first, or upgrade. A destination that belongs to a [customer portal](/customer-portal) customer is paused the same way. The customer cannot turn it back on from the portal while it is over the limit, and is told to contact you. Over the API, destinations and sources carry `paused_reason`: `"over_plan_limit"` when Hookie paused them for the plan, `null` otherwise. ## API `POST` and `PATCH /admin/api/projects/{project_id}/destinations` take `headers`, `auth`, `transform` and `alert_url`. `PATCH` with `{"enabled": true}` re-enables a destination and clears its failing state. The dataset filter is `datasetFilter` on `POST` and `dataset_filter` on `PATCH`: a list of dataset names, or `null` for every dataset. An empty list is refused with `400`, because it used to mean every dataset rather than none. `POST` also takes `type`: `webhook` (the default), `slack` or `s3`. A Slack or S3 destination is addressed by `config` instead of `url`: ```json theme={"dark"} { "name": "Orders channel", "type": "slack", "config": { "webhook_url": "https://hooks.slack.com/services/T…/B…/…" }, "datasetFilter": ["orders"] } ``` ```json theme={"dark"} { "name": "Event archive", "type": "s3", "config": { "endpoint": "https://.r2.cloudflarestorage.com", "region": "auto", "bucket": "hookie-events", "prefix": "hookie/", "access_key_id": "…", "secret_access_key": "…" } } ``` For S3, `endpoint` is left out (or `null`) for AWS, and `region` is required then. `config.secret_access_key` and `config.webhook_url` are write-only. * A Slack or S3 destination refuses `url`, `headers` and `auth` with `400`. A webhook refuses `config`, and refuses a `hooks.slack.com` URL. * A destination comes back with `type`, `config` and `secrets_set`. `config` never holds a credential: a Slack destination's `webhook_url` is the masked hint, and an S3 destination's has `endpoint`, `region`, `bucket`, `prefix` and `access_key_id`. `secrets_set` names the credential held, `["webhook_url"]` or `["secret_access_key"]`. A webhook has `config: null`. Its `url` is a display value: the masked Slack URL, or the bucket's address. * `PATCH` with `config` changes a Slack or S3 destination's settings. Settings you leave out keep their stored values, and so does a credential you leave out. `PATCH` with a different `type` is refused with `400`. **Edit or delete.** A destination's **Edit…** changes its name, URL and dataset filter, or a Slack or S3 destination's settings and credential; over the API, `PATCH` takes `name`, and `url` or `config`, too. It stays the same destination, with the same id and signing secret, so your receiver keeps verifying. A new URL must be `https`, and a destination that belongs to a [customer portal](/customer-portal) customer keeps its URL (`409`): it is theirs to change. **Delete** removes a destination that has never had a delivery outright. One with delivery history leaves the project for good, and its past deliveries keep its name, so the delivery log still says where each one went. `GET /admin/api/dashboard/summary` lists failing destinations in `failing_destinations`. See the [API reference](/api-reference). # Destinations and deliveries Source: https://docs.hookie.ai/destinations-and-deliveries How a record becomes a signed request to a URL of yours, a Slack message or an object in a bucket: deliveries and their attempts, retries, the monthly allowance and holds, replay, and what pauses a destination. A **destination** is where a project sends its records: an https URL of yours, a Slack channel, or an S3-compatible bucket. When a record lands in a dataset, Hookie creates a **delivery** for each enabled destination in the project whose dataset filter admits it: for an https URL, a signed `POST` of the record, retried until it arrives or runs out of attempts. ## Destinations Add one on a project's **Connections** tab with **Add destination**, or **From preset** for Slack, Datadog and email. A destination has: * a **type**, fixed when you create it: **HTTPS webhook** (the default), **Slack** or **S3-compatible bucket**. See [Destination types and options](/destinations); * for a webhook, a **URL**, which must be `https`. A Slack destination has its incoming-webhook URL and an S3 destination its bucket settings instead, with the credential stored encrypted; * a **Dataset filter**: the datasets it receives, or all of them when you leave it empty. An empty list is refused, since it would mean nothing rather than everything: to stop deliveries, disable the destination; * a **signing secret** (`whsec_…`), shown in full when you create it. Owners and admins can reveal it again at any time with **Reveal secret** on the destination, and rotate it; see [Verify signatures](/signatures); * optional [request options](/destinations): a body template, and for a webhook, custom headers and authentication; * **delivery settings**: a timeout (10 seconds by default), a send rate and a concurrency cap. A workspace runs 1 destination on Free, 10 on Pro and 25 on Team, counted across all of its projects, whatever their type. ## Deliveries A delivery is one record sent to one destination. To an HTTPS webhook it is, by default, the envelope `{"id", "dataset", "received_at", "data"}`, with these headers: | Header | | | - | - | | `Hookie-Signature`; `webhook-id`, `webhook-timestamp`, `webhook-signature` | Hookie's signature and the [Standard Webhooks](https://www.standardwebhooks.com) one, both made with the destination's secret. | | `Hookie-Event-Id` | The record's id, the same on every retry and replay. Dedupe on it. | | `Hookie-Delivery-Id` | The delivery's id: the same on its retries, and new for a replay. | | `Hookie-Hop` | How many times the event has passed through Hookie, to stop loops. | A Slack destination is sent a [readable message](/destinations#the-message), and an S3 destination writes each event as [one JSON object](/destinations#where-each-object-goes). Neither carries these headers: Slack checks no signature, and an S3 request is signed with your own key. Delivery is at-least-once and unordered. Each delivery has a status: | Status | Meaning | | - | - | | `queued` | Waiting to be sent, or for its turn under the destination's rate or concurrency limit. | | `delivered` | Your URL, Slack or the bucket answered `2xx`. | | `failed` | An attempt failed. The console shows `retrying` while another is scheduled. | | `dead` | Hookie gave up: no attempts are left, or your URL refused the request outright. | | `paused` | Held while the destination is disabled or the workspace suspended; sent when that ends. | | `cancelled` | The destination was deleted, so it is not sent. | ## Attempts and retries Each try is an **attempt**, and a delivery gets up to eight. After a failed one, Hookie waits about 5 seconds, then 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, give or take 20%: about 27 hours in all. A `Retry-After` on a `429` or `503` is the least it waits. | Your URL answers | Hookie | | - | - | | `2xx` | Marks the delivery `delivered`. | | `5xx` other than `508`, `530` with Cloudflare's "Origin DNS error", `404`, `408`, `409`, `425` or `429`, or nothing in time | Tries again on the schedule. | | `508` | Marks it `dead` at once. The event has already passed through Hookie eight times, so it is looping, and the delivery's error says so. | | `530` with Cloudflare's "Origin DNS error" (error 1016) | Marks it `dead` at once: the host does not resolve, and waiting will not change that. | | Any other `4xx` | Marks it `dead` at once: the receiver refused the request, and would again. | | A `3xx` | Marks it `dead` at once. Redirects are never followed: update the destination's URL. | Slack and S3 destinations follow the same schedule with one difference: a `404` is not retried. From Slack it means the incoming webhook was revoked, and from a bucket `NoSuchBucket`; neither comes back by waiting. See [Slack's answers](/destinations#slacks-answers) and [Retries and replays overwrite](/destinations#retries-and-replays-overwrite). Every failed attempt is kept with its status code, latency, error and the start of the response; the delivery keeps the latest response body, up to 16 KiB. **Details**, the eye icon on a row, shows them. Waiting under a rate or concurrency limit uses no attempt. After 10 dead deliveries in a row, over at least an hour, Hookie switches the destination off and holds its new deliveries. See [When a destination keeps failing](/destinations#when-a-destination-keeps-failing). ## The delivery allowance and holds Each plan includes deliveries a month: 1,000 on Free, 90,000 on Pro and 600,000 on Team, with 10% of grace on Pro and Team. A delivery is counted once, when it is created: its retries are free, and a replay, being a new delivery, is counted. Past the allowance, events are still received, stored and routed, but their deliveries are **held**: not sent, and not lost. Once there is room, after the 1st of the month (UTC) or an upgrade, **Send held deliveries** on the **Deliveries** tab sends them oldest first, stopping at the first event the allowance cannot cover. Held deliveries are never sent on their own; the API's `POST …/deliveries/held/release` and the `release_held_deliveries` MCP tool send them too. A held event past your plan's retention is dropped with its record. ## Replay A delivery that did not arrive can be sent again as a new delivery, with a new `Hookie-Delivery-Id` and the same `Hookie-Event-Id`: * **Replay**, on the row of a delivery that did not arrive, sends it again. * **Replay matching** sends again every delivery matching the tab's status, destination and time filters that did not arrive: `dead` ones, and `failed` ones with no attempt scheduled. It takes only the latest delivery of each event to each destination, so nothing goes twice, and asks you to confirm the count first. To an S3 destination, a replay rewrites the same object, because the key comes from the event. The record must still be stored. A replay is charged to the allowance, and refused rather than held when none is left. Over the API: `POST …/deliveries/{id}/replay` and `POST …/deliveries/replay`. ## What pauses a destination * **Disabling it.** Its deliveries wait as `paused` and go out when you enable it again. * **Failing**, as above. **Re-enable**, on its tile or on **Home**, sends what it held. * **A downgrade.** When the workspace moves to a plan with fewer destinations (a downgrade, a cancellation or a trial ending), the newest over the new cap are paused and tagged **Paused: over plan limit**, and their deliveries wait as `paused`. When the plan rises again, Hookie resumes the ones it paused, oldest first, never one you switched off yourself. See [When your plan changes](/destinations#when-your-plan-changes). * **A suspended workspace.** Nothing is delivered until it is reinstated. Deleting a destination cancels what it has not sent. Its queued, retrying and paused deliveries become `cancelled` and none of them is sent, including any that were paused while it was disabled. # Verify provider signatures Source: https://docs.hookie.ai/endpoint-verification Have an endpoint check Stripe, GitHub, Shopify, Slack, Standard Webhooks or HMAC signatures before anything is stored. An endpoint's URL is its credential. Turn on **signature verification** and the endpoint also checks the signature your provider puts on every request. Hookie verifies it over the raw request bytes before anything is stored or counted against your quota, on the endpoint's public URL and on `/v1/webhooks/{key}/{slug}` alike. A request that fails gets `401` and is **neither stored nor counted**. The body says what went wrong: ```json theme={"dark"} { "error": "Missing signature header: Stripe-Signature", "reason": "missing_header", "scheme": "stripe" } ``` | `reason` | Meaning | | - | - | | `missing_header` | The provider's signature header (or its timestamp) is absent. | | `bad_signature` | The signature does not match the body under the endpoint's secret, or the header is malformed. | | `stale_timestamp` | The signature is valid, but its timestamp is outside the tolerance. The request is too old, or the sender's clock is wrong. | Every refusal is written to your audit log as `ingest_signature_rejected`, with the scheme and reason, and appears in the endpoint's **Activity** as `signature_required`, `signature_invalid` or `signature_stale`. Neither ever includes the secret or the signature that was sent. Your provider's delivery log shows the `401` body, so it tells you which of the three went wrong. Where the check sits: a provider's URL-verification handshake (Slack, Meta, Microsoft Graph, Zoom, Twitch) is answered first, and a body Hookie cannot read is refused with `400`, `415` or `422` as usual. The signature is checked next, before duplicate detection, the quota and the store. A body with no fields, `{}` included, is refused with `422` only after the signature passes, so an unsigned request is told to sign first. ## Schemes | Scheme | Header(s) checked | What is signed | | - | - | - | | `none` | — | Nothing is checked. The default for every endpoint. | | `stripe` | `Stripe-Signature: t=,v1=` | HMAC-SHA256 of `.`. Any `v1` may match, as Stripe sends one per secret while it rolls one. | | `github` | `X-Hub-Signature-256: sha256=` | HMAC-SHA256 of the body. | | `shopify` | `X-Shopify-Hmac-Sha256` | HMAC-SHA256 of the body, base64. | | `slack` | `X-Slack-Signature: v0=` and `X-Slack-Request-Timestamp` | HMAC-SHA256 of `v0::`. | | `standard_webhooks` | `webhook-id`, `webhook-timestamp`, `webhook-signature` (or Svix's `svix-id`, `svix-timestamp`, `svix-signature`) | HMAC-SHA256 of `..`, keyed by the base64-decoded secret after `whsec_`. Any `v1,` entry may match. | | `hmac` | The header you choose (default `X-Signature`) | HMAC of the body, with the options below. | **Timestamps.** `stripe`, `slack` and `standard_webhooks` sign a timestamp, and a request more than **5 minutes** from now is refused as `stale_timestamp`. Set `tolerance_seconds` (a whole number from 30 to 86400) to change the window. It applies to those three schemes only; the others sign no timestamp, and sending it with them is refused with `400`. **Generic HMAC.** For a sender with its own scheme, `hmac` takes: | Setting | Values | Default | | - | - | - | | `header` | the HTTP header carrying the signature | `X-Signature` | | `algorithm` | `sha256` or `sha1` | `sha256` | | `encoding` | `hex` or `base64` | `hex` | | `prefix` | text before the digest, such as `sha256=` (up to 32 characters) | none | These settings apply to `hmac` only. The other schemes have fixed headers, so sending one of them with another scheme is refused with `400`, as is any setting Hookie does not know. What is stored is always what is used. Don't turn verification on for an endpoint that a browser form posts to. A browser signs nothing, so every submission would get `401`. The same goes for an endpoint with live traffic from a sender that does not sign yet: from the moment you save, its requests are refused. ## Turn it on In the console, open the endpoint's **Edit…** (or **Add endpoint**). Choose the provider under **Signature verification** and paste its signing secret. The secret is encrypted at rest and never shown again. The endpoint card shows only the scheme. Over the API, pass `verification` when you create the endpoint or in a `PATCH`: ```bash theme={"dark"} curl -X PATCH https://app.hookie.ai/admin/api/projects/$PROJECT/webhooks/$ENDPOINT \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"verification": {"scheme": "stripe", "secret": "whsec_…"}}' ``` A generic HMAC sender: ```json theme={"dark"} { "verification": { "scheme": "hmac", "secret": "…", "header": "X-Acme-Signature", "algorithm": "sha256", "encoding": "hex", "prefix": "sha256=" } } ``` **The secret is write-only.** It is up to 512 characters; a Standard Webhooks secret is `whsec_` followed by base64, or the base64 alone. No response, audit entry, log or workspace export ever contains it. Responses describe verification as: ```json theme={"dark"} { "verification": { "scheme": "stripe", "tolerance_seconds": 600, "has_secret": true, "previous_secret_expires_at": null } } ``` `has_secret` says whether a secret is held, and `previous_secret_expires_at` is when a rotated-out secret stops verifying, or `null` outside a [rotation](#rotating-the-secret). The list, `GET webhooks/{id}` and a create's `webhook` object all carry it. * **Change settings without pasting the secret again**: send the same `scheme` with no `secret`. Switching to a different scheme needs that provider's secret. * **Replace the secret at once**: send a new `secret` in the `PATCH`. The old one stops verifying immediately. To keep it working while your provider switches over, [rotate](#rotating-the-secret) instead. * **Turn verification off**: send `{"scheme": "none"}`. The stored secret is deleted. Any change to `verification` in a `PATCH` also ends a rotation's overlap. A new [version](/api-reference) of an endpoint starts with its parent's verification. Project [export, import and clone](/api-reference#export-import-and-clone-a-project) carry an endpoint's verification as its scheme and settings, never the secret. The copy is created with that scheme and **no secret**, so it refuses every request with `401` until you set one (the endpoint's **Edit** → **Verification**, or `PATCH` with `{"verification": {"scheme": "…", "secret": "…"}}`). It never starts out weaker than the endpoint it was copied from. The import or clone response lists these endpoints under `needs_verification_secret`, and `hookie apply` says the same for each one it creates. To create an endpoint this way yourself, send `"without_secret": true` in place of `secret` on create. The Stripe, GitHub and Shopify source presets (`GET /admin/api/source-presets`) name their scheme in `verification_scheme`. ## Rotating the secret When your provider rolls its signing secret, rotate it here too. The secret it replaces keeps verifying through an overlap, so events signed either way are kept while the provider switches: ```bash theme={"dark"} curl -X POST https://app.hookie.ai/admin/api/projects/$PROJECT/webhooks/$ENDPOINT/rotate-verification-secret \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"secret": "whsec_new…", "overlap_hours": 24}' ``` `overlap_hours` is 0 to 720 and defaults to 24. With 0, the old secret stops at once. Hookie keeps one previous secret, so rotating again inside an overlap ends the older one. An endpoint that does not verify signatures answers `409`: turn verification on first. The rotation is recorded in the audit log as `rotate_webhook_verification_secret`, with the overlap and never either secret. In the console, choose **Rotate signing secret…** in the endpoint's menu, paste the **New signing secret**, and choose how long to **Keep accepting the current secret for**: an hour, a day (the default), a week or 30 days, or end it now. ## Agents and the CLI | | MCP tool | CLI | | - | - | - | | Set on create | `create_webhook` with `verification` | `hookie endpoints create --name … --slug … --dataset … --verification @verification.json` | | Change or remove | `update_webhook` with `verification` | `hookie endpoints update --webhook-id … --verification @verification.json` | | Rotate the secret | `rotate_webhook_verification_secret` | `hookie endpoints rotate-secret --webhook-id … --secret … [--overlap-hours 24]` | All three tools need `hookie:write`. A connected agent is told to ask you for the provider's secret rather than invent one. ## Provider notes ### Stripe Stripe signs with the endpoint's signing secret (`whsec_…`), under **Reveal** on the endpoint in Stripe's dashboard. Choose **Stripe** and paste it. While Stripe rolls a secret it can sign with both, and [rotating](#rotating-the-secret) here keeps the old one verifying through the overlap. ### GitHub When you add the webhook in GitHub, set **Content type** to `application/json` and set a **Secret**: a long random string. Give Hookie the same value under **GitHub**. GitHub's **Recent Deliveries** shows Hookie's `401` body when a delivery is refused. ### Shopify Shopify signs with your app's client secret, or, for webhooks created under **Notifications**, the signing secret shown on that page. Choose **Shopify** and paste it. Shopify signs no timestamp, so there is no tolerance to set. When you rotate the app's client secret, [rotate](#rotating-the-secret) here too. ### Slack Use the **Signing Secret** on your Slack app's **Basic Information** page. An endpoint that also answers Slack's `url_verification` [handshake](/api-reference#html-forms-cors-and-url-verification) keeps answering it, since the handshake is handled before the signature check. ### Standard Webhooks and Svix For any sender that follows [Standard Webhooks](https://www.standardwebhooks.com), including services built on Svix and another Hookie workspace's destinations, choose **Standard Webhooks (Svix and others)** and paste the `whsec_…` secret. For any other sender that signs the body with a shared secret, choose **Custom HMAC** and set the [HMAC options](#schemes). # Endpoints and ingest keys Source: https://docs.hookie.ai/endpoints-and-keys The two ways events get into a project: a public endpoint URL whose last segment is its credential, and an ingest key your own servers send with. Every event enters a project through one of two credentials: * An **endpoint** is a public URL whose last segment, a long random value, is its credential, so the URL is all a sender needs. Paste it into a provider's dashboard or a form's `action`. * An **ingest key** (`ik_live_…`) is a secret your own servers send with. It is the only way to route events with rules, and to tail the live stream. | | Endpoint URL | Ingest key | | - | - | - | | Address | `/{workspace}/{project}/{webhook}` | `/v1/ingest/{ingest_key}`, optionally followed by `/{dataset}` | | Where events land | Always the endpoint's own dataset | The dataset in the path, or wherever the project's rules send them | | Reshaped by | The endpoint's own [criteria and mappings](#criteria-and-mappings) | The project's [rules](/rules-and-mappings), when the path names no dataset | | In a browser | Forms, a thank-you redirect, and allowed origins for `fetch()` | Never answers CORS: keep keys on servers | | Signatures | [Verifies a provider's signature](/endpoint-verification) | Can require Hookie's own `X-Hookie-Signature` | Both addresses are on `https://app.hookie.ai`. The API calls endpoints `webhooks`. ## Endpoints Create one on a project's **Endpoints** tab with **Add endpoint**: a **Name**, a **Dataset** (made from the name when left empty), and optionally the **Signature verification** its provider uses. Hookie generates the URL. A project holds at most 10 endpoints, on every plan. * **The URL is the credential.** Its last segment is about 140 bits of randomness, returned by the API as `webhook_slug`. The readable `slug` an endpoint can be given over the API is only a label, and never appears in the URL. * **It writes only to its own dataset.** A trailing `/{dataset}` naming any other is refused with `403`, so a URL in a web page cannot write elsewhere in the project. **Edit…** points the endpoint at another dataset from then on. * **Disabled, it answers `503`**, which providers retry, rather than the `404` of a URL that does not exist. Nothing is stored meanwhile. * **Rotate URL…** gives the same endpoint a new URL. The old one keeps working through the overlap you choose (a day by default, at most 30 days). * **Slug changes do not break it.** A renamed project keeps its slug, and a workspace's former slugs keep working. ## Criteria and mappings An endpoint can carry **criteria**, conditions every event must meet, and **mappings**, which pick fields into the record's shape. They apply on the endpoint's URL and on `/v1/webhooks/{ingest_key}/{slug}`. An event that fails the criteria is kept as a submission but makes no record: it is marked **Filtered**, its `201` carries `filtered: true`, and it is listed on the project's [Errors tab](/datasets-and-records#errors-tab). No criteria accepts every event, and no mappings stores the payload whole. Criteria use the same [operators as rules](/rules-and-mappings#conditions). An endpoint accepts at most 10 criteria, the same limit as a rule's conditions, and 50 mappings; an 11th criterion is refused with `400` and `At most 10 criteria are allowed`. Set them when you create the endpoint: `criteria` and `mappings` on `POST /admin/api/projects/{project_id}/webhooks`, the `create_webhook` MCP tool, or `hookie endpoints create`. The console's **Add endpoint** creates an endpoint with neither. If an endpoint's stored criteria or mappings cannot be read, Hookie does not route the event as if nothing matched. It keeps the submission, marks it `failed` with `Endpoint rule is malformed` on the project's [Errors tab](/datasets-and-records#errors-tab), and routes it once the rule is fixed. ## Versions `PATCH` refuses an endpoint's criteria and mappings, because they decide what it has already stored. To change them, publish a new **version**: ```bash theme={"dark"} curl -X POST "https://app.hookie.ai/admin/api/projects/$PROJECT/webhooks/$ENDPOINT/versions" \ -H "Authorization: Bearer $HOOKIE_API_KEY" -H "Content-Type: application/json" \ -d '{"name":"Orders (paid only)","dataset":"orders","criteria":[{"path":"status","equals":"paid"}]}' ``` A version is a new endpoint with the same readable slug, the next version number (`1.1`, `1.2`, or `2` with `"major": true`) and a URL of its own. The old version keeps its URL and keeps working, so senders move over when they are ready. A new version verifies the same provider signature and keeps the [form](/forms) honeypot, Turnstile keys and form fields, but starts without the IP allowlist, form redirect, allowed origins and URL verification: set those with **Edit…**. Each version has its own card and counts toward the 10 endpoints. Agents use `create_webhook_version`, and the CLI `hookie endpoints version`. ## Ingest keys Create keys on a project's **Ingest keys** tab with **Create key**. The key is shown once; Hookie keeps only its SHA-256 hash. A key can carry an expiry, an IP allowlist and a signature requirement, and can be rotated with an overlap or revoked: see [Ingest keys](/ingest-keys). The path decides what happens to an event sent with a key: | Request | Result | | - | - | | `POST /v1/ingest/{ingest_key}/{dataset}` | The payload is stored whole in that dataset. | | `POST /v1/ingest/{ingest_key}` | Every enabled rule in the project is tried, and each one that matches writes its own record. When none matches, the payload is stored whole in the key's default dataset. | | `POST /v1/webhooks/{ingest_key}/{slug}` | The event goes through the project's endpoint with that readable slug, or `{slug}-v{version}` for one version. | **Default dataset.** Every ingest key has a default dataset (`default` unless you choose one when you create the key). When you send to `POST /v1/ingest/{key}` without a dataset in the URL, the project's rules route the event. If none of them matches, Hookie stores the whole payload as one record in the key's default dataset rather than dropping it. That record is delivered, streamed and handed to AI triggers and workflows like any other, and the request counts as one event, the same as a rule-routed one. The response tells you which happened: `fallback: true` with `routed: [""]` when the default dataset caught it, and `fallback: false` with the names of the matching rules otherwise. Naming a dataset in the URL (`/v1/ingest/{key}/{dataset}`) writes straight to it and never uses the default. Endpoint URLs always write to the endpoint's own dataset. The default dataset is fixed when the key is created and is carried over when you rotate it. ## Before anything is stored Every request passes these checks first. A refusal stores nothing and never counts against your quota: | Check | Refused with | | - | - | | An unknown URL or key; a disabled endpoint | `404`; `503` | | The IP allowlists of the endpoint or key, the project and the workspace | `403` | | A suspended workspace | `403` | | The [burst limit](/limits-and-plans#the-burst-limit) | `429` | | The body: at most 1,000,000 bytes, in a format Hookie reads, with a field | `413`; `400` or `415`; `422` | | The endpoint's provider signature, or the key's `X-Hookie-Signature` | `401` | | An endpoint's [form checks](/forms): a filled honeypot; Turnstile; form fields | `201` (dropped); `403` or `503`; `422` | | The loop guard: 8 passes through Hookie | `508` | | The monthly event quota | `429` | A repeated `Idempotency-Key`, or a provider's delivery id seen before, returns the original submission with `200` and is not counted again. An endpoint's refusals are listed under **Activity** in its menu. An endpoint can also deduplicate on a field in its own body. Set `dedupe_path` to a payload path such as `event_id` or `data.id`, when you create the endpoint or publish a new version over the API or MCP. A POST whose body has the same value there as an earlier one to that endpoint returns the first submission with `200`, and stores and counts nothing. It is off by default. It applies only when the request has no `Idempotency-Key` header and no provider delivery-id header. The keys are kept as long as the submissions, for your plan's retention window. `PATCH` refuses `dedupe_path`: publish a new version to change it. # Forms Source: https://docs.hookie.ai/forms Put a contact, newsletter, waitlist, feedback or survey form on any site, with Hookie as its backend: spam protection, field checks, workflows and delivery to Slack. A form on any site can post straight to a Hookie endpoint. There is no backend to write and no JavaScript to add: the endpoint URL takes the browser's post, checks it, stores it, runs your rules and workflows, and delivers it to Slack, your own webhook or your customers' portal. ## One command The CLI sets up the endpoint, its checks and the form itself: ```bash theme={"dark"} hookie forms init contact --to slack --origin https://example.com ``` | Template | Fields | | - | - | | `contact` | `name`, `email`, `message` (all required) | | `newsletter` | `email` | | `waitlist` | `email`, then optional `name` and `company` | | `feedback` | `rating` (1-5), then optional `message` and `email` | | `survey` | `role`, `satisfaction` (1-5), then optional `comments` and `email` | `forms init` does four things: 1. It creates the endpoint, with the template's fields as its [form fields](#check-the-fields), a [honeypot](#stop-bots) and the origins and redirect you give it. Run it again and it updates the same endpoint, found by its `--slug`. 2. With `--to slack` or `--to webhook`, it adds the destination, filtered to the form's dataset. Slack with no `--destination-url` prints the link to **Add to Slack** on your project's **Connections** tab, which you press yourself. 3. It writes the form into your project: plain HTML, a React component or an Astro component, chosen from your `package.json` (`--framework` to choose yourself, `--out` for the directory). It never replaces a file that exists unless you pass `--force`. 4. It records the endpoint in `hookie-forms.json`, so `hookie forms test` knows which form you mean. `--json` prints one result object: the endpoint URL, the dataset, the destination, the files written, warnings and next steps. `--dry-run` shows the plan and changes nothing. Then check the whole path: ```bash theme={"dark"} hookie forms test contact-form ``` It posts a sample submission built from the endpoint's fields and reports the record it made. An endpoint that requires Turnstile refuses it, because a terminal cannot pass a Turnstile check: test from your page in a browser. ## The form What `forms init` writes for plain HTML is an ordinary form: ```html theme={"dark"}
``` The URL in `action` is public by design. It can only add submissions, and every check below runs on it. If it leaks to someone you would rather not hear from, [rotate it](/endpoints-and-keys#endpoints). **After a submit.** With no redirect URL, the visitor lands on Hookie's thank-you page, in light or dark to match their system, with a link back to your form. Give the endpoint a redirect URL (`--redirect https://example.com/thanks`, or **Redirect after a form submit** under **Edit…**) to send them to your own page instead. When a submission fails a check, the visitor sees a page that lists what to fix. **Without leaving the page.** The React component submits with `fetch()` and shows the result in place. That needs your site's origin in the endpoint's allowed origins (`--origin`, repeatable, or **Allowed browser origins** under **Edit…**), or the browser will not let the page read the answer. It sends one `Idempotency-Key` per submission and reuses it if the visitor presses **Send** again after an error, so a double click stores one submission. ### `@hookieai/forms` For your own markup, the `@hookieai/forms` package does the submitting. It has no dependencies. ```js theme={"dark"} import { enhanceForm, submitForm } from "@hookieai/forms"; // Progressive enhancement: works with no JavaScript, submits with fetch() when it can. enhanceForm(document.querySelector("form"), { onSuccess: () => showThanks(), onError: (r) => showErrors(r.fieldErrors), // { email: "must be an email address" } }); // Or by hand: const result = await submitForm(url, { email, message }); ``` In React, `useHookieForm(url)` from `@hookieai/forms/react` gives you `submit`, `status`, `fieldErrors` and `error`. `defineFields([...] as const)` and `FieldsOf` type a payload from the endpoint's fields, so a missing required field fails to compile. Install it with `npm i @hookieai/forms`. You do not need it for the forms `hookie forms init` writes, which work on their own. ## Stop bots Both checks run before anything is stored or counted, so spam never uses your monthly events or reaches Slack. **Honeypot.** A field that people never see and bots fill in. Set its name with `honeypot` (`forms init` uses `_gotcha`). A submission that fills it is answered exactly as if it had worked, so the bot learns nothing, but it is not stored, counted or delivered. It shows under the endpoint's **Activity** as **Spam caught by the honeypot**. The empty field is removed from what is stored. **Cloudflare Turnstile.** For forms that attract more determined bots. Create a widget in your Cloudflare dashboard, then give the endpoint its keys: `turnstile_site_key` (public, for the widget) and `turnstile_secret`. With the secret set, every submission must carry the token the widget adds (`cf-turnstile-response`). Hookie checks it with Cloudflare before storing anything: | Result | Answer | | - | - | | No token | `403`, `turnstile: "missing"` | | A token Cloudflare rejects, such as an expired or reused one | `403`, `turnstile: "invalid"` and Cloudflare's error codes | | Cloudflare cannot be reached | `503`, `turnstile: "unavailable"`. Hookie does not let submissions through unchecked. | The secret is encrypted when stored and never shown again: the endpoint reports only `turnstile_enabled`. The token is removed from what is stored. With `forms init`, pass `--turnstile-site-key` and `--turnstile-secret`, and the form renders the widget. ## Check the fields Give the endpoint `form_fields` and it refuses a submission that does not meet them, with `422`, before anything is stored or counted: ```json theme={"dark"} [ { "name": "email", "type": "email", "required": true }, { "name": "message", "type": "textarea", "required": true, "max_length": 2000 }, { "name": "plan", "type": "select", "options": ["free", "pro"] } ] ``` | Key | Meaning | | - | - | | `name` | The form field's name, exactly as the form sends it. | | `type` | `text`, `email`, `number`, `url`, `tel`, `textarea`, `select`, `checkbox`, `date` (`YYYY-MM-DD`) or `hidden`. | | `required` | Refuse a submission without it. | | `max_length` | Longest value allowed. Every field has a limit of 5,000 characters unless you set one. | | `options` | A `select` must be one of these. On a `checkbox`, every value must be. | The answer names every problem at once: ```json theme={"dark"} { "error": "The submission does not match the form: email must be an email address; message is required. Nothing was stored.", "reason": "form_invalid", "fields": [ { "name": "email", "error": "must be an email address" }, { "name": "message", "error": "is required" } ] } ``` Fields you did not declare are still stored, so a tracking field or a new input does not break anything. Up to 50 fields can be declared. ## Where submissions go A submission is an ordinary event, so everything else in Hookie works on it: * **Rules and mappings** reshape or filter what is stored. See [Rules and mappings](/rules-and-mappings). * **Workflows** branch on a field, call AI to classify or summarise a message, wait, or call another API. See [Workflows](/workflows). * **Destinations** deliver it: a [Slack card](/slack-app), your own signed webhook, S3, SQS or Pub/Sub. See [Destinations](/destinations). * The **customer portal** lets your own customers subscribe to the form's dataset. See [Customer portal](/customer-portal). Each submission counts as one event, and each destination it is delivered to as one delivery. The Free plan's 1,000 events a month suit a contact form, and refused spam costs nothing. See [Limits and plans](/limits-and-plans). ## Settings, wherever you work | Where | How | | - | - | | Console | **Endpoints** tab, **Edit…** in the endpoint's menu: honeypot, Turnstile keys, form fields, redirect and allowed origins | | CLI | `hookie forms init`, `hookie endpoints update --honeypot _gotcha`, or an endpoint's keys in `hookie apply`. A config file never carries the Turnstile secret. | | MCP | `create_webhook` and `update_webhook` take `form_fields`, `honeypot`, `turnstile_site_key` and `turnstile_secret` | | REST | The same fields on `POST` and `PATCH /admin/api/projects/{project_id}/webhooks`. See the [API reference](/api-reference). | A new version of an endpoint keeps its honeypot, Turnstile keys and form fields, so publishing one never opens the form to bots. # Glossary Source: https://docs.hookie.ai/glossary What each Hookie term means, in a sentence or two, with the names the API and the MCP tools use for the same thing. ## Terms ### Agent account A workspace that an AI agent registered for itself, with no person involved, through `POST /v1/agents/register`. It starts on the Free plan, and the agent works in it with an admin API key. Until a person claims it with the agent's claim link, billing, invites and a few other actions are refused with `claim_required`. See [Agent self-registration](/agent-self-registration). ### AI trigger A project setting that runs a model with your instructions on each record that lands in a dataset and meets its conditions. The reply is stored as a record in the trigger's output dataset. See [AI triggers](/workflows#ai-triggers). ### API key A workspace credential (`hk_…`) that lets a CI job, script or backend call the admin API and `/mcp` as the person who created it, held to the key's role. See [Roles, API keys and connected agents](/roles-and-agents#api-keys). ### Attempt One try at sending a delivery. A delivery gets up to eight over about 27 hours, and each failed attempt is kept with its status code, latency and error. See [Attempts and retries](/destinations-and-deliveries#attempts-and-retries). ### Connected agent A coding agent, such as Claude Code or Cursor, that signed in to Hookie's MCP server over OAuth and acts as you, within the access you grant it in **Settings → Connected agents**. See [Connected agents](/connected-agents). ### Criteria The conditions an endpoint applies to every event it receives. An event that fails them is kept as a submission but makes no record. See [Criteria on endpoints](/rules-and-mappings#criteria-on-endpoints). ### Dataset A named collection of records in a project, such as `orders`. It exists from the moment the first record is filed under its name. See [Datasets and records](/datasets-and-records#datasets). ### Default dataset The dataset an ingest key stores an event in when it is sent with no dataset in the URL and none of the project's rules matches it: the whole payload, as one record, with `fallback: true` in the response. It is `default` unless you choose another when you create the key. A cron or WebSocket trigger with no dataset stores an event no rule matches as one record in `default`. See [Endpoints and ingest keys](/endpoints-and-keys#ingest-keys) and [Triggers with no dataset](/workflows#triggers-with-no-dataset). ### Delivery One record sent to one destination: a signed `POST`, a Slack message or an object written to a bucket, retried until it arrives or runs out of attempts. Each one counts once against the monthly delivery allowance. See [Destinations and deliveries](/destinations-and-deliveries#deliveries). ### Destination Where a project sends its records, optionally only those of some datasets. Its type is an HTTPS webhook (every request signed with the destination's secret), a Slack channel, or an S3-compatible bucket. See [Destinations and deliveries](/destinations-and-deliveries) and [Destination types and options](/destinations). ### Endpoint A public URL, `https://app.hookie.ai/{workspace}/{project}/{webhook}`, whose random last segment is its credential. What is posted to it is stored in the endpoint's dataset. See [Endpoints and ingest keys](/endpoints-and-keys#endpoints). ### Event Anything sent to Hookie. Where the API names an event, as in `Hookie-Event-Id`, `event_id` or `search/events`, it means a record. ### Hold What happens to a record's deliveries once the month's delivery allowance is used up: they are neither sent nor lost, and **Send held deliveries** sends them once there is room. See [The delivery allowance and holds](/destinations-and-deliveries#the-delivery-allowance-and-holds). ### Ingest key A credential (`ik_live_…`) that your own servers send events into one project with. It is the only way to route events with rules and to tail the live stream. An event its rules do not match lands in its default dataset. See [Endpoints and ingest keys](/endpoints-and-keys#ingest-keys). ### Mapping A `{path, key}` pair that copies one field of an event into the stored record under a key you choose. With no mappings, the whole payload is stored. See [Mappings](/rules-and-mappings#mappings). ### Member A person in a workspace, with one role: owner, admin, developer or viewer. A workspace has exactly one owner, and people join it through a single-use invite link an owner or admin shares. See [Team members](/team-members). ### Portal The customer portal: a page you embed in your own app, where your customers add the destinations your events are sent to and inspect and replay their deliveries. Each customer gets in with a token you issue. See [Customer portal](/customer-portal). ### Project The unit everything else belongs to: endpoints, keys, datasets, rules, destinations, triggers and workflows. Every workspace starts with one called Default. A project's type, **Endpoints** or **AI trigger** (`webhook` or `ai_trigger` in the API), decides only what it starts with. See [Projects](/projects). ### Record One routed event, stored as a JSON object in a dataset. Records are what fan out to destinations, the live stream, AI triggers and workflows. See [Records](/datasets-and-records#records). ### Replay Sending a delivery that did not arrive again, as a new delivery with the same event id. It counts against the delivery allowance. See [Replay](/destinations-and-deliveries#replay). ### Rule A project's routing rule, which decides the dataset and shape of an event sent with an ingest key and no dataset in the path, or fired by a cron or WebSocket trigger with no dataset. Every rule that matches writes its own record; when none matches, the event is stored whole in the key's default dataset, or in `default` for a trigger. See [Rules and mappings](/rules-and-mappings#rules). ### Run One execution of a workflow, started by one matching record; the API calls it a workflow instance. An AI trigger's execution is also called a run. See [Instances & observability](/workflows#instances-%26-observability). ### Source A database source: a PostgreSQL table that Hookie polls on a schedule and files new rows from into a dataset, as events. Each row counts once against the event quota, when it is stored. Sources need the Pro or Team plan. See [Database sources](/database-sources) and [Limits and plans](/limits-and-plans#per-plan). ### Submission The request exactly as it arrived, with its method, headers and source IP, stored before any routing. One submission makes no record, one or several, and counts once against the monthly event quota. See [Submissions](/datasets-and-records#submissions). ### Workflow A multi-step process in a project that starts when a record lands in its entry dataset, and runs steps such as AI calls, HTTP calls, branches and waits. See [Workflows & AI agents](/workflows). ### Workspace Your account. It holds the projects, its members, the plan and billing, API keys, connected agents and the audit log, and its slug is the first segment of every endpoint URL. A person can belong to several workspaces and switch between them. See [Projects](/projects) and [Team members](/team-members). ## One thing, three names The console and these docs use the names above. The API and the MCP tools keep some older names for the same things: | Name | In the API | MCP tools | | - | - | - | | Workspace | `workspace`, `me`, `workspaces` | `get_workspace` | | Member | `members`, `invites` | `list_members` | | Project | `projects` | `list_projects`, `create_project` | | Endpoint | `webhooks`, `webhook_id`, `webhook_slug` | `create_webhook`, `list_webhooks`, `update_webhook` | | Ingest key | `ingest-keys` | `create_ingest_key`, `rotate_ingest_key` | | Default dataset | `dataset_default` | on `create_ingest_key` | | Dataset | `datasets`, `dataset` | `list_datasets` | | Record | `records`, `search/events`, `event_id` | `query_records`, `get_record`, `search_events` | | Submission | `submissions`, `submission_id` | `search_submissions` | | Rule | `rules` | `create_rule`, `update_rule` | | Criteria and mappings | `criteria`, `mappings` | on `create_webhook` and `create_rule` | | Destination | `destinations` | `create_destination`, `update_destination` | | Delivery and attempts | `deliveries`, `attempts` | `list_deliveries`, `get_delivery` | | Hold | `deliveries/held` | `list_held_deliveries`, `release_held_deliveries` | | Replay | `deliveries/{id}/replay`, `deliveries/replay` | `replay_delivery`, `replay_deliveries` | | Workflow and run | `workflows`, `workflow-instances` | `create_workflow`, `list_workflow_runs` | | AI trigger | `triggers` | `create_trigger` | | Source | `sources` | `create_source` | | Portal | `portal`, `portals` | `update_portal`, `create_portal_token` | | Connected agent | `agents` | none | | Agent account | `v1/agents/register`, `agent-accounts/claim` | none | | API key | `api-keys` | none | In prose, "webhook" means the HTTP request a provider sends, or one Hookie sends; the URL Hookie gives you to receive them is an **endpoint**. # Overview Source: https://docs.hookie.ai/index Capture any HTTP event, route it into structured datasets, run it through a workflow, and deliver it signed — on Cloudflare's edge. Hookie is a webhook platform: it gives you a URL to receive events, somewhere structured to keep them, logic to act on them, and a signed, retrying delivery out the other side. Everything runs on Cloudflare Workers, D1, and Durable Objects. Create a project, send a test event, see the record, and watch a signed delivery arrive. Multi-step, event-driven pipelines with branching, AI steps, and reusable agents. Every ingest, admin, and streaming endpoint, with an interactive playground. Point Claude Code, Cursor, or any MCP client at your workspace over OAuth. Let an AI agent create its own Free-plan account, with no person involved. ## How an event moves through Hookie Post to an endpoint's URL, where the high-entropy slug in `app.hookie.ai/{workspace}/{project}/{webhook}` is the credential, or to `/v1/ingest/{key}` with an ingest key. JSON, form posts, and multipart all work, so an HTML `
` can point straight at an endpoint with no JavaScript and no backend of your own. Before anything is stored, the request passes the endpoint's IP allowlist, its rate limit, a duplicate check (an `Idempotency-Key`, or the delivery id your provider already sends), and your plan quota. Refused requests are logged in the endpoint's Activity, and never counted.
The raw body is stored as a **submission** exactly as it arrived, before anything reshapes it, so "no rule matched" is always distinguishable from "the event never arrived." Addressed requests store the payload whole into the dataset the URL names. A keyed request with no dataset in the path is the one shape that consults **rules**: each enabled rule tests its conditions against the payload and, where they hold, maps fields out into a **record**. Rules are not first-match: one event can land in several datasets at once. An event no rule matches is stored whole in the key's default dataset rather than dropped. A new record triggers every matching **destination** (an outbound webhook, signed HMAC-SHA256, retried with backoff), pushes onto the **live stream** for anyone tailing it, and starts any **workflow** whose trigger it satisfies. A workflow runs steps in order: call a model, branch on the result, write to a dataset, emit a further event. Workflows also start on a schedule or from a WebSocket condition, not only from an inbound event.
## What's in the box Outbound webhooks carry an HMAC-SHA256 signature and a timestamp. Verify them in a few lines, in any language. Tail events in real time over SSE or WebSocket, with resume after a disconnect. Let your own customers manage their webhook destinations and inspect their deliveries, without a support ticket. Search submissions, records, and deliveries, and follow any ID along the whole chain it belongs to. Every resource (datasets, rules, endpoints, destinations, workflows) is scoped to a **project**, and every workspace starts with a Default project. If you are just trying things out, you can ignore projects entirely and use that one. [Projects](/projects) and the other **Concepts** pages explain how the pieces fit, and the [Glossary](/glossary) defines every term. ## Finding your way around the console **Home** is the workspace at a glance. It charts events received, deliveries sent and AI runs over the last 24 hours, 7 days or 30 days, each with its change on the period before. It also shows this month's usage against your plan, and a **Needs attention** list of anything that wants you: a failing or automatically disabled destination, or a limit you are close to. Its search box reaches any project, page or setting, and **Recently opened** lists the pages you visited last. The side navigation groups an open project's pages under **Build** (endpoints, ingest keys, datasets, rules, workflows), **Deliver** (connections, deliveries) and **Observe** (observability, errors). Press / or ⌘K (Ctrl+K on Windows and Linux) to filter it as you type. ## Start here Create a free workspace at [app.hookie.ai](https://app.hookie.ai/#/signup). Sign up with an emailed code or GitHub, and a workspace is provisioned the first time you sign in. Then follow the [Quickstart](/quickstart). ### For AI agents Start with [hookie.ai/llms.txt](https://hookie.ai/llms.txt), also served at [app.hookie.ai/llms.txt](https://app.hookie.ai/llms.txt): one plain-text file that says what Hookie is, how an agent [registers itself](/agent-self-registration), where the MCP server and the OpenAPI document are, and what the Free plan includes. These docs publish their own [llms.txt](https://docs.hookie.ai/llms.txt) and [llms-full.txt](https://docs.hookie.ai/llms-full.txt) as well. [hookie.ai/auth.md](https://hookie.ai/auth.md) lists every route to a credential. ## Get help * **Is something down?** [hookie.ai/status](https://hookie.ai/status) explains how to check ingest and deliveries yourself, and links the status pages of the platforms Hookie runs on. * **Questions and support:** [hookie.ai/contact](https://hookie.ai/contact) lists who to write to, or email [support@hookie.ai](mailto:support@hookie.ai). * **Security:** report a vulnerability or suspected unauthorized access to [security@hookie.ai](mailto:security@hookie.ai). Read [hookie.ai/security](https://hookie.ai/security) before you test anything. * **What changed:** [hookie.ai/changelog](https://hookie.ai/changelog). # Ingest keys Source: https://docs.hookie.ai/ingest-keys Create, restrict, expire, rotate and revoke the ik_live_ keys servers use to send events and tail the live stream. An **ingest key** (`ik_live_…`) is the credential for a server. An endpoint URL is what you paste into a provider's dashboard; a key is what your own code sends with, and it is the only way to use three things: * **Rules.** `POST /v1/ingest/{key}` with no dataset in the path runs the project's [mapping rules](/rules-and-mappings), and stores an event none of them matches in the key's default dataset. * **The live stream.** `GET /v1/stream` authenticates with a key and tails that key's project. See [Live streaming](/streaming). * **Hookie's own inbound signature.** A key can require every request to carry an `X-Hookie-Signature` (`t=,v1=`, an HMAC-SHA256 over `.` with the key's signing secret). See [request headers](/api-reference#request-headers). To check a *provider's* signature (Stripe, GitHub and others), use [endpoint signature verification](/endpoint-verification) instead. A key belongs to one project. Anyone holding it can send events into that project and watch its events arrive, so treat it like a password. ## Create a key In the console, open the project and choose **Ingest keys**. Owners, admins and developers can create and change keys; viewers see the list. Choose **Create key** and give it the name of the sender that will use it. Optionally set: * a **default dataset**, where an event sent with no dataset in the URL is stored, as one record holding the whole payload, when none of the project's rules matches it. Leave it empty to use `default`; * an **expiry**, after which the key is refused exactly as if it had been revoked; * **allowed source IPs**, one IP or CIDR range per line; * **require a signature**, which also mints a signing secret. The key (and its signing secret, if any) is shown **once**. Hookie stores only a SHA-256 hash of the key and its first 16 characters, which is what the list shows from then on. ```bash theme={"dark"} curl -X POST "https://app.hookie.ai/v1/ingest/$HOOKIE_KEY/orders" \ -H "Content-Type: application/json" \ -d '{"order":1042,"total":59.9}' ``` **Default dataset.** Every ingest key has a default dataset (`default` unless you choose one when you create the key). When you send to `POST /v1/ingest/{key}` without a dataset in the URL, the project's rules route the event. If none of them matches, Hookie stores the whole payload as one record in the key's default dataset rather than dropping it. That record is delivered, streamed and handed to AI triggers and workflows like any other, and the request counts as one event, the same as a rule-routed one. The response tells you which happened: `fallback: true` with `routed: [""]` when the default dataset caught it, and `fallback: false` with the names of the matching rules otherwise. Naming a dataset in the URL (`/v1/ingest/{key}/{dataset}`) writes straight to it and never uses the default. Endpoint URLs always write to the endpoint's own dataset. The default dataset is fixed when the key is created and is carried over when you rotate it. The list shows each key's name, prefix, status, expiry, allowed IPs, default dataset, when it was last used and when it was created. Status is **active**, **rotating** (still working, with a replacement and a deadline), **expired** or **revoked**. Only an active or rotating key authenticates. ## Restrict a key to your servers A key's **allowed source IPs** refuse every request from anywhere else with `403 Source IP not allowed`, and each refusal is recorded in the audit log as `ingest_ip_blocked`. Entries are IPv4 or IPv6 addresses or CIDR ranges, up to 64, validated the same way as the project and workspace allowlists. An empty list accepts any address. The lists stack: a request must pass every one that is set, whether on the key (or the endpoint), the project or the workspace. A key's list can only narrow what the project and workspace allow. Set it when you create the key, or later with **Edit…** on the key's row. The same field is on an endpoint's **Edit…** dialog, for endpoint URLs. ## Rotate without an outage **Rotate…** issues a new key with the same name, default dataset, allowed IPs and signature requirement, and gives the old key a deadline. Until then both work, so you can move every sender over, then let the old one lapse. Choose how long the old key keeps working: an hour, a day (the default), a week or 30 days. **End it now** stops it at once, which breaks anything still using it. It is shown once, like a new key. A key that requires signatures gets a **new signing secret** too, so a rotation after a leak does not carry the leaked secret forward. The old key's row reads **rotating** until its deadline and **expired** after it. To give yourself more time, use **Edit…** on the old key and move its expiry. A key can be rotated once; after that, rotate its replacement. A revoked or expired key cannot be edited or brought back: create a new key instead. ## Revoke **Revoke…** stops a key on its very next request, at ingest and on `/v1/stream`. It is not reversible, and the row stays listed with its history. To replace a key without an outage, rotate it instead. ## Endpoint URLs rotate too An endpoint's URL is its credential in the same way. On the **Endpoints** tab, **Rotate URL…** gives the endpoint a new URL while keeping the same endpoint: its id, dataset, settings and records are unchanged. The old URL keeps working through the overlap you choose (a day by default), and the card says until when. **Edit…** renames an endpoint, points it at another dataset from now on, and sets its allowed source IPs. ## From the API, an agent or the CLI | Action | Admin API | MCP tool | CLI | | - | - | - | - | | List keys | `GET /admin/api/projects/{project_id}/ingest-keys` | `list_ingest_keys` | `hookie keys list` | | Create | `POST …/ingest-keys` with `name`, `dataset_default`, `require_signature`, `expires_at`, `ip_allowlist` | `create_ingest_key` | `hookie keys create` | | Edit name, expiry, allowlist | `PATCH …/ingest-keys/{id}` | `update_ingest_key` | `hookie keys update` | | Rotate | `POST …/ingest-keys/{id}/rotate` with `overlap_hours` (0 to 720, default 24) | `rotate_ingest_key` | `hookie keys rotate` | | Revoke | `DELETE …/ingest-keys/{id}` | `revoke_ingest_key` | `hookie keys revoke` | | Edit an endpoint | `PATCH …/webhooks/{id}` with `enabled`, `name`, `dataset`, `ip_allowlist` | `update_webhook` | `hookie endpoints update` | | Rotate an endpoint URL | `POST …/webhooks/{id}/rotate` with `overlap_hours` | `rotate_webhook` | `hookie endpoints rotate` | `expires_at` is an ISO 8601 time in the future, or `null` for never. `ip_allowlist` is an array; `[]` or `null` removes it. Every change is recorded in the audit log, and neither a key nor an endpoint URL ever appears in it. # Limits and plans Source: https://docs.hookie.ai/limits-and-plans Every per-plan number in one place, how the monthly quotas count and reset, and the best-effort burst limit every credential has. Hookie has four plans: **Free**, **Starter**, **Pro** and **Team**. A plan belongs to the workspace and sets the limits of every project in it. How to change plans is in [Billing](/billing), and prices are on the [pricing page](https://hookie.ai/pricing). ## Per plan | | Free | Starter | Pro | Team | | - | - | - | - | - | | Workspace members, owner included | 1 | 1 | 3 | unlimited, billed per seat | | Events a month | 1,000 | 25,000 | 100,000 | 300,000 | | New events refused after | 1,000 | 27,500 | 110,000 | 330,000 | | Deliveries a month | 1,000 | 40,000 | 90,000 | 600,000 | | Deliveries held after | 1,000 | 44,000 | 99,000 | 660,000 | | AI tokens a month | 100,000 | 100,000 | 750,000 | 1,500,000 | | AI trigger runs a month | 100 | 300 | 2,000 | 10,000 | | Destinations | 1 | 3 | 10 | 25 | | Database sources | none | none | 3 | 10 | | Fastest database source poll | every hour | every hour | every 60 seconds | every 30 seconds | | Live stream connections at once | 1 | 2 | 5 | 20 | | Workflow runs active at once | 25 | 100 | 500 | 2,000 | | Data retention | 7 days | 14 days | 30 days | 90 days | Everything in this table is counted across the whole workspace, not per project. Starter, Pro and Team accept 10% over the event and delivery numbers before they bite; Free stops at the number. Single sign-on (SAML or OIDC) is planned and not available yet, on any plan. **Starter** is $2.99 a month and **Pro** is $9.99 a month, both flat prices for the workspace. **Team** is \$39.99 per member, per month: its seat count follows the number of people in the workspace, as they join and leave. See [Billing](/billing#team-seats). **Nothing is billed as overage** on any plan: past a limit, new events are refused, deliveries held and AI calls refused, as described below, so a paid plan never charges more than its price. ## Retention after a downgrade When your workspace moves to a plan with a shorter retention window, Hookie keeps your event data on your **previous** plan's window for **7 more days**. That happens when you downgrade or cancel a paid plan, when a subscription ends after failed payments, when a free trial ends, or when Hookie support changes your plan. After those 7 days, the new plan's window applies: event data older than it is permanently deleted by the automatic cleanup, which runs every five minutes. That covers records, raw submissions, delivery history, the AI call log, workflow runs and AI trigger runs. For example, moving from Team (90 days) to Free (7 days) keeps 90 days of data for 7 more days. After that, anything older than 7 days is deleted. * **Downgrade again within the 7 days:** the longest window still applies, and the 7 days start again. Team → Pro → Free keeps Team's 90 days until 7 days after the move to Free. * **Upgrade to a plan whose retention is at least as long as the window being kept:** the window ends, because the new plan keeps that data anyway. * **Upgrade to a plan with a shorter window than the one being kept** (for example Team → Free, then Free → Pro): the window runs on unchanged. When it closes, the new plan's window applies. * **Closing a workspace** works differently. See [Close the workspace](/data-deletion#close-the-workspace). While the window is open, **Home** shows a warning with the date the older data will be deleted. Below it are two buttons: **Export & closure**, where an owner or admin can download everything as one JSON Lines file (see [Export all data](/data-deletion#export-all-data)), and **Plan & billing**, where the owner can upgrade. The API returns the window on `GET /admin/api/me`: ```json theme={"dark"} { "retentionGrace": { "retention_days": 90, "plan_retention_days": 7, "ends_at": "2026-10-06T14:05:00.000Z" } } ``` `retention_days` is the window being kept, `plan_retention_days` is the current plan's, and `ends_at` is when the window closes. `retentionGrace` is `null` when no window is open. ## The same on every plan | Limit | | | - | - | | Endpoints | 10 per project, versions included | | Cron triggers | 10 per project, firing at most every 5 minutes | | WebSocket triggers | 5 per project | | Criteria and conditions | 10 per endpoint or rule, with 50 mappings | | Request body | 1,000,000 bytes | | Burst limit | about 100 requests per 10 seconds per credential; see [below](#the-burst-limit) | | Workflows | 32 steps, branches nested 3 deep; see [Workflows](/workflows#limits) | ## Monthly quotas Every monthly quota resets at 00:00 UTC on the 1st of the month, whatever day your subscription renews. **Home** shows this month's events, deliveries and AI tokens against your plan, with a warning from 80%. * **Events.** Every accepted submission counts once, however many records it makes, and so does every event a cron trigger, WebSocket trigger or database source writes. A database source row counts once, when it is stored; one Hookie could not store is not counted (see [Database sources](/database-sources#quota-and-retries)). Refused requests and recognised duplicates are not counted. The count is exact: one counter per workspace. Past the ceiling, a new event is refused with `429` and not stored. The answer carries `"reason": "quota_exceeded"`, `reset_at`, an `upgrade_url` and `Retry-After: 3600`. A trigger or source shows the refusal instead. * **Deliveries.** One delivery is one event sent to one destination, counted when it is created. Retries are free, and replays count. Past the ceiling, deliveries are held, not dropped: see [The delivery allowance and holds](/destinations-and-deliveries#the-delivery-allowance-and-holds). * **AI tokens.** The input and output tokens of every AI call count: AI trigger runs, and workflow `call_ai` and `agent_call` steps. There is no grace: once the allowance is used, the next AI call is refused before the model runs, and its step or run fails. See [AI allowance](/workflows#ai-allowance-and-the-ai-call-log). * **AI trigger runs.** Each AI trigger run counts once it starts running; one that fails is not counted. Past the quota, a new run fails. ## The burst limit Each credential has a burst limit of **about 100 requests per 10 seconds**, the same on every plan: * on ingest, per endpoint URL and per ingest key, and per [Data API](/data-api) key; * on `/admin/api` and `/mcp` together, per API key, per connected agent and per person signed in to the console. Over it, the request is refused with `429` and a `Retry-After: 10` header. The burst limit is a best-effort burst guard, not an exact count. It runs on Cloudflare's rate limiting, where 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 Cloudflare locations is counted once at each. It is there to stop a runaway sender, not to meter one. The monthly event quota is separate from it, and exact. The customer portal's API has the same kind of guard, at about 60 calls a minute per portal token. ## When you reach a limit | Limit | What happens | | - | - | | Event quota | New events are refused with `429` and not stored, until the 1st or an upgrade. | | Delivery allowance | Events are stored, and their deliveries held until someone sends them. | | AI tokens or AI trigger runs | The AI step or trigger run fails with the reason. | | Destinations or sources | Creating one more is refused with `402`. After a downgrade, the newest over the cap are paused: see [When your plan changes](/destinations#when-your-plan-changes). | | Live stream connections | One more connection is refused with `429`; the console's **Live** mode refreshes every 10 seconds instead. | | Active workflow runs | A record that would start one more run does not start it, and the audit log records `workflow_start_skipped`. | | Endpoints, cron or WebSocket triggers | Creating one more in the project is refused: `402` for an endpoint, `409` for a trigger. | | Workspace members | Creating or accepting an invite is refused with `403` and `"code": "member_limit_reached"`, with an `upgrade_url`. Nobody already in the workspace is removed, including after a downgrade. See [Team members](/team-members#member-limits). | | Burst limit | `429` with `Retry-After: 10`. | # Projects Source: https://docs.hookie.ai/projects The unit everything in Hookie belongs to: what a project holds, the Default project, and who can create, rename and delete one. A **project** is the unit everything in Hookie belongs to. Endpoints, ingest keys, datasets and their records, rules, destinations and their deliveries, database sources, AI triggers, cron and WebSocket triggers, workflows, the customer portal and Data API keys each belong to exactly one project. A project lives in a **workspace**, which is your account. The workspace holds its [members](/team-members), the plan and billing, API keys, connected agents, the network allowlist and the audit log. Use projects to keep apart what should not see each other's events: staging and production, say, or one customer's pipeline and another's. ## What stays inside a project * **An event fans out only inside its project.** A record is sent to its own project's destinations, starts its own project's workflows and AI triggers, and appears on its own project's live stream. * **Rules see only their project's events.** A rule never routes, reshapes or stores an event sent to another project. * **Credentials are pinned to one project.** An ingest key sends into, and streams from, its own project only, and a Data API key reads its own project's datasets. An endpoint's URL names its project. * **Datasets are per project.** Two projects can each have an `orders` dataset, and they are two different datasets. Plan limits are the exception: most are counted across the whole workspace. See [Limits in a project](#limits-in-a-project). ## The Default project Every workspace is created with a project called **Default**, whose slug is `default`. It starts empty: add an endpoint to it, or create projects of your own and leave it alone. The Default project cannot be deleted, so a workspace always has somewhere for events to land. The admin API's top-level routes, such as `/admin/api/rules`, are shortcuts to the Default project. Everywhere else, name the project: `/admin/api/projects/{project_id}/…`. ## Create a project Open **Projects** and choose **Create a project**. Give it a **Name** (up to 80 characters) and pick an option under **Start with**: | Choice | What you get | | - | - | | **Endpoints — one endpoint, ready to receive events** | The project and one endpoint, named after the project with "endpoint" added (`Orders endpoint` for a project named `Orders`), that stores into the dataset `default`. The project opens on its **Endpoints** tab with that endpoint's URL and **Send test event** in view. | | **AI trigger — an empty project, for running a model against events** | The project, with nothing in it yet. It opens on **Overview**. | The choice only decides what the project starts with: either kind can have endpoints, AI triggers and workflows added later. It is shown as the project's **Type**, **Endpoints** or **AI trigger**, on **Home**, in the **Projects** list and in the project's **Settings**. The API and the MCP tools keep the stored values `webhook` and `ai_trigger`. Only owners and admins can create projects. Over the API, send `POST /admin/api/projects` with `{"name": "Orders", "type": "webhook"}`, or `"type": "ai_trigger"`. A connected agent with `hookie:manage` uses `create_project`. Hookie does not limit how many projects a workspace has. ## The project slug Hookie makes a slug from the name, with a short random suffix, such as `orders-x7k2qa`. It is the second segment of every endpoint URL in the project: ```http theme={"dark"} https://app.hookie.ai/{workspace}/{project}/{webhook} ``` Renaming a project keeps its slug, so no sender needs a new URL. Owners and admins rename a project in its **Settings**, with the pencil icon (**Rename project**). ## Project settings A project's **Settings** tab holds: * its **Name**, **Slug** and **Type**; * **Configuration**: **Export JSON**, **Export YAML**, **Import configuration (JSON)** and **Clone project**. These copy the project's endpoints, rules, destinations, workflows, triggers and record views, but never its secrets, keys or endpoint URLs. See [Export, import and clone a project](/api-reference#export-import-and-clone-a-project); * the project's [customer portal](/customer-portal) and its [Data API](/data-api); * **Delete this project**. A project can also carry its own IP allowlist, set over the API with `ip_allowlist` on `PATCH /admin/api/projects/{project_id}`. Ingest enforces it together with the endpoint's or ingest key's own list and the workspace's: a request must pass every list that is set. ## Delete a project Only the workspace owner can delete a project. Choose **Delete project** in its **Settings** and type the project's name to confirm. Deleting removes the project's configuration: its endpoints, ingest keys, rules, destinations, sources, triggers, workflows, portal and Data API keys. Any workflow run still going is stopped. What the project received is kept as history: its records, raw submissions and deliveries stay, no longer belonging to any project, until your plan's retention window prunes them. To delete data itself, see [Delete, export and close](/data-deletion). ## Limits in a project | Limit | Counted | Free | Pro | Team | | - | - | - | - | - | | Endpoints, versions included | per project | 10 | 10 | 10 | | Cron triggers | per project | 10 | 10 | 10 | | WebSocket triggers | per project | 5 | 5 | 5 | | Destinations | per workspace | 1 | 10 | 25 | | Database sources | per workspace | 0 | 3 | 10 | The monthly event, delivery and AI quotas are counted per workspace too. [Limits and plans](/limits-and-plans) has every number. ## Who can do what * Everyone in a workspace can read every project. * Owners, admins and developers create and change what is inside a project: endpoints, ingest keys, rules, destinations, workflows, sources and AI triggers. * Owners and admins create and rename projects, and manage cron and WebSocket triggers. * Only the owner deletes a project. See [Roles, API keys and connected agents](/roles-and-agents). # Quickstart Source: https://docs.hookie.ai/quickstart Create a project, send a test event, find its record, and watch a signed delivery reach a URL of yours. This walks one event all the way through Hookie: into an endpoint, into a dataset as a record, and out to a destination as a signed delivery. Everything here works on the Free plan. ## 1. Create a project [Create an account](https://app.hookie.ai/#/signup) with **Email me a code** (a 6-digit code, valid for 10 minutes) or **Continue with GitHub**. There is no password. Your first sign-in creates your workspace, with an empty project called **Default**. Open **Projects** in the side navigation and choose **Create a project**. Give it a name, such as `Orders`, leave **Start with** on **Endpoints — one endpoint, ready to receive events**, and choose **Create**. The project opens on its **Endpoints** tab. Hookie has made one endpoint for you, named after the project (`Orders endpoint`), tagged **new**, and storing what it receives in the dataset `default`. Rather use the Default project? Open it, choose **Add endpoint** on its **Endpoints** tab, and give the endpoint a name and a dataset. Until your workspace has an endpoint, an event and a destination, **Home** shows a **Get started** checklist that links to each step below. Each step is ticked off from what the workspace really holds, not from what you clicked. ## 2. Copy the endpoint URL The endpoint's card shows its public URL, with a copy button: ```http theme={"dark"} https://app.hookie.ai/{workspace}/{project}/{webhook} ``` The last segment is a long random value that Hookie generates, and it is the endpoint's only credential: anyone who has the URL can send events to it. Paste it into a provider's webhook settings, a form's `action`, or your own code. If it leaks, **Rotate URL…** in the card's menu gives the endpoint a new URL and keeps the old one working for an overlap you choose. **Show curl** on the card gives you a ready-to-paste command, and so does the project's **Overview** tab, under **Send events to this project**. ## 3. Send a test event Choose **Send test event** on the endpoint's card. It is a button under the URL, next to **View records** and **Show curl**, not an item in the card's menu. Owners, admins and developers see it, and it is disabled while the endpoint is disabled. The dialog holds a sample JSON payload under **Event payload (JSON)**. Edit it if you like, then choose **Send test event**. The dialog posts to the endpoint's real URL, so the event is stored, routed and fanned out exactly like one from a provider, and it counts against your monthly event quota like one. It then shows three stages: * **Sending**, then **Waiting for acknowledgement**, with the status and the time taken, such as `201 · 143 ms`. * **Fan-out to destinations**, which follows the event for up to 30 seconds. With no destination yet, it says the event was stored and that no enabled destination accepts its dataset, and it offers **Add a destination to forward events**. **Follow this event in Observability** opens the event's whole chain. You can send the same kind of event from a terminal: ```bash theme={"dark"} curl -X POST 'https://app.hookie.ai/your-workspace/orders-x7k2qa/aBcXyz…' \ -H 'Content-Type: application/json' \ -d '{"order":1042,"email":"ada@example.com","total":59.9}' ``` ```http theme={"dark"} 201 Created { "submission_id": "9d3f…", "routed": ["default"], "records": 1 } ``` `submission_id` names the raw body Hookie stored, and `routed` lists the datasets the event landed in. ## 4. See the record Choose **See the record in default** in the dialog, or **View records** on the endpoint's card. Both open the endpoint's dataset on the project's **Datasets** tab, newest record first. Open a record with its eye icon (**Open record**) to see all of it, and the **Request** it arrived in: the method, content type, source IP, query string and headers, such as `X-GitHub-Event`. Credentials such as `Authorization` and `Cookie` are stored as `[redacted]`. A record is what Hookie routed into a dataset. The body exactly as it arrived is kept as well, as a **submission**: find it on the **Observability** tab under **Submissions**. **Export CSV** and **Export XLSX** on the dataset export all of it, not just the page you see. See [Datasets and records](/datasets-and-records). ## 5. Add a destination A **destination** is an https URL of yours that Hookie sends each new record to, signed. For this step, any https URL that answers with a `2xx` status will do, such as a request inspector or a route on your own server. Open the project's **Connections** tab. Under **Destinations · outbound**, choose **Add destination** and fill in: * **Name**, such as `My server`; * **Type**: leave **HTTPS webhook** selected. A destination can also post to [Slack or write to an S3-compatible bucket](/destinations); * **URL**, which must be `https`; * **Dataset filter (optional)**: leave it empty to receive every dataset. Choose **Create**. Hookie shows the destination's signing secret (`whsec_…`) under **Signing secret — shown once**. Copy it: your receiver uses it to check that a request came from Hookie. Owners and admins can see it again later with **Reveal secret** on the destination's tile. The Free plan runs one destination. See [Limits and plans](/limits-and-plans). ## 6. Watch the delivery arrive Go back to the **Endpoints** tab and choose **Send test event** again. This time **Fan-out to destinations** ends with `Delivered to My server (200).` If your URL refused the request or could not be reached, the dialog says so, and whether Hookie will try again. Now open the project's **Deliveries** tab. Each row is one **delivery**: one event sent to one destination. | Column | Shows | | - | - | | **Status** | `queued`, `delivered`, `retrying` (with the next attempt's time), `failed`, `dead`, `paused` or `cancelled`. | | **Destination** · **Dataset** | Where it went, and the dataset of the record it carried. | | **Response** | Your URL's status code and how long it took to answer. | | **Attempts** | Attempts made, out of eight: `1/8` when the first try arrived. | The eye icon (**Details**) lists every attempt and the latest response body your URL sent back. A delivery that failed for good has a **Replay** button. Every delivery is signed twice with the destination's secret: as `Hookie-Signature`, and as the [Standard Webhooks](https://www.standardwebhooks.com) headers. It also carries `Hookie-Event-Id`, which stays the same on every retry and replay of that event. [Verify the signature](/signatures) before you trust a request, and dedupe on the event id, because delivery is at-least-once. [Destinations and deliveries](/destinations-and-deliveries) covers retries, holds and replays. ## Trace an event later The **Observability** tab searches **Events**, **Submissions**, **Deliveries**, **AI runs** and **AI calls** over the last 24 hours by default. Widen it with **Time range**, down to a custom window to the minute. Each kind has its own filters, such as a delivery's status, destination and response code, or a submission's routing status. **Trace an id** takes any record, submission, delivery, AI run or workflow run id, such as the `Hookie-Delivery-Id` your receiver logged, and shows the chain it belongs to. Switch on **Live** to watch events arrive as you send them. The filters are kept in the page address, so a search can be shared as a link. When a provider says Hookie returned an error, choose **Activity** in the endpoint's menu. It lists what the endpoint received and refused over the last 7 days, and why each request was refused: IP allowlist, rate limit, quota, disabled endpoint, content type, signature and more. Refused requests never count against your quota. ## Point an HTML form at it An endpoint accepts what a browser actually posts, so a plain form needs no JavaScript and no backend of your own. Set `action` to your endpoint URL: ```html theme={"dark"} ``` The fields arrive as an ordinary record, stored whole in the endpoint's dataset: ```json theme={"dark"} { "name": "Ada Lovelace", "email": "ada@example.com", "message": "Hello, world!" } ``` Both form encodings work: `application/x-www-form-urlencoded` and `multipart/form-data`. A few details worth knowing: * A field that repeats (checkboxes, a multi-select) becomes an **array** rather than losing all but one value. * Field names are kept **verbatim and flat**. A field called `user.email` stays the key `user.email` and does not become a nested object, so a mapping sees exactly what the form sent. * Every form value arrives as **text**. `gt`, `gte`, `lt` and `lte` still compare a field such as `amount=150` as the number 150. In workflows and triggers `equals` and `in` compare exactly, so match `"150"` there, not `150`; rules and endpoint criteria compare as text, so either works. * A **file** part is recorded as a descriptor and its bytes are discarded, in the record **and** in the raw submission. Hookie stores records, not files, so you get proof something was attached without the contents: ```json theme={"dark"} { "email": "grace@example.com", "attachment": { "filename": "report.pdf", "type": "application/pdf", "size": 18244 } } ``` * Everything else is still parsed as JSON regardless of its `Content-Type`, which is what most webhook providers send. A body that is not JSON but is sent as text, such as `text/plain`, XML, CSV or NDJSON, is stored as `{ "body": "", "content_type": "text/plain" }`. See the [API reference](/api-reference#ingest). ### Send visitors to a thank-you page Because the browser navigates to the endpoint on submit, a visitor lands on Hookie's own thank-you page, with a link back to your form. To send them to your page instead, give the endpoint a redirect URL: on the **Endpoints** tab, choose **Edit…** in the endpoint's menu and set **Redirect after a form submit** to your page (it must be `https://`). A native form post is then answered `303 See Other` to that page once the submission is stored. `fetch()` calls and server senders still get the JSON, and a resubmitted form that was already stored is sent to the same page. ### Submit with fetch() To keep the visitor on your page, submit with `fetch()`. A browser only lets a page read the answer from an origin that allows it, so add your site under **Allowed browser origins** in the same form: one origin per line, such as `https://example.com`, or `*` for any page. Nothing is allowed by default. For an allowed origin the endpoint answers the `OPTIONS` preflight that a JSON body triggers, and every response carries `Access-Control-Allow-Origin`, so your page can show the error on a `429` too. ```js theme={"dark"} const res = await fetch("https://app.hookie.ai/your-workspace/contact-3kq8zt/aBcXyz…", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, message }), }); if (res.ok) showThanks(); ``` A `FormData` body needs no preflight, because it is a form encoding. Without an allowed origin the submission is still stored, but your page cannot read the answer, and `fetch()` rejects with "Failed to fetch". `hookie forms init contact` sets all of this up in one command, with spam protection and field checks. See [Forms](/forms). ## Safe retries A retry returns the original submission (`200`, with `"idempotent": true`) instead of creating a duplicate, and it never counts twice against your quota. Hookie recognises the delivery ids that webhook providers already send and keep across their retries, so Stripe, GitHub, Shopify, GitLab, Twilio, Linear, Twitch, Atlassian, Slack, Svix and every [Standard Webhooks](https://www.standardwebhooks.com) sender are deduplicated with nothing to configure. `deduplicated_by` in the answer says which id matched. Duplicates are tracked per endpoint, so two endpoints never collide. From your own code, send an `Idempotency-Key`: ```bash theme={"dark"} curl -X POST 'https://app.hookie.ai/your-workspace/orders-x7k2qa/aBcXyz…' \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042" \ -d '{"order":1042,"total":59.9}' ``` ## Send from your own servers A server can send with an **ingest key** (`ik_live_…`) instead of an endpoint URL. Create one on the project's **Ingest keys** tab: it is shown once, stored hashed, and can carry an expiry and an IP allowlist. ```bash theme={"dark"} curl -X POST 'https://app.hookie.ai/v1/ingest/ik_live_xxx/orders' \ -H "Content-Type: application/json" \ -d '{"order":1042,"total":59.9}' ``` With a dataset at the end of the path, as here, the payload is stored whole in that dataset. Leave the dataset out and the project's **rules** decide where it goes, and in what shape. An event no rule matches is stored whole in the key's default dataset (`default` unless you chose another when you created the key), and the response says `fallback: true`. Rules run only on `POST /v1/ingest/{ingest_key}` with no dataset in the path. An endpoint URL always writes to the endpoint's own dataset and never consults rules, so adding a rule and then posting to an endpoint URL changes nothing. See [Endpoints and ingest keys](/endpoints-and-keys) and [Rules and mappings](/rules-and-mappings). ## Next * Read the **Concepts** pages, starting with [Projects](/projects), to see how the pieces fit together. * [Verify signatures](/signatures) on your receiver. * [Tail the live stream](/streaming) over SSE or WebSocket. * [Browse the API reference](/api-reference). * Look up any term in the [Glossary](/glossary). # Roles, API keys and connected agents Source: https://docs.hookie.ai/roles-and-agents Who can do what in a workspace: the owner, admin, developer and viewer roles, and the two credentials that act for a person, API keys and connected agents, with their scopes. ## People in a workspace A workspace has exactly one **owner**, whose first sign-in created it, and any number of **admins**, **developers** and **viewers**, up to its plan's member limit. The owner and admins invite people with a shareable link, choose the role each one joins with, and later change a member's role or remove them in **Settings → Members**. [Team members](/team-members) covers inviting, removing, transferring ownership and switching between workspaces. **Settings → Members** and **Settings → Plan & billing → Your access** show your role. Nobody can grant a role above their own. Only the owner can make someone an admin, an admin cannot change the owner or their own role, and `owner` is never granted: it moves only by transferring ownership. The two credentials that act for a person are held to the same roles. An [API key](#api-keys) carries a role, and a [connected agent](#connected-agents) is clipped to one by the access you grant it. Either one is also capped by its person's current role, so changing someone's role changes what their keys and agents can do on the next request. ## Roles Each role can do everything the role below it can: | Role | Adds | | - | - | | **Owner** | Billing, inviting and promoting admins, transferring ownership, deleting a project, changing the workspace slug, and closing the workspace. | | **Admin** | Creating and renaming projects; cron and WebSocket triggers; purging a dataset; revealing and rotating a destination's signing secret; Broadcast Logs and the Data API; inviting developers and viewers, and changing or removing members other than the owner; and workspace settings: the name, API keys, network access, the audit log export and **Export all data**. | | **Developer** | Creating, changing and deleting what is inside a project: endpoints, ingest keys, rules, destinations, workflows, database sources, AI triggers, AI agents and the customer portal. Also sending test events, replaying deliveries and deleting single records. | | **Viewer** | Reading everything: projects, datasets and records, endpoints, rules, destinations, deliveries, triggers, workflows and the audit log. | A `member` role, from before these four existed, reads as viewer. Single sign-on through your own identity provider (SAML or OIDC) is planned and not available yet, on any plan: **Settings → Single sign-on** says **Coming soon**. People sign in with an emailed code or with GitHub. ### How you signed in The account panel, your profile in the console's top bar, shows **Signed in with** and the method this session used, such as **Emailed code** or **GitHub**. It also says what guards the account: anyone who can read your email can sign in with an emailed code, whichever method you used, so your account is as safe as your inbox. Protect that mailbox with its own two-step verification. Hookie does not offer multi-factor authentication (an authenticator-app step) yet. If an account is ever asked for one, sign-in stops with `mfa_required` and the message that this sign-in page does not support it yet; contact [support@hookie.ai](mailto:support@hookie.ai). Over the API, `GET /admin/api/me` reports the method as `signInMethod` (see [API reference](/api-reference)). ## API keys An **API key** (`hk_…`) lets a system with no browser, such as a CI job, a script or your own backend, call the admin API and the hosted MCP server at `/mcp`. Owners and admins create them in **Settings → API keys**, and only a person signed in to the console can: an agent or another key cannot. * **A key acts as the person who created it**, and its role, viewer, developer or admin, is a ceiling on that person's role. There is no owner role for a key, so billing is out of its reach. Inviting and member changes are too, at every role. If its creator leaves or is removed, the key stops working. * **A key can be limited** to one project, to an expiry date, and to an IP allowlist. * **A key is shown once.** Hookie keeps only its SHA-256 hash. Revoking a key stops its next request. [API keys](/api-keys) covers creating, using and revoking them. ## Connected agents A **connected agent** is a coding agent, such as Claude Code, Cursor or any MCP client, that you connect to `https://app.hookie.ai/mcp` over OAuth. It signs in as you, through WorkOS AuthKit, and then holds a token it refreshes on its own: there is no key to paste anywhere. The **Connect agent** button in the console's top bar opens **Connect an agent**, with the URL and the setup steps. What it may do is decided separately, by you, in **Settings → Connected agents**. A newly connected agent can only read. **Change access** gives it one of three levels, each including the one before: | Level | Scope | Acts, at most, as | | - | - | - | | **Read only** | `hookie:read` | viewer | | **Read and write** | `hookie:write` | developer | | **Administer** | `hookie:manage` | admin | An agent's authority is your own role narrowed to the level you granted, whichever is lower. A change applies on its next request, with no need to reconnect. **Revoke** cuts it off on its next request too, even though its token is still valid, because Hookie checks the grant on every call. At every level, an agent cannot touch billing, invite people or change who the members are, manage connected agents, create API keys, or reveal or rotate a destination's signing secret. It can read the member list at `hookie:read`. Everything it does is written to the audit log with you as the actor and the agent's client id beside it. [Connected agents](/connected-agents) lists every tool and what each level unlocks. ## The same ladder API keys and agents share one ladder, so the same words mean the same thing everywhere: | Scope | API key role | Workspace role | | - | - | - | | `hookie:read` | viewer | viewer | | `hookie:write` | developer | developer | | `hookie:manage` | admin | admin | | no scope reaches it | no key role reaches it | owner | A call above what a credential holds is refused with `403`. For an agent, the answer names the scope the call needs and links to **Settings → Connected agents**, so you can widen it. ## Other credentials Three more credentials exist, and none of them acts for a person or reaches the admin API: * An **ingest key** (`ik_live_…`) sends events into one project, and tails its live stream. See [Endpoints and ingest keys](/endpoints-and-keys). * A **Data API key** (`hdk_…`) reads the datasets and columns one project exposes. See [Data API](/data-api). * A **portal token** lets one of your customers into your [customer portal](/customer-portal). # Rules and mappings Source: https://docs.hookie.ai/rules-and-mappings How Hookie decides which dataset an event lands in and what shape it takes: rules on keyed ingest, criteria and mappings on endpoints, and the ten condition operators. Routing decides which dataset an event lands in, and which fields its record has. What decides depends only on how the event was sent: | Sent to | Dataset | Shape | | - | - | - | | An endpoint URL, `/{workspace}/{project}/{webhook}` | The endpoint's own | The endpoint's criteria and mappings | | `/v1/webhooks/{ingest_key}/{slug}` | That endpoint's own | That endpoint's criteria and mappings | | `/v1/ingest/{ingest_key}/{dataset}` | The one in the path | Stored whole | | `/v1/ingest/{ingest_key}` | Chosen by the project's **rules**; the key's default dataset when none matches | Each matching rule's mappings; stored whole when none matches | **Of the URLs you send events to, rules run on exactly one shape: `POST /v1/ingest/{ingest_key}` with no dataset in the path.** They also route events from a cron or WebSocket trigger that names no dataset. An endpoint URL never consults them, so adding a rule and then posting to an endpoint changes nothing. Rules are for a sender that cannot say where each event belongs. ## Rules A rule has a name, a dataset, conditions, mappings and a status. For an event sent with an ingest key and no dataset, every enabled rule in the key's project is tried: * **Every rule that matches writes its own record**, so one event can land in several datasets. Rules are not first-match. * **When no rule matches, the event is not lost.** It is stored whole in the ingest key's [default dataset](/endpoints-and-keys#ingest-keys), and the response says `fallback: true`. To see which events are falling through, open that dataset: anything there is a candidate for a new rule. Rules do not run on endpoint URLs, which apply the endpoint's own criteria and mappings. * **No conditions matches every event, and no mappings stores the payload whole.** Rules also route every event from a cron or WebSocket trigger that has no dataset. Every rule that matches writes its own record, and an event none of them matches is stored whole, as one record, in the `default` dataset. See [Triggers with no dataset](/workflows#triggers-with-no-dataset). For example, this event: ```bash theme={"dark"} curl -X POST 'https://app.hookie.ai/v1/ingest/ik_live_xxx' \ -H "Content-Type: application/json" \ -d '{"type":"refund","order":1042,"total":59.9}' ``` matches this rule, which stores `{"order_id": 1042, "amount": 59.9}` in the `refunds` dataset: ```json theme={"dark"} { "name": "Large refunds", "dataset": "refunds", "conditions": [ { "path": "type", "equals": "refund" }, { "path": "total", "op": "gte", "value": 50 } ], "mappings": [ { "path": "order", "key": "order_id" }, { "path": "total", "key": "amount" } ], "enabled": true } ``` A rule holds up to 10 conditions and 50 mappings. ## Conditions Every condition in a list must hold. A condition reads a dotted path into the event as it was sent, such as `customer.tier`, and tests it with one of ten operators: | Operator | In the console | Value | Matches when the field… | | - | - | - | - | | `equals` | equals | text, a number, a boolean or `null` | equals the value | | `not_equals` | does not equal | the same | does not equal it; a missing field counts as not equal | | `contains` | contains | text | is text that contains the value | | `exists` | exists | none | is present and not `null` | | `gt` · `gte` | is greater than · is at least | a number | is a number, or text that is one (such as `"150"`), greater than · at least the value | | `lt` · `lte` | is less than · is at most | a number | is a number, or text that is one (such as `"99.5"`), less than · at most the value | | `in` | is one of | a list of 1 to 100 values | equals one of them | | `regex` | matches pattern | a pattern | is text the pattern matches, within the [bounds on patterns](/workflows#regular-expressions) | * **Rules compare as text.** In rules and endpoint criteria, `equals`, `not_equals` and `in` compare as text, so `5` and `"5"` match. Workflows and triggers compare those three exactly, type included. * **Numbers sent as text still compare.** `gt`, `gte`, `lt` and `lte` read a field that is a number, or text that spells a plain decimal number: an optional `+` or `-`, digits and an optional fraction, with spaces around it ignored. So a form's `"150"` is 150. Any other text never matches, including hex (`"0x10"`), exponents (`"1e3"`), thousands separators (`"1,000"`) and empty text. The value you compare against must be a number. * **Text stays text.** `contains` and `regex` match only a field that is text. A condition that could never match, such as an unknown operator or `gt` against a value that is not a number, is refused with `400` when you save it. As in the example above, an exact match is stored in the `equals` shorthand, and every other operator with `op` and `value`. ## Mappings A mapping copies one field of the event into the record under a key you choose: `{"path": "customer.email", "key": "email"}`. A key starts with a letter, uses only letters, digits and underscores, and is used once. Three special paths exist: `$payload` (the whole event), `$submission.id` and `$submission.received_at`. A path the event lacks leaves its key out of the record. ## Criteria on endpoints An endpoint applies its own conditions, called **criteria**, and its own mappings to everything sent to it. They work like a rule's, with three differences: * The dataset is always the endpoint's own. * An event that fails the criteria is kept as a submission and makes no record. * They are set when the endpoint is created over the API, MCP or the CLI, and changed only by a new [version](/endpoints-and-keys#versions). The console's **Add endpoint** sets neither. An endpoint accepts at most 10 criteria, the same limit as a rule's conditions, and 50 mappings. Project rules are never tried on an endpoint. ## Build and test rules in the console On a project's **Rules** tab, choose **Add rule**. Until a project has a rule, the tab's empty state says the same as the warning above: rules run only on `POST /v1/ingest/{ingest_key}` with no dataset in the URL, and endpoint URLs do not run rules. The editor has a **Name**, a **Dataset** picker (one of the project's datasets, or a new name), a row per condition with its **Path**, **Operator** and value, a row per mapping with its **Payload path** and **Record key**, and a **Status** switch. A rule with no conditions is listed as **matches everything**. **Test against a sample event** runs the rule on a payload, prefilled with the project's most recent event, and stores nothing. **Test rule** answers **Matched**, with the record the rule would store, or **Not matched**. Each rule's menu has **Edit**, **Duplicate**, **Enable** or **Disable**, and **Delete**, which keeps the records the rule stored. Owners, admins and developers can change rules. Over the API, rules live at `/admin/api/projects/{project_id}/rules`, and `POST …/rules/test` with `{conditions, mappings, payload}` answers `{matched, record}` without writing anything. Connected agents use `create_rule`, `update_rule` and `delete_rule`. # Verify signatures Source: https://docs.hookie.ai/signatures Verify Hookie's outbound webhook signatures: Hookie-Signature or Standard Webhooks, in Node, Python, Go and Ruby. Every outbound delivery is signed with the destination's secret, a `whsec_`-prefixed value that Hookie generates when you create the destination. Each delivery is signed **twice**: with Hookie's own `Hookie-Signature` header, and with the [Standard Webhooks](https://www.standardwebhooks.com) headers, so any Standard Webhooks library can verify it. Verify either one before trusting the payload. You never have to write it down. The secret is encrypted at rest and masked in the console. It is shown in full when you create the destination, and owners and admins can reveal it again at any time: **Reveal secret**, the eye button on the destination's **Signing secret** row, fetches it on demand for them only, and every reveal is written to your audit log. **Rotate** issues a new secret and shows it to you. The secret it replaces keeps signing for **24 hours**: every delivery in that window carries two signatures, one per secret, so your receiver keeps verifying while you update it (see [Rotating the secret](#rotating-the-secret)). This page is about the deliveries Hookie **sends**. To have an endpoint check the signature a provider such as Stripe, GitHub or Shopify puts on the requests it **receives**, see [Verify provider signatures](/endpoint-verification). ## The headers Each delivery carries: ```http theme={"dark"} Hookie-Signature: t=1718000000,v1=5f2b8c…e11a webhook-id: 4c7b… webhook-timestamp: 1718000000 webhook-signature: v1,g0hM9SsE…1OE= Hookie-Event-Id: 9d3f2a10-… Hookie-Delivery-Id: 4c7b… Hookie-Hop: 1 ``` * `Hookie-Signature`: `t=,v1=`, an HMAC-SHA256 (Stripe-style). For 24 hours after a rotation it carries a second `v1`: `t=,v1=,v1=`. * `webhook-id`, `webhook-timestamp`, `webhook-signature`: the [Standard Webhooks](https://www.standardwebhooks.com) set. `webhook-id` is the delivery ID, the same on every retry of that delivery (a replay is a new delivery, with a new ID); `webhook-timestamp` is the same unix time as `t`; `webhook-signature` is `v1,`, and for 24 hours after a rotation `v1, v1,`, space-separated. * `Hookie-Event-Id`: the event ID. **Dedupe on this**, since delivery is at-least-once and unordered. * `Hookie-Delivery-Id`: the delivery, useful in logs. It stays the same across the retries of one delivery (every attempt is listed under it in the console's delivery drawer), and a replay is a new delivery with a new ID. * `Hookie-Hop`: how many times this event has passed through Hookie. If your service forwards the event into Hookie again, pass this header on: Hookie refuses an event at 8 hops, which stops a delivery loop. Deliveries **never follow redirects**. A `3xx` answer is recorded as a failed attempt naming the `Location` it pointed to, and is not retried: a redirect means the destination URL is wrong, so update it rather than letting a signed event be forwarded somewhere you did not choose. ## Two schemes, one secret You only need to verify one of them. `Hookie-Signature` is unchanged, so an existing receiver keeps working; the Standard Webhooks headers let you use an off-the-shelf library instead. | | `Hookie-Signature` | Standard Webhooks | | - | - | - | | Headers | `Hookie-Signature: t=,v1=` | `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,` | | Signed string | `.` | `..` | | HMAC-SHA256 key | the UTF-8 bytes of the **whole secret string**, `whsec_` included | the **base64-decoded** bytes of what follows `whsec_` (36 bytes) | | Signature encoding | lowercase hex | base64 | | During a rotation | `t=,v1=,v1=` | `v1, v1,` (space-separated) | If the destination has a body template (see [Destination options](/destinations)), both signatures cover the templated body as sent, not the envelope. Verification is the same. Both are computed from the **same** signing secret, but each derives its HMAC key the way its own scheme says, so the keys differ. Hookie's secrets are `whsec_` followed by 48 hexadecimal characters. Hex digits are all base64 characters and 48 is a multiple of 4, so the part after `whsec_` is already valid base64: it decodes to 36 bytes, and every Standard Webhooks library accepts the secret exactly as Hookie shows it. Do **not** hex-decode it, and do not use the text of it as the Standard Webhooks key: base64-decode the characters after `whsec_`. For either scheme: verify the exact **raw body bytes**, compare in constant time with **each** signature and accept the request if any of them matches, and reject a timestamp more than five minutes from now. ## Verify Hookie-Signature The signed string is `.`: the timestamp, a dot, then the exact request body bytes. Compute `HMAC-SHA256(secret, ".")` as lowercase hex, keyed with the whole secret string, and compare it with **each** `v1` in the header. ```js Node.js theme={"dark"} const crypto = require("crypto"); // Hookie-Signature: "t=,v1=[,v1=]". // Key: the secret string itself, whsec_ prefix included. Signed: ".". function verifyHookie(secret, header, rawBody, toleranceSeconds = 300) { // v1 repeats during a secret rotation, so collect every one. (Object.fromEntries // would keep only the last, which is the PREVIOUS secret's signature.) const pairs = String(header ?? "").split(",").map((kv) => kv.split("=")); const t = pairs.find(([k]) => k === "t")?.[1]; const signatures = pairs.filter(([k]) => k === "v1").map(([, v]) => v); if (!t || signatures.length === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // replay protection const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); return signatures.some((sig) => { const given = Buffer.from(sig); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } ``` ```python Python theme={"dark"} import hashlib, hmac, time # Hookie-Signature: "t=,v1=[,v1=]". # Key: the secret string itself, whsec_ prefix included. Signed: ".". def verify_hookie(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: pairs = [p.split("=", 1) for p in (header or "").split(",") if "=" in p] t = next((v for k, v in pairs if k == "t"), None) signatures = [v for k, v in pairs if k == "v1"] # two during a secret rotation if not t or not t.isdigit() or not signatures or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected.encode(), sig.encode()) for sig in signatures) ``` ```go Go theme={"dark"} package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) // VerifyHookie checks Hookie-Signature: "t=,v1=[,v1=]". // Key: the secret string itself, whsec_ prefix included. Signed: ".". func VerifyHookie(secret, header string, rawBody []byte) bool { var t string var sigs []string for _, part := range strings.Split(header, ",") { k, v, _ := strings.Cut(part, "=") switch k { case "t": t = v case "v1": sigs = append(sigs, v) // two during a secret rotation } } unix, err := strconv.ParseInt(t, 10, 64) if err != nil || len(sigs) == 0 || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := []byte(hex.EncodeToString(mac.Sum(nil))) for _, sig := range sigs { if hmac.Equal(expected, []byte(sig)) { return true } } return false } ``` ```ruby Ruby theme={"dark"} require "openssl" # Hookie-Signature: "t=,v1=[,v1=]". # Key: the secret string itself, whsec_ prefix included. Signed: ".". def verify_hookie(secret, header, raw_body, tolerance = 300) pairs = header.to_s.split(",").map { |p| p.split("=", 2) } t = pairs.find { |k, _| k == "t" }&.last signatures = pairs.select { |k, _| k == "v1" }.map(&:last) # two during a secret rotation return false if t.nil? || signatures.empty? || (Time.now.to_i - t.to_i).abs > tolerance expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}") signatures.any? { |sig| OpenSSL.secure_compare(expected, sig.to_s) } end ``` ## Verify Standard Webhooks The signed string is `..`. Compute its HMAC-SHA256 as base64, keyed with the base64-decoded bytes after `whsec_`, and compare it with the signature after `v1,` in **each** space-separated entry of `webhook-signature`. Any [Standard Webhooks](https://www.standardwebhooks.com) library verifies these headers with the secret as-is: `standardwebhooks` on npm, PyPI and RubyGems, and `github.com/standard-webhooks/standard-webhooks/libraries/go` for Go. For example, in Node: `new Webhook(secret).verify(rawBody, headers)` (from `standardwebhooks`), which throws when no signature matches. Or, without a library: ```js Node.js theme={"dark"} const crypto = require("crypto"); // Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature ("v1,[ v1,]"). // Key: the base64-DECODED bytes after "whsec_". Signed: "..". // headers: an object with lower-case names (Express's req.headers is one). function verifyStandardWebhook(secret, headers, rawBody, toleranceSeconds = 300) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false; const key = Buffer.from(secret.slice("whsec_".length), "base64"); const expected = Buffer.from(crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64")); // Space-separated: two signatures during a secret rotation. return signatures.split(" ").some((versioned) => { const [version, sig] = versioned.split(","); const given = Buffer.from(sig ?? ""); return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } ``` ```python Python theme={"dark"} import base64, hashlib, hmac, time # Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature ("v1,[ v1,]"). # Key: the base64-DECODED bytes after "whsec_". Signed: "..". # headers: a case-insensitive mapping (Flask's request.headers, Django's request.headers). def verify_standard_webhook(secret: str, headers, raw_body: bytes, tolerance: int = 300) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not timestamp.isdigit() or not signatures: return False if abs(time.time() - int(timestamp)) > tolerance: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()) for versioned in signatures.split(" "): # two during a secret rotation version, _, sig = versioned.partition(",") if version == "v1" and hmac.compare_digest(expected, sig.encode()): return True return False ``` ```go Go theme={"dark"} package webhooks import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "net/http" "strconv" "strings" "time" ) // VerifyStandardWebhook checks webhook-id, webhook-timestamp and webhook-signature // ("v1,[ v1,]"). // Key: the base64-DECODED bytes after "whsec_". Signed: "..". func VerifyStandardWebhook(secret string, h http.Header, rawBody []byte) bool { id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") unix, err := strconv.ParseInt(ts, 10, 64) if id == "" || sigs == "" || err != nil || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(rawBody) expected := []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil))) for _, versioned := range strings.Split(sigs, " ") { // two during a secret rotation version, sig, _ := strings.Cut(versioned, ",") if version == "v1" && hmac.Equal(expected, []byte(sig)) { return true } } return false } ``` ```ruby Ruby theme={"dark"} require "base64" require "openssl" # Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature ("v1,[ v1,]"). # Key: the base64-DECODED bytes after "whsec_". Signed: "..". # headers: a hash with lower-case names (in Rails: request.headers["webhook-id"] etc.). def verify_standard_webhook(secret, headers, raw_body, tolerance = 300) id, timestamp, signatures = headers.values_at("webhook-id", "webhook-timestamp", "webhook-signature") return false if id.nil? || timestamp.nil? || signatures.nil? return false if (Time.now.to_i - timestamp.to_i).abs > tolerance key = Base64.strict_decode64(secret.delete_prefix("whsec_")) expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{raw_body}")) signatures.split(" ").any? do |versioned| # two during a secret rotation version, sig = versioned.split(",", 2) version == "v1" && OpenSSL.secure_compare(expected, sig.to_s) end end ``` ## Rotating the secret Rotating is safe to do while deliveries are flowing: 1. Press **Rotate** on the destination (owner or admin). The new secret is shown to you. 2. For the next 24 hours every delivery is signed with **both** secrets, new first: `Hookie-Signature: t=1718000000,v1=,v1=` and `webhook-signature: v1, v1,`. A receiver that accepts any matching signature (as the verifiers above and every Standard Webhooks library do) keeps working whichever secret it holds. 3. Update your receiver to the new secret at any point in that window. The destination shows when the previous secret stops signing. 4. Once every receiver is on the new secret you can press **Expire now** to stop sending the previous signature early; otherwise it stops on its own after 24 hours. Rotating again inside the window replaces the older of the two, so at most two secrets ever sign a delivery. A receiver that briefly needs to accept both can also hold both secrets and check each. ## Express handler ```js theme={"dark"} const express = require("express"); const app = express(); // Capture the RAW body — signature is over the exact bytes. app.post("/hook", express.raw({ type: "*/*" }), (req, res) => { const raw = req.body.toString("utf8"); const ok = verifyHookie(process.env.HOOKIE_SECRET, req.get("Hookie-Signature"), raw); // or: verifyStandardWebhook(process.env.HOOKIE_SECRET, req.headers, raw) if (!ok) return res.status(401).end(); const eventId = req.get("Hookie-Event-Id"); // dedupe on this — delivery is at-least-once const event = JSON.parse(raw); // … handle event … res.status(200).end(); }); ``` ## Check that Hookie sent it (Web Bot Auth) Production deliveries to a webhook destination also carry three headers from the [Web Bot Auth](https://datatracker.ietf.org/wg/webbotauth/about/) scheme, which is HTTP Message Signatures ([RFC 9421](https://www.rfc-editor.org/rfc/rfc9421)) with an Ed25519 key. They prove the request came from Hookie's bot. They do not prove the body is intact: use the payload signatures above for that. ```http theme={"dark"} Signature-Agent: "https://hookie.ai" Signature-Input: sig1=("@authority" "signature-agent");created=1718000000;keyid="kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k";alg="ed25519";expires=1718000300;nonce="…";tag="web-bot-auth" Signature: sig1=:…: ``` To check one: 1. Fetch Hookie's key directory at `https://hookie.ai/.well-known/http-message-signatures-directory`. It is a JSON Web Key Set, served as `application/http-message-signatures-directory+json`. 2. Find the key whose RFC 7638 thumbprint is the `keyid`. The `keyid` is the RFC 8037 thumbprint of the Ed25519 public key. Reject the request if no key matches. 3. Rebuild the signature base, three lines joined by newlines: `"@authority": `, then `"signature-agent": "https://hookie.ai"` (the header's value, quotes included), then `"@signature-params": ` followed by the value of `Signature-Input` after `sig1=`. 4. Verify the Ed25519 signature in `Signature` (the base64 between the colons) over that base with the key. Reject the request if `expires` has passed. Hookie signs a delivery to a **webhook** destination only. Slack, S3 and SQS destinations are not signed this way, and deliveries from a preview or test environment are never signed, so a test deployment cannot pass as Hookie's production sender. Each attempt of a delivery gets fresh headers, with its own `created`, `expires` and `nonce`. If a destination sends its own `Signature`, `Signature-Input` or `Signature-Agent` header, Hookie sends none of the three on that destination's deliveries. The partner's header goes out unchanged, and those deliveries are not signed this way. ## Notes * Verify against the **raw bytes**. Re-serializing the JSON will change the signature. * Reject the request if the headers are missing or no signature matches. Reject a timestamp more than five minutes old to limit replay. * Always compare with a constant-time function: `crypto.timingSafeEqual` (Node), `hmac.compare_digest` (Python), `hmac.Equal` (Go), `OpenSSL.secure_compare` (Ruby). * A destination's URL is fixed once created, and a signature is only meaningful against the URL it was sent to. To move a destination, create the replacement and delete the old one. # Slack app Source: https://docs.hookie.ai/slack-app Connect a Slack channel with Add to Slack, or install the Hookie Slack app, and see each webhook submission as an alert card. The **Hookie Slack app** posts each event a Slack destination sends as a card: a title and severity taken from the payload, its fields, and a **View in Hookie** button. It ships with Hookie as a Slack app manifest, so you create it in a few clicks and own it in your Slack workspace. The app asks for one permission, `incoming-webhook`. It can post to the channel you install it to, and it can read nothing. ## Add to Slack The quickest way in. If your project's **Connections** tab shows **Add to Slack** above the destinations, Hookie's own Slack app connects a channel in one step, with no app to create and no URL to copy. In the console, open your project's **Connections** tab and choose **Add to Slack**. You need a role that can add destinations. Slack asks you to allow Hookie to post to a channel in your Slack workspace. Pick the channel and choose **Allow**. You return to **Connections** with a new Slack destination named after the channel, such as `Slack #alerts (Acme)`. It delivers every dataset until you give it a **Dataset filter**. Hookie keeps only the channel's webhook URL, encrypted and shown masked, and discards the access token Slack also sends. To connect another channel, choose **Add to Slack** again. The new destination counts toward your plan's destination limit. If you cancel on Slack's screen, or the connection is started in one browser and finished in another, nothing is created and Connections says why. Choose **Add to Slack** again. ## Install your own copy of the app Use this when **Add to Slack** is not shown, or when you want the app to belong to your Slack workspace. In the console, open your project's **Connections** tab. Choose **Add destination**, then **Slack** under **Type**. Choose **Create the Hookie Slack app**. Slack opens **Create app from manifest** with the Hookie app filled in. Pick your Slack workspace, review it, and choose **Create**. Download the [Hookie app icon](https://app.hookie.ai/brand/hookie-slack-icon.png). In the app's settings, open **Basic Information**, and upload it under **Display Information**. Every card posts under the app's name and icon, so this is what your team sees beside each alert. Choose **Install to Workspace**, pick the channel the cards go to, and choose **Allow**. Under **Incoming Webhooks**, copy the URL. It starts `https://hooks.slack.com/services/`. Paste it into **Slack incoming-webhook URL**, name the destination, and choose **Create**. Anyone who holds the URL can post to the channel, so treat it like a password. Hookie stores it encrypted and only ever shows a masked hint. See [Slack destinations](/destinations#slack). If the link does not open, go to [api.slack.com/apps](https://api.slack.com/apps), choose **Create New App**, then **From a manifest**, and paste the manifest below. ```json theme={"dark"} { "_metadata": { "major_version": 1, "minor_version": 1 }, "display_information": { "name": "Hookie", "description": "Webhook submissions from Hookie, as alert cards in your channels.", "long_description": "Hookie receives your webhooks, files them into datasets and delivers them onward. This app posts each event a Hookie Slack destination sends as a card: a title from the payload, a severity badge and colour, the payload's fields, and a View in Hookie button that opens the dataset's records in the console. Install it to a channel, copy the channel's incoming-webhook URL, and paste it into a Slack destination on your project's Connections tab.", "background_color": "#0B0F19" }, "features": { "bot_user": { "display_name": "Hookie", "always_online": false } }, "oauth_config": { "scopes": { "bot": ["incoming-webhook"] } }, "settings": { "org_deploy_enabled": false, "socket_mode_enabled": false, "token_rotation_enabled": false } } ``` The manifest sets the app's name, description and background colour. Slack does not let a manifest carry an icon, which is why you upload it yourself. ## Send cards to more than one channel One incoming webhook posts to one channel. With **Add to Slack**, choose it again and pick the next channel. With your own copy of the app, open the app's **Incoming Webhooks** page, choose **Add New Webhook to Workspace**, and pick the channel. Then create a second Slack destination with the new URL. Give each destination a **Dataset filter** so each channel gets only the datasets it wants. For example, send `alerts` to `#on-call` and `orders` to `#sales`. ## What a card shows | Part | Where it comes from | | - | - | | **Title** | The payload's first non-empty `title`, `summary`, `subject`, `alertname`, `alert_name`, `message`, `name`, `event` or `type`. With none of these, **New event in** and the dataset. | | **Severity** | A badge and a coloured bar, when `severity`, `level`, `priority`, `status` or `state` holds a word Hookie recognizes. See [Severity](#severity). | | **Fields** | Up to 10 of the payload's other top-level values. Nested objects are flattened one level, such as `customer.email`. Each value is cut at 300 characters. | | **Payload** | The payload as pretty-printed JSON, only when the fields left something out: more than 10 values, a value that was cut, or a list of objects. It is cut at 2,500 characters. | | **View in Hookie** | A button that opens the dataset's records in the console. | | **Footer** | The Hookie logo, the dataset, the event id, and when the event arrived, in each reader's own time zone. | | **Bar colour** | Red, orange or green for a [severity](#severity). Hookie blue when the card has no severity or only `info`. | The notification text is the severity, the title and the dataset, such as `[CRITICAL] CPU above 90% on web-1 · alerts`. Everything from the payload is shown as text. A value such as `` does not notify anyone. For example, this payload: ```json theme={"dark"} { "title": "CPU above 90% on web-1", "severity": "critical", "host": "web-1", "labels": { "region": "us-east", "team": "infra" } } ``` becomes a card with a red bar, the title **CPU above 90% on web-1**, a **CRITICAL** badge, and the fields **host**, **labels.region** and **labels.team**. Every value fits on the card, so there is no JSON block. ### Severity | Bar | Words | | - | - | | Red | `critical`, `fatal`, `emergency`, `alert`, `error`, `high`, `urgent`, `p1`, `sev1`, `failed`, `failure`, `down`, `firing`, `outage`, `triggered` | | Orange | `warning`, `warn`, `medium`, `moderate`, `p2`, `p3`, `sev2`, `sev3`, `degraded`, `pending`, `unstable`, `acknowledged` | | Green | `ok`, `resolved`, `success`, `succeeded`, `healthy`, `up`, `passed`, `recovered`, `closed` | | Hookie blue (blue badge) | `info`, `informational`, `notice`, `low`, `debug`, `minor`, `p4`, `p5`, `sev4`, `sev5`, `open` | Matching ignores case and surrounding spaces. Any other value, such as `"status": "paid"`, is shown as an ordinary field and the card has the Hookie blue bar. ## Write your own message To post something other than the card, choose **JSON template** under **Body** on the destination. A template replaces the card, so it must be a Slack message body: ```json theme={"dark"} { "text": "Order {{data.order_id}} paid by {{data.customer.email}}" } ``` See [Body](/destinations#body) for placeholders. # Live streaming Source: https://docs.hookie.ai/streaming Tail Hookie events in real time over SSE or WebSocket, with resume. Watch events as they arrive over **Server-Sent Events** or a **WebSocket**. Use it for live dashboards, tailing, and dev feedback. Best-effort and lossy by design: the durable delivery guarantee is signed outbound webhooks. Use the stream to *observe*, not to guarantee receipt. ## Server-Sent Events ```bash theme={"dark"} # Server-Sent Events — key in the Authorization header curl -N https://app.hookie.ai/v1/stream \ -H "Authorization: Bearer ik_live_xxx" ``` Each event is an SSE frame; a control frame signals a gap if you resumed past the buffer: ```http theme={"dark"} id: 4217 data: {"seq":4217,"id":"9d3f…","dataset":"leads","received_at":"2026-07-10T12:00:00.000Z","data":{"email":"ada@example.com"}} event: stream-gap data: {"missed_from":4000,"resume_from":4180} ``` ## In the browser ```js theme={"dark"} // Browsers can't set headers on EventSource, so pass the key as ?key= const es = new EventSource("https://app.hookie.ai/v1/stream?key=ik_live_xxx"); es.onmessage = (e) => { const event = JSON.parse(e.data); // { seq, id, dataset, received_at, data } console.log(event.dataset, event.data); }; // The browser auto-resends Last-Event-ID on reconnect; a gap surfaces here: es.addEventListener("stream-gap", (e) => { const gap = JSON.parse(e.data); // { missed_from, resume_from } console.warn("missed events", gap.missed_from, "→", gap.resume_from); }); ``` ## Resume The buffer holds roughly the last 1,000 events (up to \~5 minutes). Resume from where you left off with the `Last-Event-ID` header (SSE sends it automatically on reconnect) or the `?after_seq=` query parameter. If your resume point has already aged out, Hookie emits a `stream-gap` event with the range you missed, then continues live. ## WebSocket ```bash theme={"dark"} # WebSocket — same endpoint, Upgrade header (resume with ?after_seq=) wscat -c "wss://app.hookie.ai/v1/stream?key=ik_live_xxx&after_seq=4180" ``` ## From the console Signed-in users get the same stream over the session-authenticated `/admin/api/stream` endpoint: no ingest key required, same SSE and WebSocket behavior, same resume. It is the endpoint to point a dashboard of your own at when you already have a browser session and would rather not mint an ingest key for it. The console tails it in **Live** mode. Switch on **Live** on a project's **Observability** or **Deliveries** tab: * **Observability** lists events as they are stored, newest first (the last 50), and re-runs your search as they land. Click an event's ID to trace its whole chain. * **Deliveries** re-reads the delivery list and destination health when an event arrives and every 10 seconds, because delivery outcomes and retries are not on the stream. **Pause** holds what arrives without dropping it; **Resume** shows it. A tab in Live mode is one stream connection against your plan's limit. When the stream refuses one (the limit is reached), the tab says so and refreshes every 10 seconds instead. Without Live, each view's **Refresh** button fetches new arrivals and shows when it last did. ## Limits * Concurrent connections are capped per plan (Free: 1, Pro: 5, Team: 20). * Authenticate with an active ingest key: a revoked key, or one past its expiry, is refused with `404`. Create and rotate keys in the project's [Ingest keys](/ingest-keys) tab. The stream is a read-only tail and never exposes stored data. A key streams **its own project's events only**: `project_id` is optional, and naming any other project answers `403`. * A suspended workspace cannot open a stream: `403` with `"reason": "workspace_suspended"`. # Team members Source: https://docs.hookie.ai/team-members Invite people to a workspace with a shareable link, give each one a role, remove or transfer ownership, switch between workspaces, and what each plan allows. A workspace has **exactly one owner** and any number of **admins**, **developers** and **viewers**, up to its plan's [member limit](#member-limits). Everyone signs in with their own account, and each role decides what that person can do: see [Roles](/roles-and-agents#roles). Everything on this page happens in **Settings → Members** (`#/settings/members`). Everyone in the workspace sees the member list there. Owners and admins also see **Invite people** and the pending invites. ## Invite someone Hookie does not send invite emails. It makes a link, and you send it to the person yourself, over chat, email or however you like. Choose **Invite people**. Only owners and admins have it. Pick the role the person joins with: **Admin**, **Developer** or **Viewer**. Only the owner can invite an admin, so an admin chooses between developer and viewer. Nobody can invite a second owner: ownership moves only by [transfer](#transfer-ownership). Choose **Create invite link**. The link, `https://app.hookie.ai/#/invite/…`, is shown **once**. Copy it and send it privately. Hookie keeps only a hash of it and cannot show it again, so if it is lost, create a new one. An invite link: * **Works once.** One person joins with it, and then it is spent. * **Expires after 7 days.** * **Can be revoked.** **Pending invites**, under the member list, shows each unused link's role, who created it, and when it was created and expires. Choose **Revoke** to stop one straight away. The list never shows the link itself. * **Lasts only as long as its creator's authority.** If the person who created it is removed, is demoted below admin, or (for a link to join as admin) is no longer the owner, the link stops working and leaves the list. A workspace can have at most **50** pending invites at once. Anyone who has a link can use it, so treat it like a password until it is used. ## Join a workspace If you are not signed in, choose **Sign in to join**, or **Create an account** if you are new to Hookie. You come back to the invite afterwards. The page shows who invited you, the workspace's name and address, and the role you would join with. Opening the link does not join you by itself. Join only if you expected the invite and recognise the workspace. You join with the invite's role, and the console opens the workspace. Joining does not create an empty workspace for you first, so someone new to Hookie who signs up through an invite starts in the workspace they were invited to. If you are signed in with the wrong account, choose **Use a different account**. A link that is wrong, has expired, was revoked or has already been used always shows the same message: **This invite link is not valid. It may have expired, been revoked or already been used: ask for a new one.** If you are already a member, the page offers to open the workspace instead, and the link is not spent. A link cannot be used while the workspace is [full](#member-limits) or suspended. The page tells you to ask the workspace's owner or an admin. ## Manage members Each row's menu in the member list has the actions you are allowed to take on that person. * **Change role…** Owners and admins change the roles of other members. An admin can change developers, viewers and other admins, but cannot make anyone an admin, cannot change the owner, and cannot change their own role. Only the owner can make someone an admin. * **Remove…** Owners and admins remove members, with the same limits. The owner cannot be removed. * **Leave workspace…** Anyone except the owner can leave, from their own row. The owner [transfers ownership](#transfer-ownership) first. If it was your only workspace, Hookie opens a new, empty one for you. When someone is removed or leaves: * They lose access at once. To come back, someone has to invite them again. * Their [API keys](/api-keys) for the workspace stop working on the next request, because a key acts as the person who created it. * The invite links they created are revoked, so a link they kept cannot bring them, or anyone they hand it to, back in. Demoting an admin revokes the links they could no longer create in the same way. A change of role applies on the person's next request, and so does the ceiling it puts on their API keys and [connected agents](/connected-agents). ## Transfer ownership Only the owner can do this. On another member's row, choose **Make owner…**, then **Transfer ownership**. * That member becomes the owner, and you become an **admin**. There is always exactly one owner. * Billing, the workspace slug, closing the workspace, and ownership itself become theirs. See [Billing](/billing). * Any links you created to join as admin are revoked, because only the owner can invite an admin. Only the new owner can give ownership back. ## Switch workspaces You can belong to more than one workspace: your own, and any you have been invited to. Once you do, the account panel (your profile, in the console's top bar) shows **Switch workspace**, with each workspace and your role in it. Choose one and the console reloads into it. Joining a workspace through an invite switches you to it. Everything you see, the projects, settings and billing, belongs to the workspace you are in. ## Member limits Every member counts, the owner included: | Plan | Members | | - | - | | **Free** | 1: the owner only | | **Pro** | Up to 3 | | **Team** | Unlimited, billed per seat | At the limit, **Invite people** is disabled and **Settings → Members** says the workspace is at its member limit. Over the API, creating or accepting an invite answers `403` with `"code": "member_limit_reached"`, `max_members`, `members` and an `upgrade_url`. Nobody already in the workspace is removed when it reaches the limit, including after a downgrade. On **Team**, each member is a billed seat. The subscription's seat count follows the number of members: when someone joins, is removed or leaves, Hookie updates it in Stripe, prorated. See [Billing](/billing#team-seats). ## Agents and API keys Inviting, changing roles, removing members and transferring ownership are for people in the console. A [connected agent](/connected-agents) or an [API key](/api-keys) is refused with `403` and `"code": "agent_not_permitted"` at every scope, even `hookie:manage`. An invite link gives a person access to your workspace, so no automated credential can create, read, revoke or use one. An agent at `hookie:read` or above, and an API key of any role for all projects, can read the member list: * the `list_members` MCP tool; * `hookie members list` in the [CLI](/cli/commands#members); * `GET /admin/api/members`. Each member comes with `user_id`, `email`, `name`, `role` and `joined_at`, owner first, and the response carries the plan's `max_members` (`null` means unlimited). ## Audit log Every change is recorded in **Settings → Audit log**: `member_invited`, `member_invite_revoked`, `member_invite_accepted`, `member_role_changed`, `member_removed`, `member_left` and `ownership_transferred`. On Team, seat updates are recorded as `seats_updated`, or `seat_sync_failed` if Stripe refused one. ## Over the API All of these are under `/admin/api`, and the [API reference](/api-reference) has the full contract. | Route | | | - | - | | `GET members` | The member list. Any role, agents and keys included (a key limited to one project cannot). | | `PATCH members/{user_id}` | Change a role, with `{"role": "admin" \| "developer" \| "viewer"}`. | | `DELETE members/{user_id}` | Remove a member, or leave when it is your own id. | | `POST members/transfer-ownership` | Make `{"user_id": …}` the owner. Owner only. | | `GET invites` · `POST invites` · `DELETE invites/{id}` | List pending invites, create one with `{"role": …}` (the response carries the `url` once), and revoke one. Owners and admins. `POST invites` refuses an `Idempotency-Key`, because the link is never stored for a replay. | | `POST invites/preview` · `POST invites/accept` | What a link's `{"token": …}` would join, and joining with it. | | `GET workspaces` · `POST workspaces/active` | The workspaces you belong to, and switching to one with `{"tenant_id": …}`. | While a workspace is suspended, its owner and admins can still revoke invites and remove members, and anyone but the owner can leave: taking access away is never refused. Inviting, changing roles and transferring ownership are. # Workflows & AI agents Source: https://docs.hookie.ai/workflows Multi-step, event-driven workflows built on a visual canvas, with branching, HTTP calls, AI steps and reusable AI agents. A **workflow** is a project-scoped, multi-step process that starts when a record lands in its *entry dataset*. Steps run in order, share a JSON *context* scratchpad seeded from the entry record, and can emit events, call AI or an AI agent, call an HTTP endpoint, set and reshape values, branch on a condition, wait for other events, or sleep. You compose pipelines like *lead → AI score → branch on score → notify → wait for approval → follow up* without running your own queue or state store. ## Build it visually Open a project's **Workflows** tab and choose **New workflow**, or **Edit** on an existing one. The builder is a canvas: the workflow runs from the **Trigger** at the top, down through its steps. | Part | What it does | | - | - | | **Add a step** | One button per step type. Choosing one adds the step where **Put new steps** says: after the selected step, into a selected branch's `then` or `else`, at the end of the workflow, or unconnected. You can also drag a step type onto the canvas. | | **Canvas** | Drag steps to arrange them, and drag from a step's handle to another step to connect them. A `branch` has three handles: **next** (bottom) for what runs after the branch, and **then** and **else** (right) for its two arms. Drag the background to pan; hold Ctrl (or ⌘) and scroll, or pinch, to zoom. The minimap shows the whole workflow. | | **Toolbar** | Undo, redo, duplicate, delete, auto-layout, zoom in, zoom out and fit to screen. | | **Edit panel** | The selected step's settings, as a form for its type: dataset pickers that list the project's datasets (or take a new name), an AI-agent dropdown, condition rows (path, operator, value), header and mapping rows, and JSON fields for payloads and bodies. **Connections** sets what runs next, or a branch's `then` and `else`, from a list, so you can connect steps without dragging. | | **Definition (JSON)** | Exactly what **Save** sends: the same definition the API, the CLI and MCP agents read and write. The canvas is a view of that definition, not a second format. | Everything works from the keyboard. Tab moves through the palette, the toolbar, the steps on the canvas and the edit panel. Enter selects a focused step, and the arrow keys move it. The **Edit** menu jumps to any step. Shortcuts: Ctrl+Z undo, Ctrl+Shift+Z (or Ctrl+Y) redo, Ctrl+D duplicate, and Delete removes the selected steps while the canvas has focus. Deleting a step from the middle of a chain reconnects the steps on either side of it. Duplicating one step puts the copy straight after the original. Step positions are not saved: opening a workflow, or **Auto-layout**, arranges it from its structure, with branch arms to the right of the branch. ### Checked before you save The builder checks the workflow as you edit and marks each problem on the step that has it, with a list above the canvas. **Save** stays blocked until there are none. It checks for: * an entry dataset on the trigger, and at least one step connected to it; * steps that nothing connects to the trigger (they would never run); * the step limit (32, counting steps inside branches) and branch nesting (3 levels); * a `delay` or `wait_for_event` inside a branch; * each step's own fields, such as a missing or non-https URL, a reserved header, instructions over 8,000 characters, a delay over 365 days, or a branch condition path that is not in the context (see below). Hookie's API runs the same checks again when you save, so a workflow saved from the API, the CLI or an agent is held to the same rules. ## Runs on the graph Every workflow's builder has a **Run history** section. Choose a run and each step on the canvas shows what happened to it in that run: completed, running, waiting, failed or skipped. Steps inside a branch show which arm ran. Below the graph, a table lists every step with its start and finish times and any error. An `emit_event` step's details also list the `delivery_ids` it queued, so you can open each delivery. A run that is pending, running or waiting can be stopped with **Cancel run**. A run that failed, was cancelled or timed out can be started again with **Retry run**. Both are also in the **Instances** dialog on the workflow list, which has a **Show on the graph** button for each run. ## Branching A `branch` step evaluates one or more conditions against the instance context, using the same condition shape used everywhere else in Hookie (see [Conditions](#conditions)), and runs `then` when they match, `else` otherwise. Because conditions read the context, you can branch on *anything an earlier step produced*: score a lead with AI, then route high-scorers one way and everyone else another. Branches nest up to **3 levels deep**, and every taken path is recorded in step history so you can see exactly which way an instance went. ### Where a branch condition's path points This is the one thing worth getting right before you write a branch, because the two condition fields on a workflow read **different things**: | Field | Evaluated against | Path to use | | - | - | - | | `entry_conditions` | the raw record | the bare field — `"topic"` (an `entry.` prefix is refused) | | a `branch` step's `conditions` | the instance context | `"entry.topic"` | The context is a scratchpad seeded from the entry record, and the record sits under `entry` there. Alongside it are the keys earlier steps wrote: `call_ai` puts its reply under its `output_key` (default `ai_result`), `agent_call` under `agent_result`, `http` under `http_result`, `transform` under `transformed`, `set` under each key it sets, and `wait_for_event` under `waited_event`. So `"entry.tier"` reads the incoming lead, and `"score"` reads what the AI step just produced — as in the example below. Writing a branch condition as `"topic"` when you mean `"entry.topic"` used to be silent: the path resolved to nothing, the comparison failed, and the branch took `else` on every event for the life of the workflow. Hookie now **refuses a branch whose condition path cannot resolve**, and the error names the keys that do exist. A path pointing at a key an earlier step has not written yet is refused for the same reason. Branch sub-steps run *synchronously*: every step type except `delay` and `wait_for_event`, which cannot sit inside a branch; put them after the branch instead. This keeps a branch's execution atomic and its outcome immediately visible on the graph. ### Branching on an AI answer `gt`, `gte`, `lt` and `lte` compare numbers, and a model replies with text, so what a `call_ai` or `agent_call` step puts in the context matters. A reply that *is* a JSON value is stored as that value; anything else is stored as the text the model wrote. So `"8"` becomes the number `8` and `{"score": 8, "reason": "…"}` becomes an object, while `"Looks like a good fit"` stays a string. Models do not always answer with bare JSON. A reply wrapped in a ` ```json ` fence, or introduced with a line such as "Here is the JSON:", is stored as the JSON inside it: the first fenced block that parses, otherwise the first `{…}` or `[…]` in the text that parses. A number inside a sentence ("8 out of 10") stays text. The run's step history says what each reply became (`output_type`), and flags `json_parse_failed` when a reply had a fence or a brace but no JSON could be read from it — check there first when a branch on an AI answer always takes `else`. That gives you two shapes that work, and it is worth picking one deliberately: * **Ask for the bare value**: "reply with the number only" with `"output_key": "score"`, then branch on `{ "path": "score", "op": "gt", "value": 7 }`. That is the example above. * **Ask for an object**: "reply with JSON: score and reason" with `"output_key": "ai"`, then branch on `{ "path": "ai.score", "op": "gt", "value": 7 }`. Conditions take dotted paths, so the reason travels with the score and both are visible in the instance context. What does *not* work is asking for JSON and then branching on the `output_key` itself: `path: "score"` would resolve to the whole object, and a numeric comparison against an object is false. That one is still accepted, because the path resolves; it is the *value* that cannot compare. The instance context is shown in the run's details, so check there first when a branch always goes the same way. Secret-looking values (credential-named keys, `Bearer …`/`Basic …` values and URL passwords) are shown as `[redacted]` there, in the step samples, over MCP and in the workspace export. The run itself always uses the real values. `output_dataset` is unaffected by any of this: it always records the model's reply as the text it actually wrote, so the audit trail is what was said, not what was inferred from it. ## Conditions Every place Hookie takes conditions uses one shape and the same ten operators: routing rules, endpoint criteria, workflow entry conditions, `branch` and `wait_for_event` conditions, WebSocket triggers and AI triggers. ```json theme={"dark"} { "path": "status", "op": "equals", "value": "paid" } { "path": "status", "equals": "paid" } ``` The second line is shorthand for the first. Every condition in a list must hold, and an empty list matches everything. A list holds at most 50 conditions. In a workflow's entry conditions, and in `branch` and `wait_for_event` conditions, a condition can also be a **group**. `{"any": [...]}` holds when one member holds, and `{"all": [...]}` holds when every member does. A member is a condition or another group, and groups nest at most two levels deep. A rule, an endpoint's criteria or a trigger refuses a group with `400`, because it tests one condition at a time. | Operator | Value | Matches when the field… | | - | - | - | | `equals` | a string, number, boolean or `null` | equals the value. | | `not_equals` | a string, number, boolean or `null` | does not equal the value. A missing field counts as not equal. | | `contains` | a string | is text that contains the value. | | `exists` | none | is present and not `null`. | | `gt` · `gte` | a number | is a number, or text that is one (such as `"150"`), greater than · at least the value. | | `lt` · `lte` | a number | is a number, or text that is one (such as `"99.5"`), less than · at most the value. | | `in` | a list of 1 to 100 strings, numbers, booleans or `null` | equals one of the values. | | `regex` | a pattern | is text the pattern matches. See [Regular expressions](#regular-expressions). | ```json theme={"dark"} { "path": "amount", "op": "gte", "value": 100 } { "path": "country", "op": "in", "value": ["US", "CA", "MX"] } { "path": "email", "op": "regex", "value": "@example\\.com$" } ``` * **Where equality is exact, and where it is text.** In workflows and triggers, `equals`, `not_equals` and `in` compare exactly, type included: `5` and `"5"` are different values. In rules and endpoint criteria they compare as text, as rules always have, so `5` and `"5"` are equal. * **Numbers sent as text still compare.** `gt`, `gte`, `lt` and `lte` match a field that is a number, and a field that is text spelling a plain decimal number: an optional `+` or `-`, digits, and an optional fraction, with spaces around it ignored. So an HTML form's `"150"` is 150, and a feed that sends a price as `"60000"` matches `{ "path": "price", "op": "gt", "value": 50000 }`. Any other text never matches, including hex (`"0x10"`), exponents (`"1e3"`), thousands separators (`"1,000"`) and empty text. The value you compare against must still be a number. `equals`, `not_equals` and `in` do not change: in workflows and triggers `"150"` still does not equal `150`. * **Text stays text.** `contains` and `regex` match only a field that is a string. A condition that could never match is refused when you save it, with a `400` that names the condition, rather than stored and silently never matching. That covers an unknown operator, a condition missing its value, both the shorthand and `op`/`value` at once (such as `{"path": "status", "op": "equals", "equals": "paid"}`), a key Hookie does not know, a numeric operator against anything but a number, `contains` or `regex` against anything but a string, an `in` without a list of 1 to 100 plain values, and a pattern outside the bounds below. A condition's value can be a `{{placeholder}}` only in a `branch` or `wait_for_event`, where there is an instance context to read it from. Anywhere else it would be compared as the literal text `{{…}}`, so it is refused. The values in an `in` list are never resolved, so a placeholder there is refused too. ### Regular expressions A `regex` value is a JavaScript regular expression, written without the surrounding slashes. It matches anywhere in the field unless you anchor it with `^` or `$`, and it is case-sensitive, so use a class such as `[Pp]aid` to match either case. Patterns run against every matching event on a shared service, so each one is bounded to keep a single event cheap: * **At most 200 characters**, and the pattern must compile. * **No backreferences**, such as `\1` or `\k`. * **No nested quantifiers.** A repeated group that itself repeats or alternates, such as `(a+)+`, `(a|b)*` or `(\w+\s?)*`, is refused. * **At most 2 open-ended repeats**: `*`, `+`, `{n,}`, or `{n,m}` with `m` above 16. `.*paid.*usd` is accepted; `.*a.*b.*c` is not. * **Only the first 256 characters** of the field are tested. A pattern that breaks a bound is refused when you save it. A stored pattern that breaks one (saved before these bounds existed) never matches; it is not run. ## Defining a workflow Build workflows on the canvas under **Project → Workflows**, or send the definition to the API. A workflow's menu also has **Rename…** and **Activate** or **Deactivate**, which change nothing else and keep its version, and **Delete**, which removes the workflow and its run history for good (see [Deleting a workflow](#deleting-a-workflow)). Over the API, `PATCH /admin/api/projects/{project_id}/workflows/{id}` merges what you send into the stored definition, so `{"active": false}` alone switches a workflow off, while `PUT` replaces the whole definition. The `update_workflow` tool and `hookie workflows update` send the whole definition. Either way it is this JSON; the builder's **Definition (JSON)** panel shows it for the workflow you are editing. Entry conditions use the shared condition shape; an empty array matches every record in the dataset. ```json theme={"dark"} { "name": "Lead qualification", "entry_dataset": "leads", "entry_conditions": [ { "path": "tier", "op": "equals", "value": "enterprise" } ], "steps": [ { "type": "call_ai", "instructions": "Score this lead 1-10 for ICP fit. Reply with the number only.", "output_key": "score", "output_dataset": "lead_qualification" }, { "type": "branch", "conditions": [{ "path": "score", "op": "gt", "value": 7 }], "then": [ { "type": "emit_event", "dataset": "hot_leads", "payload": { "priority": "high" } } ], "else": [ { "type": "emit_event", "dataset": "nurture_leads" } ] }, { "type": "wait_for_event", "dataset": "approvals", "conditions": [ { "path": "lead_id", "op": "equals", "value": "{{entry.id}}" }, { "path": "status", "op": "equals", "value": "approved" } ], "timeout_seconds": 86400, "on_timeout": "continue" }, { "type": "branch", "conditions": [{ "path": "waited_event", "op": "exists" }], "then": [ { "type": "emit_event", "dataset": "approved_leads", "payload": { "approved_by": "{{waited_event.approver}}", "score": "{{score}}" } } ], "else": [ { "type": "emit_event", "dataset": "escalations", "payload": { "reason": "no approval in 24h" } } ] }, { "type": "delay", "seconds": 3600 } ] } ``` ## Step types | Type | What it does | | - | - | | `emit_event` | Write a record into a dataset and enqueue signed deliveries for it. The record is the entry record's fields with an optional `payload` merged over them, and the payload is [templated](#templating) from the instance context, so `"summary": "{{triage.summary}}"` delivers what an AI step produced. An emitted record is delivered, streamed and offered to the project's AI triggers like any other, and it is written together with its fan-out, so a fan-out interrupted part-way is finished later rather than lost. It never re-triggers workflow entry, so there are no feedback loops. With `"deliver": false` the record is only stored and streamed. | | `call_ai` | Run an AI prompt over the instance context. The reply lands in the context under output\_key (default ai\_result), as a number or object if the model replied with one, so a later branch can compare it, and, verbatim as text, in output\_dataset. The output\_dataset record is delivered like any other record: to the project's destinations whose filter admits the dataset, the live stream and AI triggers. Like an emitted record, it never starts a workflow, not even one that starts on that dataset. | | `agent_call` | Invoke a reusable AI agent by ID: its model, system prompt, and memory dataset apply. The result lands under output\_key (default agent\_result), interpreted the same way call\_ai's is. In the builder, pick the agent from a list. | | `http` | Call an https URL on a public host with `method` (GET, POST, PUT, PATCH or DELETE), optional `headers` (up to 32) and a JSON `body` (up to 64 KB; not on GET). A `{{path}}` placeholder can appear after the host. The response lands in the context under output\_key (default http\_result). `timeout_seconds` is 1 to 30. | | `set` | Write `values` into the context. A value of exactly `"{{entry.email}}"` copies that context path; anything else is stored as written. | | `transform` | Build a new object from context paths: each field in `mapping` takes the value at its path. The object lands under output\_key (default transformed). | | `branch` | Conditional if/then/else. Evaluate conditions against the instance context and run the matching then or else sub-sequence, including branching on an earlier call\_ai or agent\_call result. Branches can nest up to 3 levels deep. | | `wait_for_event` | Suspend until a record matching the conditions arrives in a dataset. The conditions read the arriving record, and a value can come from this run, as in `"{{entry.order_id}}"` (see [Waiting for this run's record](#waiting-for-this-runs-record)). An optional `timeout_seconds` sets how long to wait. By default a timeout moves the instance to `timed_out`. With `"on_timeout": "continue"`, the run carries on with `waited_event` set to `null`. | | `delay` | Suspend for N seconds before the next step, accurate to the second. | ## Waiting for this run's record A `wait_for_event` condition's value can name the run's own context. Hookie resolves it once, when the run starts waiting, so the wait matches the record that belongs to this run and no other: ```json theme={"dark"} { "type": "wait_for_event", "dataset": "approvals", "conditions": [ { "path": "order_id", "op": "equals", "value": "{{entry.order_id}}" }, { "path": "status", "equals": "approved" } ], "timeout_seconds": 86400, "on_timeout": "continue" } ``` Without the first condition, every run waiting on `approvals` would take the first approval that arrived, whichever order it was for. With it, an approval for order `B2` resumes the run that started from order `B2` and leaves the others waiting. The placeholder has to be something the run has at that point: the entry record (`entry.…`) or a key an earlier step wrote. A placeholder that could never resolve is refused when you save. If the value is empty for a particular run (that entry record has no `order_id`), the run fails when it reaches the wait, rather than waiting for any record at all. ### When nothing arrives `"on_timeout": "continue"` turns a timeout into an answer. The run carries on with `waited_event` set to `null`, so a `branch` on `{ "path": "waited_event", "op": "exists" }` takes `then` when the record came and `else` when it did not — "nobody approved in 24 hours, so escalate". Without `on_timeout`, or with `"fail"`, a timeout ends the run as `timed_out`, as before. ## AI agents An **AI agent** is a reusable, named LLM persona: a model, a system prompt, a bounded token cap, and an optional *memory dataset* that every output is also written to for recall. Define agents next to your workflows in the console, then reference one from any workflow with an `agent_call` step, including inside a branch. Update the agent once; every workflow that uses it picks up the change. **Deactivate** an agent from its menu to switch it off: its definition is kept, a workflow step that names it fails until you **Reactivate** it, and nothing else changes. **Delete** removes it for good, after a warning; a step that names it then fails with not found. Over the API, `DELETE /admin/api/projects/{project_id}/ai-agents/{id}` only retires the agent, as the `delete_ai_agent` tool does, and `?permanent=true` removes it. ## Templating `emit_event` payloads, `http`, `set` and `transform` read the instance context with `{{path}}`, and so do the values of `branch` and `wait_for_event` conditions. A value that is exactly `"{{entry.email}}"` keeps its type (a number stays a number, an object stays an object). Inside longer text, as in `"https://api.example.com/users/{{entry.id}}"` or `"Ticket {{entry.id}}: {{triage.summary}}"`, the value is inserted as text; in a URL it is also URL-encoded, and it may only appear after the host. In an `emit_event` payload, a field whose whole value is a placeholder that resolves to nothing for a run keeps the entry record's value for that field, or is left out when the entry record has none. The step's details list such placeholders under `unresolved_placeholders`. A placeholder whose first part is not something the run can have (`entry`, `waited_event`, or a key an earlier step writes) is refused when you save, and the error lists the keys that do exist. ## Durable execution Workflows run on [Cloudflare Workflows](https://developers.cloudflare.com/workflows/). Each step is durable: a step that fails on a transient error is retried, a run survives restarts, and `delay` and `wait_for_event` suspend the run without holding anything open. A step that already completed is never run twice, so a retried run does not emit the same event again. An AI step is the exception: re-running it calls the model again, and counts against your AI allowance again. ## Instances & observability Each matching entry record starts an **instance** (a run). The console lists instances per workflow with a drill-down into the live context and a per-step history, and shows any run on the workflow's graph (see [Runs on the graph](#runs-on-the-graph)), so you can see exactly where a flow is and why it stopped. A running or waiting instance can be **cancelled**, and a failed, timed-out or cancelled one **retried** from the start, from the console, the API (`POST /admin/api/projects/{project_id}/workflow-instances/{id}/cancel` and `…/retry`), the CLI (`hookie workflows cancel|retry`) or a connected agent. Over the API, `GET /admin/api/projects/{project_id}/workflows/{id}/instances` takes `state`, `since`, `until`, `limit` and `offset`, and answers with a `total`. A run's context starts as a copy of the record that started it, so run history is kept for your plan's retention window, like the records themselves: a finished run (completed, failed, cancelled or timed out) is removed, with its step history, once both its start and its last change are older than the window. A run that is pending, running or waiting is never removed. Deleting a workflow removes its finished runs at once, whatever their age. | State | Meaning | | - | - | | `pending` | Created from a matching entry record; about to run. | | `running` | Executing steps. | | `waiting` | Suspended on a delay or wait\_for\_event. | | `completed` | Every step finished. | | `failed` | A step errored; last\_error records why. | | `timed_out` | A wait\_for\_event passed its deadline, and its `on_timeout` is not `continue`. | | `cancelled` | Stopped with **Cancel run**, or from the API, the CLI or a connected agent. | ## Deleting a workflow **Deactivate** switches a workflow off and keeps everything: it starts no new runs, runs in progress carry on, and **Activate** turns it back on. **Delete**, in the same menu, removes it for good after a warning: the workflow and its whole run history, meaning every run and its step history. Records and deliveries its runs produced, and the AI call log, are kept. A workflow with a run that has not finished (pending, running, waiting, or paused by a suspension) cannot be deleted. The refusal names up to 10 of those runs. Cancel them first (Instances → Details → **Cancel run**, `hookie workflows cancel`, or the `cancel_workflow_run` tool), and deactivate the workflow if it is still on so it starts no new ones. Over the API, `DELETE /admin/api/projects/{project_id}/workflows/{id}` only deactivates, as the `delete_workflow` tool does by default. With `?permanent=true` it deletes: * It answers `200` with `{"ok": true, "done": true, "deleted": {"runs": …, "step_events": …}}`. * It answers `409` with `"code": "unfinished_runs"` and the runs in `instances` while a run has not finished. * A history of more than 10,000 runs is deleted over several calls. Each answers `202` with `"done": false` and `remaining_runs`, and sending the same request again carries on. The workflow is switched off before its history is deleted, and each call is recorded in the audit log as `delete_workflow`. With the tool, pass `permanent: true`. With the CLI, run `hookie workflows delete --workflow-id … --permanent`, which asks before it runs unless you pass `--yes`. ## Limits * Up to **32 steps** per workflow (branch sub-steps count toward the total). * Branch nesting depth: **3** levels. * Active-instance cap per plan: **25** on Free, **500** on Pro, **2,000** on Team. * `delay` and `wait_for_event` timeouts are exact to the second, up to **365 days**. A wait with no timeout gives up after 365 days. * A definition's steps are at most **256 KiB** of JSON, and an instance's context at most **768 KiB**. `set` writes at most 64 keys. * AI steps are bounded to 1,024 output tokens and 8,000 characters of instructions per step. ## Start triggers A workflow starts when a record lands in its entry dataset. Besides events sent to an endpoint or with an ingest key, two kinds of trigger land records on their own. Both are under **Start triggers** on the Workflows tab, and owners and admins can create them. ### Cron triggers A cron trigger fires its payload as an event on a schedule: a 5-field cron expression (`minute hour day-of-month month day-of-week`) read in the trigger's **time zone**. `0 9 * * 1-5` in `America/New_York` fires at 09:00 New York time on weekdays, on both sides of a daylight-saving change. Give the zone as an IANA name. The console fills in your browser's zone, and the API defaults to `UTC`. * The scheduler runs every 5 minutes, so a schedule whose firings are closer than that is refused. `*/5 * * * *` is the finest one accepted. * At a daylight-saving change, a local time that happens twice fires once, at its first occurrence. A local time the change skips (02:30 on the night clocks go from 02:00 to 03:00) fires just after the gap instead of being skipped. * Creating a trigger returns its next three fire times. To see them before saving, send `POST /admin/api/projects/{project_id}/cron-triggers/preview` with `schedule` and `timezone`. * **Edit** changes the name, schedule, time zone, payload or dataset. A new schedule or time zone moves the next run. Resuming a paused trigger also moves it, so a trigger does not fire the moment it is resumed for a time that passed while it was paused. ### WebSocket triggers A WebSocket trigger holds a connection to a feed open and turns every frame that matches its [conditions](#conditions) into an event. The conditions are checked when you save, so a misspelled operator or a `gt` against text is an error there. Before this change, the trigger was saved, reported **connected**, and never matched a frame. `gt`, `gte`, `lt` and `lte` compare numbers, and a price the feed sends as text (`"60000"`) counts as one. See [Conditions](#conditions) for exactly which text counts. **Mappings** reshape each matching frame the way a rule's mappings do: each `{ "path": "data.p", "key": "price" }` picks a field out of the frame into a key of the event. With no mappings, the frame is kept as it arrived. The raw submission always keeps the frame exactly as received; the mapped event is what is filed into the trigger's dataset, or handed to the project's rules when the trigger names no dataset, and what `default` keeps when none of them matches. A mapping key must be a plain identifier (a letter, then letters, digits or underscores); anything else is refused with `400` when you create the trigger, rather than saved and then dropping every frame. Set mappings over the API (`POST /admin/api/projects/{project_id}/ws-triggers`), with a connected agent's `create_ws_trigger`, or with `hookie ws-triggers create --mappings`. * **Edit** changes the name, URL, connect message, conditions or dataset, each checked as it is when you create the trigger. A new URL, connect message, set of conditions or dataset reconnects the socket; a new name does not. Over the API, `PATCH /admin/api/projects/{project_id}/ws-triggers/{id}` also takes `headers` and `subprotocols`, and a connected agent's `update_ws_trigger` takes every one of those fields. A trigger keeps the `mappings` it was created with; to change them, create a new trigger. * The URL, headers and connect message are stored encrypted and never shown again, so the Edit form leaves the URL blank: leave it blank to keep the current connection. A new URL is saved with the connect message you enter, and none if you leave it blank. * Moving the URL to a **different host** is refused unless the request also sends each stored connection header and connect message (`null` for none). They often carry the feed's credentials, and Hookie will not pass them to a new host that nobody chose to give them to. ### Triggers with no dataset The **Dataset** field on a cron or WebSocket trigger is optional. If you set it, every cron run or matching frame is stored there as one record. If you leave it blank, each event goes through your project's [rules](/rules-and-mappings#rules) instead, and every rule that matches writes its own record. The trigger lists show such a trigger's dataset as **rules, else default**. An event that no rule matches is not dropped: it is stored whole, as one record, in the **`default`** dataset. For a WebSocket trigger, "whole" means the event after the trigger's mappings. That record is delivered to destinations subscribed to `default`, starts AI triggers and workflows on `default`, and appears in the live stream, just like any other record. Each fire counts as one event against your monthly quota, however it is stored. If the event cannot be stored in `default`, it is not fanned out, and its raw submission is kept and marked **Routing failed**. The usual cause is an empty payload such as `{}`, which has no fields to store. The error reads `no rule stored the event, and storing it in "default" failed: record has no fields`: * A cron trigger shows it as its last error, and a test fire (`POST /admin/api/projects/{project_id}/cron-triggers/{id}/test`, or `hookie cron-triggers test`) answers `500` with it. * A WebSocket trigger shows it under its status on the trigger list, and in the `last_error` field of `GET /admin/api/projects/{project_id}/ws-triggers`. The next frame that is stored clears it. A feed that fails on every frame updates the error at most every few seconds, not once per frame. The frame's submission is also under **Routing failed** on the **Observability** tab. Give the payload at least one field, for example `{"tick": "{{now}}"}`, or set a dataset. ### AI triggers An AI trigger runs an AI agent when a matching record lands. Its reply is stored as a record in the trigger's output dataset, and that record is fanned out like any other: delivered to the project's destinations whose filter admits the dataset (counted against the delivery allowance), and sent to the live stream. It carries the hop count of the event that triggered it. It never fires an AI trigger and never starts a workflow, so a trigger that matches its own output cannot loop. If the model is briefly unavailable (busy, rate-limited, timing out, or failing on its side), the run is retried after about 30 seconds, then 2 minutes, then 10 minutes, and marked failed with the model's error after the fourth attempt. While it waits, the run shows `queued` and says why. A request the model refuses (for example, an unknown model) fails at once. A run refused because the month's AI allowance is used up also fails at once and is not retried. ## AI allowance and the AI call log Every AI call counts the tokens it sends to the model and the tokens the model answers with against your plan's **monthly AI token allowance**. That includes a `call_ai` step, an `agent_call` step and an AI trigger run. | Plan | AI tokens / month | | - | - | | Free | 100,000 | | Pro | 750,000 | | Team | 1,500,000 | A typical classification step uses about 1,800 tokens. The largest a single step can use is about 4,600. Once the month's allowance is used, the next AI call is **refused before the model runs**. The step fails with the reason, for example *Monthly AI allowance used: 750,000 of 750,000 tokens on the Pro plan for 2026-09*, and so does the workflow instance or trigger run. AI steps work again on the 1st (UTC), or straight away after an upgrade. **Home** shows this month's usage against the allowance, with a warning from 80%. Every call is logged in the project's **Observability → AI calls**. Each entry records: * the step, agent or trigger that made the call, and the model; * the instructions, the input and the model's output, each kept up to 8,000 characters and marked when cut; * the tokens in and out, the latency, and whether the call succeeded, failed or was refused. Search matches the text of the instructions, input or output. The log is kept for your plan's retention window, and the audit log gets a daily summary of each day's AI usage. Workflows are built in the console. [Sign in](https://app.hookie.ai/) and open a project's **Workflows** tab. For end-to-end patterns, see the [use cases](https://hookie.ai/use-cases). # WorkOS Agent Auth Source: https://docs.hookie.ai/workos-agent-auth Sign an agent up for Hookie through WorkOS Agent Registration, or call Hookie with an Agent Auth token that acts for a person. Hookie signs people in with WorkOS, and it also accepts the credentials of **WorkOS Agent Auth**. An agent that already speaks Agent Auth can use it in two ways: * **Agent Registration.** The agent signs itself up with WorkOS and calls Hookie with the credential it gets. Hookie gives it its own workspace on the **Free** plan, the same account [agent self-registration](/agent-self-registration) makes. * **Delegated agent tokens.** An agent holding a WorkOS Agent Auth token that acts for a person reaches that person's workspace, as a [connected agent](/connected-agents) they control. Agent Auth is optional. [Self-registration](/agent-self-registration) and [connected agents over OAuth](/connected-agents) keep working exactly as before, and an [API key](/api-keys) is still the simplest credential for a script. ## Sign up with Agent Registration `GET https://app.hookie.ai/.well-known/oauth-protected-resource` and take `authorization_servers[0]`. That is Hookie's WorkOS AuthKit domain. `GET /.well-known/oauth-authorization-server`. Its `agent_auth` block names the identity endpoint. `POST /agent/identity` with `{"type": "anonymous"}`. Keep the **claim token** it returns: the person responsible for you uses it to claim your workspace later. `POST /oauth2/token` with `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` and your identity assertion. You get an access token, or an API key if Hookie's WorkOS environment issues those. Send it as `Authorization: Bearer ` to the [admin API](/api-reference), the hosted MCP server at `https://app.hookie.ai/mcp`, or the [CLI](/cli) as `HOOKIE_TOKEN`. ```bash theme={"dark"} curl -s https://app.hookie.ai/admin/api/me -H "Authorization: Bearer $HOOKIE_TOKEN" ``` Your **first** request creates your workspace, with a Default project, on the Free plan. Every later request lands in the same workspace, so a new credential for the same registration never makes a second account. Refresh your credential with WorkOS before it expires. Using Hookie this way accepts the [Terms of Use](https://hookie.ai/legal/terms). Hookie records the version in force when your workspace is created. ### What the account can do Until a person claims it, the account works like a self-registered one: you build and run your own projects over the API, MCP and the CLI, and the actions reserved for people answer `403` with the code `agent_not_permitted` and `claim_required: true`. Those include billing, inviting members, creating API keys and single sign-on. If your credential carries Hookie scopes in its `scope` claim (`hookie:read`, `hookie:write`, `hookie:manage`), you get at most what they allow. A credential with no `scope` claim gets the same admin role on your own workspace as a self-registration API key. ### Limits The same controls apply as for self-registration: * New accounts are capped per day across Hookie. When the cap is reached, a first request is refused with `403` until 00:00 UTC. * The operator can pause new sign-ups. Accounts that already exist keep working. * New accounts from one network are rate limited. * Free plan quotas apply in full. ### Claiming the workspace Give the claim token to the person responsible for you. They complete WorkOS's claim ceremony with it. Once they have signed in to Hookie at least once, the next request you make after the claim hands them the workspace: they become its owner, and your credential keeps working on their behalf, still limited to your scopes. They can then upgrade the plan and manage billing in the console. ## Call Hookie with a delegated agent token If your agent holds a WorkOS Agent Auth token minted **for a person** (a user-delegated token, which names that person in its `act` claim), it can call Hookie as that person's agent. * It appears in the person's **Connected agents** list as **WorkOS agent**, one entry per agent instance. * It starts **read-only**. The person widens it to write or manage there, and can revoke it, which takes effect on the next request. * If the token's `permissions` claim lists Hookie scopes, they narrow what the person granted. They never widen it. An **autonomous** agent token, which acts for no person, is refused with `401`. To give an agent its own account, use Agent Registration or [self-registration](/agent-self-registration). ## Errors | Status | Meaning | | - | - | | `401` | The credential is invalid, expired, issued for a different application, or an autonomous agent token. | | `403` | New agent sign-ups are paused, today's cap is reached, too many sign-ups came from your network, or your scopes do not allow the action. The message says which. | ## Related * [Agent self-registration](/agent-self-registration): sign up directly with Hookie, with a proof of work instead of WorkOS. * [Connected agents](/connected-agents): act for a person's workspace over OAuth. * [Roles and agents](/roles-and-agents): what each role and scope can do.