> ## 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 into a configured endpoint using an ingest key

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



## OpenAPI

````yaml /openapi.json post /v1/webhooks/{ingest_key}/{slug}
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/webhooks/{ingest_key}/{slug}:
    post:
      tags:
        - Ingest
      summary: Ingest an event into a configured endpoint using an ingest key
      description: >-
        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.
      operationId: ingestWebhookEvent
      parameters:
        - name: ingest_key
          in: path
          required: true
          description: >-
            The plaintext ingest key (looked up by its full SHA-256 hash, #177;
            revoked and expired keys never match). It authenticates the request;
            the tenant it belongs to scopes the webhook lookup.
          schema:
            type: string
        - name: slug
          in: path
          required: true
          description: >-
            Webhook slug within the key's project; a slug of another project,
            even in the same workspace, is 404. A bare slug selects the most
            recently created enabled webhook with that base slug; a versioned
            slug of the form '<base>-v<version>' (version = 1+ digits,
            optionally .digits, e.g. 'orders-v2' or 'orders-v2.1') pins that
            exact enabled version. When the slug names only a disabled webhook
            the answer is 503.
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: false
          description: >-
            Optional. A repeat key short-circuits with 200 — after the webhook
            slug is resolved, but 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 (this
            route uses the KEY's signature setting and signing secret, not the
            webhook's). Format 't=<unix-timestamp>,v1=<hex>', v1 = HMAC-SHA-256
            over '<t>.<raw body>'.
          schema:
            type: string
      requestBody:
        required: false
        description: >-
          Form encodings are flattened (repeats become arrays, file parts become
          {filename, type, size} with the bytes discarded); every other body is
          parsed as JSON first, and textual non-JSON bodies are stored as {body,
          content_type} (see ingestEvent), which the rule's conditions and
          mappings address as `body` and `content_type`; an empty body becomes
          {}. The parsed payload is evaluated against the webhook's rule
          conditions and reshaped by its mappings (empty conditions match
          everything, empty mappings are identity). Over 1,000,000 bytes yields
          413.
        content:
          application/json:
            schema:
              description: >-
                Any JSON value; its shape is whatever the webhook's rule
                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. If the webhook rule's conditions are empty or match the
            payload, the rule is applied and 'routed' holds its NAME; if the
            conditions do not match, the submission is still stored and the
            response is 201 with routed: [] and records: 0. A swallowed routing
            failure looks the same.
          content:
            application/json:
              schema:
                type: object
                required:
                  - submission_id
                  - routed
                  - records
                additionalProperties: false
                properties:
                  submission_id:
                    type: string
                  routed:
                    type: array
                    items:
                      type: string
                    description: >-
                      the applied rule's name, or empty when the conditions did
                      not match
                  records:
                    type: integer
                    description: routed.length
        '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: >-
            Signature-required keys: 'Signature required' or 'Invalid signature'
            (X-Hookie-Signature). Endpoints with verification (#210): the
            provider's signature is missing, forged or stale; reason
            missing_header, bad_signature or stale_timestamp, with the scheme.
            Nothing is stored or counted. Logged to the endpoint's rejection log
            (OBS-3, #193): see GET
            /admin/api/projects/{project_id}/webhooks/{id}/activity.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            error: 'Source IP not allowed' — the client IP failed the key
            allowlist or the tenant allowlist; 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: >-
            Two messages. 'Unknown ingest key' — unknown, revoked or expired
            key, hash mismatch or missing tenant row. 'Unknown endpoint' — no
            endpoint with that slug in the key's PROJECT. The lookup is confined
            to the key's project, so a slug that belongs to another project of
            the same workspace is 404 too. The slug is resolved BEFORE quota is
            charged and before the submission is stored, so a bad slug costs
            nothing and leaves no pending row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: >-
            error: 'Body exceeds 1000000 bytes'. 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
                      upgrade_url:
                        type: string
                        format: uri
                      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),
            'Could not store submission', or 'Endpoint rule is malformed' (the
            rule's conditions/mappings JSON failed to parse — note the
            submission has already been stored and the quota already charged at
            this point).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            The endpoint exists but is disabled: {error: 'This endpoint is
            disabled, so events sent to it are not being stored. ...', reason:
            'endpoint_disabled'}. Nothing is stored and no quota is charged. It
            is deliberately not the 404 of a wrong URL: providers treat a 404 as
            a dead endpoint (and may disable it or drop the events), while a 503
            is retried, so events sent while it is disabled can still arrive if
            it is enabled again within the provider's retry window. 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:
                      reason:
                        type: string
                        enum:
                          - endpoint_disabled
        '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.

````