> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hookie.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Ingest an event with an ingest key, routed by the project's mapping rules, with the key's default dataset as the fallback

> 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.



## OpenAPI

````yaml /openapi.json post /v1/ingest/{ingest_key}
openapi: 3.1.0
info:
  title: Hookie API
  version: 1.0.0
  summary: Capture, route and deliver webhooks.
  description: >-
    Hookie exposes three HTTP surfaces.


    **Ingest + streaming** is the public plane. A path-form endpoint URL
    authenticates by its own high-entropy slug — there is no key, token or
    cookie — and an `ik_live_…` ingest key authenticates the `/v1/*` routes. An
    endpoint URL files each payload into the endpoint's own dataset through the
    endpoint's own criteria and mappings (none means the whole payload, as one
    record); `/v1/ingest/{ingest_key}/{dataset}` stores the payload whole into
    the named dataset. Project mapping rules are evaluated on exactly one shape,
    `POST /v1/ingest/{ingest_key}` with no dataset segment, and never on an
    endpoint URL.


    **The customer portal API** is a separate, token-authed surface for your end
    customers, scoped to one portal and one customer.


    **The admin API** drives the console. It accepts either a browser session or
    an OAuth bearer token from a connected coding agent — the same routes, the
    same validation, the same audit trail. An agent's authority is its user's
    workspace role narrowed to the scopes granted in the console. It also
    accepts an **admin API key** (`hk_…`, Settings → API keys) for CI,
    infrastructure-as-code and vendor backends: the key acts as the member who
    created it, capped at the key's role (viewer, developer or admin — never
    billing or platform administration), optionally limited to one project, with
    an optional expiry and IP allowlist. Keys are stored as a SHA-256 hash and
    shown once; if their creator leaves the workspace they stop working. The
    same key works on the hosted MCP server at `/mcp`.


    **A refused agent is told how to proceed.** An agent's grant is one rung of
    a ladder — `hookie:read` < `hookie:write` < `hookie:manage` — and a call
    above it answers 403 with `code: "agent_scope_insufficient"`, the
    `required_scope`, the `granted_scopes` and a `manage_url` to the console
    page where its user widens it. When the user's own role is the limit the
    code is `insufficient_role` and there is no link, because no grant can
    exceed the user; billing, platform admin, managing connected agents and
    destination signing secrets answer `agent_not_permitted` at every scope. A
    human in the console still reads `Insufficient role`.


    **A suspended workspace** (an operator action, for abuse or non-payment)
    answers every surface with 403 and `reason: "workspace_suspended"`: ingest
    (endpoint URLs and ingest keys), `/v1/data` and `/v1/stream` refuse before
    anything is stored or counted, and every admin API write — console, OAuth
    agent or hosted MCP alike — is refused except billing, platform admin,
    connected-agent management, revoking an API key and the POST-bodied
    searches. The customer portal API is read-only in the same way: destination
    writes answer 403 `workspace_suspended`. Reads keep working. Nothing is sent
    while it is suspended: pending deliveries are paused, workflow runs already
    under way are paused, open `/v1/stream` connections are closed, and cron
    triggers, database sources and WebSocket listeners stop. Reinstating sends
    the paused deliveries, resumes the paused workflow runs where they stopped,
    and resumes the rest; no stored data is lost.


    **Rate limits.** `/admin/api/*` and `/mcp` allow about 100 requests every 10
    seconds per credential — per API key, per connected agent, or per signed-in
    person. Over that, the answer is 429 with `Retry-After` (seconds). Like
    every burst limit here, ingest's included, it is a best-effort guard rather
    than an exact count: it is counted separately at each Cloudflare location
    and catches up within seconds, so a short burst can get through above it.
    The monthly event quota is the exact count.


    **Idempotency.** A `POST` to `/admin/api/*` may carry an `Idempotency-Key`
    header (1–255 printable characters, e.g. a UUID). The first request runs and
    its response is stored for 24 hours; a retry with the same key from the same
    credential, with the same path and body, gets that response back with
    `Idempotent-Replayed: true` and runs nothing. The same key with a different
    path, body or credential is refused with 422; a retry while the first
    request is still running gets 409. 5xx responses are not stored. `POST
    /admin/api/api-keys` refuses the header, because its response is a secret
    shown once and is never stored.


    **Versioning.** Every `/admin/api/*` and `/mcp` response carries
    `Hookie-Version` (currently `2026-09-29`). Additive changes — a new route, a
    new response field, a new optional parameter — ship under the current
    version, so clients must ignore fields they do not know. A breaking change
    gets a new dated version, announced in the changelog, and the previous
    version stays available for at least 12 months; a client pins one by sending
    `Hookie-Version` on its requests. A version this deployment does not serve
    is refused with 400 and the list of `supported_versions`.


    This document is generated from the request handlers and adversarially
    verified against them.
  contact:
    name: Hookie
    url: https://hookie.ai
  license:
    name: Proprietary
    url: https://hookie.ai/legal/terms
servers:
  - url: https://app.hookie.ai
    description: Production
  - url: https://app.preview.hookie.ai
    description: Preview
security: []
tags:
  - name: Ingest
    description: Send events to Hookie.
  - name: Streaming
    description: Tail events in real time over SSE or WebSocket.
  - name: Portal
    description: Token-authed surface for your end customers.
  - name: Projects
    description: Projects and their settings.
  - name: Routing
    description: Rules, endpoints, datasets and records.
  - name: Delivery
    description: Destinations, deliveries and replay.
  - name: Workflows
    description: Multi-step workflows, triggers and AI agents.
  - name: Observability
    description: Search, correlation, stats and the audit log.
  - name: Workspace
    description: >-
      The workspace itself: its name and slug, admin API keys, data export and
      closure. There are no member invites or member routes yet (#258): a
      workspace has its owner, plus any admin the platform sets up.
  - name: Data API
    description: >-
      Read-only access to the datasets and columns a project exposes, with
      per-project keys.
  - name: Broadcast Logs
    description: >-
      Forward a project's logs and traces over OTLP/HTTP (JSON) to Datadog,
      Grafana Cloud, an OpenTelemetry Collector, PostHog or Sentry.
paths:
  /v1/ingest/{ingest_key}:
    post:
      tags:
        - Ingest
      summary: >-
        Ingest an event with an ingest key, routed by the project's mapping
        rules, with the key's default dataset as the fallback
      description: >-
        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.
      operationId: ingestEvent
      parameters:
        - name: ingest_key
          in: path
          required: true
          description: >-
            The plaintext ingest key. It is looked up by its full SHA-256 hash
            (#177), among keys that are neither revoked nor expired, and
            compared constant-time; key_prefix is only a display label.
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: false
          description: >-
            Optional. If a submission with this key already exists for the
            tenant, the request short-circuits with 200 before the monthly quota
            is charged.
          schema:
            type: string
        - name: X-Hookie-Signature
          in: header
          required: false
          description: >-
            Required only when the ingest key has require_signature set; ignored
            otherwise. Format: 't=<unix-timestamp>,v1=<hex>' where v1 is
            HMAC-SHA-256 over '<t>.<raw body>' keyed with the key's signing
            secret.
          schema:
            type: string
            example: t=1700000000,v1=3ba8c0e9...
      requestBody:
        required: false
        description: >-
          Content-Type selects the parser: 'application/x-www-form-urlencoded'
          and 'multipart/form-data' are flattened into a shallow object
          (repeated fields become arrays; file parts become {filename, type,
          size} and their bytes are discarded). EVERY other body is parsed as
          JSON first, whatever its Content-Type (missing or wrong ones included,
          which is deliberate so webhook providers that send JSON with an
          unhelpful Content-Type still work). A body that is not JSON is kept as
          text when its Content-Type is textual: text/* (plain, csv, xml, ...),
          application/xml or any +xml type, NDJSON / JSON Lines
          (application/x-ndjson, application/ndjson, application/jsonl,
          application/x-jsonlines), application/csv and YAML. It is stored as
          one event, {"body": "<the text, verbatim>", "content_type": "<media
          type, lowercased, parameters stripped>"}, decoded as UTF-8; NDJSON is
          not split into several events. A body that does parse as JSON is
          stored as JSON whatever its Content-Type, exactly as before, so a
          provider that posts JSON as text/plain still gets an object. A
          non-JSON body that declares JSON (or no Content-Type) is 400, and one
          of any other type (application/octet-stream, image/*, ...) is 415. An
          empty body is accepted and becomes {}. Over 1,000,000 bytes yields
          413.
        content:
          application/json:
            schema:
              description: >-
                Any JSON value. On this route the payload is fed to the
                project's mapping rules, so its shape is whatever the rules'
                conditions and mappings expect.
          application/x-www-form-urlencoded:
            schema:
              type: object
              additionalProperties:
                oneOf:
                  - type: string
                  - type: array
                    items:
                      type: string
          multipart/form-data:
            schema:
              type: object
              additionalProperties:
                oneOf:
                  - type: string
                  - type: array
                    items:
                      type: string
                  - type: object
                    required:
                      - filename
                      - type
                      - size
                    properties:
                      filename:
                        type: string
                      type:
                        type: string
                      size:
                        type: integer
          text/plain:
            schema:
              type: string
              description: >-
                Plain text, e.g. an alert. Stored as {body, content_type} unless
                it parses as JSON.
          application/xml:
            schema:
              type: string
              description: >-
                XML, e.g. a SOAP outbound message (text/xml and +xml types too).
                Stored as {body, content_type}.
          text/csv:
            schema:
              type: string
              description: CSV. Stored whole as {body, content_type}; rows are not split.
          application/x-ndjson:
            schema:
              type: string
              description: >-
                NDJSON / JSON Lines. Stored whole as {body, content_type}; lines
                are not split into events. A single line that is itself valid
                JSON is stored as JSON.
      responses:
        '200':
          description: >-
            Duplicate - this endpoint (or, on /v1/ingest, this ingest key)
            already stored a submission with the same dedup key, so the retry is
            answered with the first submission's id and nothing is stored or
            counted (ING-9, #193). The dedup key is, in order: an
            Idempotency-Key header; else a provider delivery id kept the same
            across that provider's retries - 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; else
            Stripe's event id (a body with object 'event' and an evt_ id) or
            Slack's event_id (type 'event_callback'). The key space is the
            ENDPOINT's, not the workspace's: two endpoints may receive the same
            key. `deduplicated_by` names which source matched. A request that
            loses the unique-index race has already been counted against the
            monthly quota.
          content:
            application/json:
              schema:
                type: object
                required:
                  - submission_id
                  - idempotent
                additionalProperties: false
                properties:
                  submission_id:
                    type: string
                  idempotent:
                    type: boolean
                    enum:
                      - true
                  deduplicated_by:
                    type: string
                    enum:
                      - idempotency-key
                      - standard-webhooks
                      - svix
                      - github
                      - shopify
                      - gitlab
                      - twilio
                      - linear
                      - twitch
                      - atlassian
                      - stripe
                      - slack
                    description: Which dedup source matched.
        '201':
          description: >-
            Stored. With no dataset in the path the payload runs through the
            project's enabled mapping rules (webhook mirror rules excluded), and
            'routed' holds the NAME of every rule that matched and applied
            (fallback: false). When no rule produces a record (none matched, or
            every matching rule failed to write), the whole payload is stored as
            one record in the key's dataset_default instead (#280): routed:
            ["<that dataset>"], records: 1, fallback: true. That record fans out
            (deliveries, live stream, AI triggers, workflows) exactly as a
            rule-routed one does, and the request is charged the same one quota
            event either way. A routing failure the fallback cannot recover from
            is still a 201, with routed: [] and records: 0; the raw submission
            is always kept and marked failed.
          content:
            application/json:
              schema:
                type: object
                required:
                  - submission_id
                  - routed
                  - records
                  - fallback
                additionalProperties: false
                properties:
                  submission_id:
                    type: string
                  routed:
                    type: array
                    items:
                      type: string
                    description: >-
                      names of the mapping rules that applied; with fallback:
                      true, the single dataset the event was stored in instead
                  records:
                    type: integer
                    description: routed.length
                  fallback:
                    type: boolean
                    description: >-
                      True when no mapping rule routed the event and it was
                      stored whole, as one record, in the ingest key's
                      dataset_default (#280); 'routed' then holds that dataset's
                      name instead of rule names. False when rules routed it.
                      Present only on this route (no dataset segment).
        '400':
          description: >-
            Body could not be parsed: 'Body must be valid JSON' (the body
            declares application/json, a +json type or no Content-Type, and does
            not parse) or 'Body is not valid multipart/form-data'. Logged to the
            endpoint's rejection log (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: >-
            Only when the key has require_signature: 'Signature required'
            (header absent or no stored secret) or 'Invalid signature'. Logged
            to the endpoint's rejection log (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            error: 'Source IP not allowed'. The Cloudflare client IP must
            satisfy BOTH the key's allowlist and the tenant's (an empty
            allowlist matches everything); the block is audited. Also `reason:
            "workspace_suspended"` when the workspace has been suspended by the
            operator: nothing is stored, counted or charged, so a sender should
            retry after the workspace is reinstated. Logged to the endpoint's
            rejection log (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            error: 'Unknown ingest key' — no live key with this prefix (revoked,
            or past its expires_at), hash mismatch, or the key's tenant row is
            missing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: >-
            error: 'Body exceeds 1000000 bytes' — from Content-Length, or from
            the buffered byte length. Logged to the endpoint's rejection log
            (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '415':
          description: >-
            The body is not JSON and its Content-Type is neither a form encoding
            nor a textual type that can be stored as {body, content_type} (for
            example application/octet-stream or image/png). The error names the
            Content-Type. Nothing is stored and no quota is charged. Logged to
            the endpoint's rejection log (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: >-
            Two cases, each with a Retry-After response header in seconds that
            equals the retry_after body field. Edge burst limit: about 100
            requests per 10 seconds per credential (one endpoint URL, or one
            ingest key), the same on every plan: {error: 'Rate limit exceeded',
            retry_after: 10} with Retry-After: 10. It is a best-effort burst
            guard, not an exact count: it is counted separately at each
            Cloudflare location and catches up within seconds, so a short burst
            can get through above it. Monthly plan quota, the exact count (the
            plan's events plus its soft cap: +10% on Pro and Team, none on
            Free): {error: 'Monthly event quota reached: this event was not
            stored. The quota resets at <reset_at>; upgrade the plan to raise it
            sooner.', reason: 'quota_exceeded', limit: <events at which ingest
            refuses>, reset_at: '<00:00 UTC on the 1st of next month>',
            upgrade_url: '<APP_ORIGIN>/#/settings/billing', retry_after: 3600}
            with Retry-After: 3600. The event is refused, not queued: nothing is
            stored, so a sender must retry it after reset_at or an upgrade. Only
            the quota case carries reason, limit, reset_at and upgrade_url;
            branch on reason, not on the error text. Both limiters fail open if
            their backing service is unavailable. Logged to the endpoint's
            rejection log (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      retry_after:
                        type: integer
                        description: 10 (rate limit) or 3600 (quota)
                      upgrade_url:
                        type: string
                        format: uri
                        description: quota 429 only
                      reason:
                        type: string
                        enum:
                          - quota_exceeded
                        description: 'monthly-quota 429 only: quota_exceeded'
                      limit:
                        type: integer
                        description: >-
                          monthly-quota 429 only: the event count at which
                          ingest refuses (the plan's events plus its soft cap)
                      reset_at:
                        type: string
                        format: date-time
                        description: >-
                          monthly-quota 429 only: when the quota resets, 00:00
                          UTC on the 1st of next month
          headers:
            Retry-After:
              description: >-
                Seconds to wait before retrying: 10 for the edge burst limit,
                3600 for the monthly quota. Same value as retry_after in the
                body.
              schema:
                type: integer
        '500':
          description: >-
            error: 'Key misconfigured' (signing secret could not be decrypted)
            or 'Could not store submission'.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '508':
          description: >-
            Loop detected: the request's `Hookie-Hop` header says this event has
            already passed through Hookie 8 times, which almost always means a
            destination is delivering back into an endpoint that feeds it.
            Nothing is stored. Logged to the endpoint's rejection log (OBS-3,
            #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity. A refusal
            never counts against the event quota.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - ingestKey: []
components:
  schemas:
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          description: Human-readable reason.
        retry_after:
          type: integer
          description: Seconds to wait. Present on 429.
        upgrade_url:
          type: string
          format: uri
          description: Present only on a monthly-quota 429.
        reason:
          type: string
          description: >-
            Machine-readable reason, where the status alone is ambiguous.
            `workspace_suspended` (403): the workspace is suspended;
            `delivery_allowance_exhausted` (429): a replay was refused because
            the monthly delivery allowance is used. `endpoint_disabled` (503):
            the endpoint is switched off; `handshake_failed`: a provider
            URL-verification challenge could not be answered (#193).
            `missing_header`, `bad_signature`, `stale_timestamp` (401): an
            endpoint's provider signature check failed; `scheme` names the
            check.
        code:
          type: string
          enum:
            - agent_scope_insufficient
            - insufficient_role
            - agent_not_permitted
          description: >-
            Present on a 403 to an OAuth-connected agent.
            `agent_scope_insufficient`: the agent's grant is below what the call
            needs, and widening it would let the call through (see
            `required_scope` and `manage_url`). `insufficient_role`: the user's
            OWN workspace role does not allow the call, so no grant can — and
            roles cannot be changed in the product yet, so the call is one for
            the workspace owner. `agent_not_permitted`: no connected agent may
            do this at any scope (billing, platform admin, managing connected
            agents, a destination's signing secret).
        required_scope:
          type: string
          enum:
            - hookie:read
            - hookie:write
            - hookie:manage
          description: 'With `code`: the scope the refused call needs.'
        granted_scopes:
          type: array
          items:
            type: string
          description: 'With `code`: the scopes the agent''s grant holds now.'
        manage_url:
          type: string
          format: uri
          description: >-
            With `code: "agent_scope_insufficient"` only: the console's Settings
            → Connected agents page, opened on this agent
            (`…/#/settings/agents?client=<client_id>`), where its user widens
            the grant. The change applies on the agent's next request.
        scheme:
          type: string
          description: 'Present on a signature 401: the endpoint''s verification scheme.'
  securitySchemes:
    ingestKey:
      type: http
      scheme: bearer
      description: >-
        An `ik_live_…` ingest key. Stored hashed; the plaintext is returned once
        at creation.

````