openapi: "3.1.0"
info:
  title: webhooks.cc API
  version: "1.0.0"
  description: |
    REST API for webhooks.cc: capture, inspect, and test webhooks and email.

    **Authentication:** Most endpoints require a Bearer token in the `Authorization` header.
    Use your API key (prefix `whcc_`) generated on the [API keys page](https://webhooks.cc/account/api-keys),
    or an access token from OAuth 2.1 (authorization code with PKCE, for apps and MCP clients acting
    for a signed-in user). Agents without a human at hand can register themselves (see
    [auth.md](https://webhooks.cc/auth.md)) and exchange the identity assertion for access tokens.
    Every access token is an ordinary `whcc_` bearer token and goes in the same header.
    Public endpoints (`/api/stats`, `/api/health`, agent registration and the OAuth endpoints) do not
    require authentication.

    **MCP server:** The same API is available to AI agents as a remote MCP server (streamable
    HTTP, not described here) at `https://webhooks.cc/mcp`, with the same bearer tokens; its
    protected resource metadata is at `https://webhooks.cc/.well-known/oauth-protected-resource/mcp`.

    **Webhook receiver:** Send webhooks to `https://go.webhooks.cc/w/{slug}`. They are captured
    and available via the API and dashboard in real time.

    **Email capture:** Every endpoint that belongs to an account also receives email at
    `{slug}@mailhooks.cc` (and `{slug}+tag@mailhooks.cc`). An email is stored as a request with
    `kind: "email"` and `method: "EMAIL"`. See [Email capture](https://webhooks.cc/docs/email-capture)
    and [Email forwarding](https://webhooks.cc/docs/forwarding).

    **Rate limits:** Rate-limited endpoints return `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
    and `X-RateLimit-Reset` headers on every response. When the limit is exceeded, the API
    returns `429` with a `Retry-After` header.
  contact:
    name: webhooks.cc
    url: https://webhooks.cc/support
  license:
    name: AGPL-3.0
    url: https://www.gnu.org/licenses/agpl-3.0.html

servers:
  - url: https://webhooks.cc
    description: Production

security:
  - BearerAuth: []
  - OAuth2: [webhooks:read, webhooks:write]

tags:
  - name: Endpoints
    description: Create, read, update, and delete webhook endpoints.
  - name: Requests
    description: List, inspect, and clear captured webhook requests and emails.
  - name: Forwarding
    description: Forward every email an endpoint captures to your own URL as JSON, signed with Standard Webhooks headers. Only the endpoint's owner can configure it.
  - name: Streaming
    description: Real-time Server-Sent Events stream of incoming webhooks.
  - name: Search
    description: Search retained requests across your endpoints and endpoints shared with you through teams.
  - name: Send
    description: Send test webhooks to your endpoints.
  - name: Usage
    description: Request quota and billing information.
  - name: Teams
    description: Team management and endpoint sharing. Any account can create a team; the team needs its own Teams subscription before it can invite members or share endpoints.
  - name: Invites
    description: Team invitation management.
  - name: Agents
    description: |
      Agent registration (auth.md v0.6, documented at https://webhooks.cc/auth.md) and the agent
      sandbox. An agent registers without a human, works in a small sandbox with a pre-claim token,
      and is later claimed into a human's account, after which it uses the normal API.
  - name: OAuth
    description: |
      OAuth 2.1 token, revocation and dynamic client registration endpoints. Server metadata is
      published at https://webhooks.cc/.well-known/oauth-authorization-server (RFC 8414).

paths:
  # -- Endpoints ---------------------------------------------------------------

  /api/endpoints:
    get:
      operationId: listEndpoints
      tags: [Endpoints]
      summary: List endpoints
      description: |
        Returns all endpoints owned by the authenticated user (`owned`, each with
        `sharedWith`), plus endpoints shared with them through a team that has an
        active Teams subscription (`shared`, each with `fromTeam`). Personal plans
        do not affect team access.
      responses:
        "200":
          description: Endpoint listing
          content:
            application/json:
              schema:
                type: object
                required: [owned, shared]
                properties:
                  owned:
                    type: array
                    items:
                      $ref: "#/components/schemas/Endpoint"
                  shared:
                    type: array
                    items:
                      $ref: "#/components/schemas/Endpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      operationId: createEndpoint
      tags: [Endpoints]
      summary: Create endpoint
      description: |
        Create a new webhook endpoint. Rate limited to 30 requests per 10 minutes.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateEndpointRequest"
      responses:
        "200":
          description: Created endpoint
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Endpoint"
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/endpoints/{slug}:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: getEndpoint
      tags: [Endpoints]
      summary: Get endpoint
      description: |
        Returns a single endpoint by slug. Team members can access shared endpoints; for them
        `notificationUrl` and `forwardUrl` are left out.
      responses:
        "200":
          description: Endpoint details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Endpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    patch:
      operationId: updateEndpoint
      tags: [Endpoints]
      summary: Update endpoint
      description: |
        Update an endpoint. Only the fields you send change.
        Set `mockResponse` to `null` to clear the mock response.

        Team members can update `name`, `mockResponse`, and `responseRules` on endpoints shared
        with them. Only the owner can change `showEmailExtracts` (it also decides whether forwarded
        JSON includes codes and links), `site`, `notificationUrl`, the signing settings,
        `forwardEnabled`, and `forwardUrl`; anyone else who sends one gets `403`.

        **Forwarding:** `forwardEnabled: true` needs a `forwardUrl`, sent in the same request or
        saved before. Saving the first URL (or turning forwarding on) creates the endpoint's signing
        secret; read it with `GET /api/endpoints/{slug}/forwarding`. Send `forwardUrl: null` (or `""`)
        to remove the URL, which fails while forwarding is on. Turning forwarding off marks
        deliveries that are still waiting as failed. See [Email forwarding](https://webhooks.cc/docs/forwarding).
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateEndpointRequest"
      responses:
        "200":
          description: Updated endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Endpoint"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            A team member sent an owner-only field (email, notification, signing, or forwarding settings),
            or the API key belongs to an unclaimed agent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"
        "503":
          description: |
            Secrets cannot be encrypted on this server, so a signing secret or the first forwarding
            URL cannot be saved
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

    delete:
      operationId: deleteEndpoint
      tags: [Endpoints]
      summary: Delete endpoint
      description: Deletes the endpoint and all its captured requests. Only the owner can delete.
      responses:
        "204":
          description: Endpoint deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/endpoints/{slug}/favorite:
    parameters:
      - $ref: "#/components/parameters/slug"

    put:
      operationId: favoriteEndpoint
      tags: [Endpoints]
      summary: Favourite endpoint
      description: |
        Mark the endpoint as one of the caller's favourites, which the dashboard lists first and
        opens by default. Idempotent. Anyone who can open the endpoint may, team members included;
        a favourite is the caller's own. Rate limited to 60 changes a minute.
      responses:
        "200":
          $ref: "#/components/responses/FavoriteResult"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "403":
          description: Claimed account required (an unowned agent key is rejected)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

    delete:
      operationId: unfavoriteEndpoint
      tags: [Endpoints]
      summary: Unfavourite endpoint
      description: Remove the endpoint from the caller's favourites. Idempotent.
      responses:
        "200":
          $ref: "#/components/responses/FavoriteResult"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "403":
          description: Claimed account required (an unowned agent key is rejected)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/endpoints/claim:
    post:
      operationId: claimEndpoint
      tags: [Endpoints]
      summary: Claim guest endpoint
      description: Claim an ephemeral guest endpoint, associating it with your account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug]
              properties:
                slug:
                  type: string
                  description: Slug of the guest endpoint to claim
      responses:
        "200":
          description: Claimed endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Endpoint"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # -- Requests ----------------------------------------------------------------

  /api/endpoints/{slug}/requests:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: listRequests
      tags: [Requests]
      summary: List requests
      description: List captured requests for an endpoint, newest first.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
          description: Maximum number of requests to return
        - name: since
          in: query
          schema:
            type: integer
          description: Only return requests received after this Unix timestamp (ms)
        - name: kind
          in: query
          schema:
            type: string
            enum: [http, email]
          description: Only HTTP requests (`http`) or only captured emails (`email`)
        - $ref: "#/components/parameters/extracts"
      responses:
        "200":
          description: Array of captured requests
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Request"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    delete:
      operationId: clearRequests
      tags: [Requests]
      summary: Clear requests
      description: |
        Delete captured requests for an endpoint without deleting the endpoint itself.
        Optionally pass `before` to only delete requests older than a timestamp.
      parameters:
        - name: before
          in: query
          schema:
            type: integer
          description: Only delete requests received before this Unix timestamp (ms)
      responses:
        "204":
          description: Requests cleared
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/endpoints/{slug}/requests/paginated:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: listRequestsPaginated
      tags: [Requests]
      summary: List requests (paginated)
      description: |
        Cursor-based paginated listing of captured requests. Pass the returned `cursor`
        to fetch the next page.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
          description: Maximum number of requests per page
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque cursor from a previous page
        - name: kind
          in: query
          schema:
            type: string
            enum: [http, email]
          description: Only HTTP requests (`http`) or only captured emails (`email`); send the same value for every page
        - $ref: "#/components/parameters/extracts"
      responses:
        "200":
          description: Paginated request listing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedRequests"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/requests/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Request ID

    get:
      operationId: getRequest
      tags: [Requests]
      summary: Get request
      description: |
        Get a single captured request by ID. Captured emails are requests too: they have
        `kind: "email"` and the parsed message in `email`.
      parameters:
        - $ref: "#/components/parameters/extracts"
      responses:
        "200":
          description: Request details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Request"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/requests/{id}/raw:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    get:
      operationId: getRawEmail
      tags: [Requests]
      summary: Download a captured email's exact message
      description: |
        The original message as `message/rfc822`, whatever its size when it was kept in
        object storage (`email.stored`); otherwise the database copy, which exists for
        messages up to 1 MiB.
      responses:
        "200":
          description: The message
          content:
            message/rfc822:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/requests/{id}/attachments/{index}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      - name: index
        in: path
        required: true
        schema:
          type: integer
          minimum: 0
          maximum: 99
        description: The attachment's position in `email.attachments`
    get:
      operationId: getAttachment
      tags: [Requests]
      summary: Download an attachment of a stored email
      responses:
        "200":
          description: The attachment, as a download
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/requests/{id}/replay:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
    post:
      operationId: replayRequest
      tags: [Forwarding]
      summary: Send a request through its endpoint's destinations again
      description: Queues the request to every destination that takes its kind, with their current settings. Owner only.
      responses:
        "200":
          description: Whether a delivery was queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  queued:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/endpoints/{slug}/replay:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
    post:
      operationId: replayEndpoint
      tags: [Forwarding]
      summary: Send requests in a time range through the destinations again
      description: At most 500 requests, oldest first. Owner only.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                since:
                  type: integer
                  description: Milliseconds; requests received at or after this
                until:
                  type: integer
                  description: Milliseconds; requests received at or before this
                kind:
                  type: string
                  enum: [http, email]
      responses:
        "200":
          description: How many requests were found and queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests:
                    type: integer
                  queued:
                    type: integer
                  limit:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/endpoints/{slug}/layouts/preview:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
    post:
      operationId: previewLayouts
      tags: [Endpoints]
      summary: Try layouts on a captured email
      description: |
        Which layout a captured email matches, with its fields and line items, using `layouts`
        (the saved ones when left out). Nothing is saved and no pages are used: attachments show
        an earlier parse, never a new one. Owner only.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                requestId:
                  type: string
                  description: "A captured email on the endpoint (default: the newest)"
                layouts:
                  type: array
                  items:
                    $ref: "#/components/schemas/Layout"
      responses:
        "200":
          description: The preview
          content:
            application/json:
              schema:
                type: object
                properties:
                  problems:
                    type: array
                    items:
                      type: string
                  layoutsCap:
                    type: integer
                  request:
                    type: ["object", "null"]
                    properties:
                      id:
                        type: string
                      subject:
                        type: ["string", "null"]
                      receivedAt:
                        type: integer
                  parsed:
                    oneOf:
                      - $ref: "#/components/schemas/ParsedEmail"
                      - type: "null"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/endpoints/{slug}/alerts:
    parameters:
      - name: slug
        in: path
        required: true
        schema:
          type: string
    get:
      operationId: getAlerts
      tags: [Endpoints]
      summary: Alert settings and recent alerts
      responses:
        "200":
          description: Settings and alerts, open ones first
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings:
                    $ref: "#/components/schemas/AlertSettings"
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        kind:
                          type: string
                          enum: [fields, silence, destination]
                        subject:
                          type: [string, "null"]
                        openedAt:
                          type: integer
                        resolvedAt:
                          type: [integer, "null"]
                        details:
                          type: object
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: updateAlerts
      tags: [Endpoints]
      summary: Change alert settings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AlertSettings"
      responses:
        "200":
          description: The saved settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  settings:
                    $ref: "#/components/schemas/AlertSettings"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # -- Forwarding --------------------------------------------------------------

  /api/endpoints/{slug}/forwarding:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: getForwardingSecret
      tags: [Forwarding]
      summary: Get forwarding secret
      description: |
        Returns the secret that signs the endpoint's forwarded emails, for verifying the
        `webhook-signature` header in your handler. The secret is created when the endpoint's
        first forwarding URL is saved. Owner only: anyone else gets `404`. The response is sent
        with `Cache-Control: no-store`.
      responses:
        "200":
          description: The forwarding secret
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardingSecret"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Endpoint not found, not owned by you, or it has no forwarding secret yet
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      operationId: rotateForwardingSecret
      tags: [Forwarding]
      summary: Rotate forwarding secret
      description: |
        Replaces the forwarding secret with a new one and returns it. Every delivery sent from now
        on is signed with the new secret, including retries of deliveries still waiting, so update
        your handler right away. Owner only: anyone else gets `404`. The response is sent with
        `Cache-Control: no-store`.
      responses:
        "200":
          description: The new forwarding secret
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardingSecret"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/endpoints/{slug}/forwarding/test:
    parameters:
      - $ref: "#/components/parameters/slug"

    post:
      operationId: testForwarding
      tags: [Forwarding]
      summary: Send test delivery
      description: |
        Posts the endpoint's newest email to the saved forwarding URL once, signed with the current
        secret, and returns what your server answered. When the endpoint has not received an email
        yet, a sample is sent instead, with `"test": true` in its JSON. Works whether forwarding is
        on or off. Nothing is queued, retried, or added to the delivery history. Owner only: anyone
        else gets `404`. Rate limited to 10 requests per minute.
      responses:
        "200":
          description: |
            What the forwarding URL answered. A non-2xx answer is still a `200` here, with
            `delivered: false`.
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ForwardTestResult"
        "400":
          description: No forwarding URL saved yet
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Endpoint not found or not owned by you
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/endpoints/{slug}/deliveries:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: listEndpointDeliveries
      tags: [Forwarding]
      summary: List recent deliveries
      description: |
        The endpoint's latest forwarded emails, newest first, each with the email's subject.
        Owner only: anyone else gets `404`.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 20
            default: 5
          description: Number of deliveries to return (clamped to 1 to 20)
      responses:
        "200":
          description: Recent deliveries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/RecentDelivery"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/requests/{id}/deliveries:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Request ID of a captured email

    get:
      operationId: listEmailDeliveries
      tags: [Forwarding]
      summary: List deliveries of an email
      description: |
        Every forwarded copy of one email, newest first (up to 20), each with its attempts.
        Anyone who can read the email can list them, including team members on a shared endpoint.
        A request that was never forwarded (any HTTP request, for one) returns an empty array.
      responses:
        "200":
          description: Deliveries of the email
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Delivery"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      operationId: redeliverEmail
      tags: [Forwarding]
      summary: Redeliver an email
      description: |
        Queues another delivery of the email. It is sent with the endpoint's current forwarding URL
        and secret, and retried on the usual schedule if it fails. Forwarding must be on.
        Owner only: anyone else gets `404`. Rate limited to 30 requests per minute.
      responses:
        "200":
          description: Delivery queued
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id:
                    type: string
                    description: ID of the new delivery
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Not found, not an email, or the endpoint is not yours
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Forwarding is off for this endpoint
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  # -- Streaming ---------------------------------------------------------------

  /api/stream/{slug}:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: streamRequests
      tags: [Streaming]
      summary: Stream requests (SSE)
      description: |
        Server-Sent Events stream of incoming webhook requests. Each captured request
        arrives as an `event: request` message with JSON data.

        The server sends keepalive pings (`:ping`) every 30 seconds. Maximum connection
        duration is 30 minutes; reconnect when the stream closes.

        Captured emails arrive as `request` events too, with `kind: "email"`, `method: "EMAIL"` and
        the recipient address as `path`. Stream messages do not carry the parsed `email`; fetch the
        request by its `_id` for it.

        **Event types:**
        - `connected`: stream established
        - `request`: captured webhook or email (JSON data matching the StreamRequest schema,
          which differs from Request: `_id` instead of `id`, an extra `_creationTime`, and no
          `email`)
        - `keepalive`: connection keepalive
        - `timeout`: max duration reached, reconnect
        - `endpoint_deleted`: endpoint was deleted, stream closed
      parameters:
        - name: since
          in: query
          schema:
            type: integer
          description: Resume from this Unix timestamp (ms); returns requests received after this time
      responses:
        "200":
          description: SSE stream
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # -- Search ------------------------------------------------------------------

  /api/search/requests:
    get:
      operationId: searchRequests
      tags: [Search]
      summary: Search requests
      description: |
        Search retained requests across endpoints you own and endpoints shared
        with you through a subscribed team. Rows on shared endpoints are bounded
        by the owner's retention, not the caller's plan.
        Rate limited to 60 requests per 10 minutes.
      parameters:
        - name: slug
          in: query
          schema:
            type: string
          description: Filter by endpoint slug
        - name: method
          in: query
          schema:
            type: string
            enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, EMAIL]
          description: Filter by HTTP method (`EMAIL` for captured emails)
        - name: kind
          in: query
          schema:
            type: string
            enum: [http, email]
          description: Only HTTP requests or only captured emails
        - name: q
          in: query
          schema:
            type: string
          description: Free-text search across path, body, and headers
        - name: from
          in: query
          schema:
            type: integer
          description: Start time as Unix timestamp (ms)
        - name: to
          in: query
          schema:
            type: integer
          description: End time as Unix timestamp (ms)
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
          description: Maximum number of results
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
          description: Pagination offset
        - name: order
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: desc
          description: Sort order by receivedAt
      responses:
        "200":
          description: Search results
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SearchResult"
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "403":
          description: Claimed account required (an unowned agent key is rejected)
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/search/requests/count:
    get:
      operationId: searchRequestsCount
      tags: [Search]
      summary: Count search results
      description: |
        Count retained requests matching search filters without returning them.
        Covers endpoints shared with you through a subscribed team. Rate limited to 120 requests per 10 minutes.
        Accepts the same query parameters as search (except `limit`, `offset`, `order`).
      parameters:
        - name: slug
          in: query
          schema:
            type: string
        - name: method
          in: query
          schema:
            type: string
            enum: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, EMAIL]
        - name: kind
          in: query
          schema:
            type: string
            enum: [http, email]
        - name: q
          in: query
          schema:
            type: string
        - name: from
          in: query
          schema:
            type: integer
        - name: to
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Match count
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [count]
                properties:
                  count:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "403":
          description: Claimed account required (an unowned agent key is rejected)
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  # -- Send --------------------------------------------------------------------

  /api/send-test:
    post:
      operationId: sendTestWebhook
      tags: [Send]
      summary: Send test webhook
      description: |
        Send a test webhook to one of your endpoints. The webhook is sent to the receiver
        and captured like any external webhook. Rate limited to 30 requests per minute.
        Maximum body size: 1MB.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug]
              properties:
                slug:
                  type: string
                  description: Target endpoint slug
                method:
                  type: string
                  default: POST
                  description: HTTP method
                path:
                  type: string
                  description: Optional sub-path appended to the endpoint URL
                headers:
                  type: object
                  additionalProperties:
                    type: string
                  description: HTTP headers to include
                body:
                  type: string
                  description: Request body
      responses:
        "200":
          description: Webhook sent
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: integer
                    description: Status code returned by the receiver
                  statusText:
                    type: string
                    description: Status text returned by the receiver
                  body:
                    type: string
                    description: Response body from the receiver
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"
        "502":
          description: Upstream receiver unreachable
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/send-test-email:
    post:
      operationId: sendTestEmail
      tags: [Send]
      summary: Send test email
      description: |
        Deliver a sample email (with a one-time code and a link) to one of your endpoints, or one
        shared with you through a team. It is captured and counted like any other email, but it does
        not travel over SMTP, so no sender checks (SPF, DKIM, DMARC) run on it.
        Pass `tag` to send it to a tagged address. Rate limited to 10 requests per minute.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug]
              properties:
                slug:
                  type: string
                  pattern: "^[A-Za-z0-9_-]{1,50}$"
                  description: Target endpoint slug
                tag:
                  type: string
                  description: |
                    Deliver to `{slug}+{tag}@mailhooks.cc` instead of the plain address, so a test
                    can wait for its own sample. Printable ASCII without spaces, quotes, brackets,
                    `@`, `,`, `;` or `:`; slug, `+` and tag together at most 64 characters.
      responses:
        "200":
          description: Test email captured
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                required: [status, requestId]
                properties:
                  status:
                    type: string
                    enum: [captured]
                  requestId:
                    type: ["string", "null"]
                    description: ID of the captured email
        "400":
          description: Invalid JSON, slug or tag
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: |
            No endpoint with that slug that you own or that is shared with you. Guest endpoints
            answer `404` too: they belong to no account, and only endpoints on an account receive
            email.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The endpoint's request quota is used up
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: The endpoint has expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "502":
          description: The test email could not be delivered; try again
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Test emails are not available on this server
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  # -- Usage -------------------------------------------------------------------

  /api/usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Get usage
      description: Returns current request quota and usage for the authenticated user.
      responses:
        "200":
          description: Usage information
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageInfo"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # -- Teams -------------------------------------------------------------------

  /api/teams:
    get:
      operationId: listTeams
      tags: [Teams]
      summary: List teams
      description: List all teams the authenticated user owns or is a member of.
      responses:
        "200":
          description: Array of teams
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Team"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      operationId: createTeam
      tags: [Teams]
      summary: Create team
      description: |
        Create a new team. Any account can create one; it is suspended until it has a Teams subscription.
        Rate limited to 10 requests per 10 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: Team display name
      responses:
        "200":
          description: Created team
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Team"
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/teams/{teamId}:
    parameters:
      - $ref: "#/components/parameters/teamId"

    patch:
      operationId: updateTeam
      tags: [Teams]
      summary: Update team
      description: Update the team name. Only the owner can update.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
      responses:
        "200":
          description: Updated team
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    delete:
      operationId: deleteTeam
      tags: [Teams]
      summary: Delete team
      description: Delete a team. Only the owner can delete. All shares are removed.
      responses:
        "204":
          description: Team deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/teams/{teamId}/members:
    parameters:
      - $ref: "#/components/parameters/teamId"

    get:
      operationId: listTeamMembers
      tags: [Teams]
      summary: List team members
      description: List all members and pending invites for a team.
      responses:
        "200":
          description: Members and pending invites
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      $ref: "#/components/schemas/TeamMember"
                  pendingInvites:
                    type: array
                    items:
                      $ref: "#/components/schemas/TeamInvite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/teams/{teamId}/members/{userId}:
    parameters:
      - $ref: "#/components/parameters/teamId"
      - name: userId
        in: path
        required: true
        schema:
          type: string
        description: User ID to remove

    delete:
      operationId: removeTeamMember
      tags: [Teams]
      summary: Remove team member
      description: Remove a member from the team. Only the team owner can remove members.
      responses:
        "204":
          description: Member removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/teams/{teamId}/endpoints:
    parameters:
      - $ref: "#/components/parameters/teamId"

    get:
      operationId: listTeamEndpoints
      tags: [Teams]
      summary: List endpoints shared with a team
      description: |
        Every endpoint shared with the team, oldest share first. Any member may
        call it, also while the team has no subscription.
      responses:
        "200":
          description: Shared endpoints
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items:
                      $ref: "#/components/schemas/TeamSharedEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      operationId: shareEndpointWithTeam
      tags: [Teams]
      summary: Share endpoint with team
      description: |
        Share one of your endpoints with a team. Rate limited to 30 requests per 10 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpointId]
              properties:
                endpointId:
                  type: string
                  description: ID of the endpoint to share
      responses:
        "200":
          description: Endpoint shared
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/teams/{teamId}/endpoints/{endpointId}:
    parameters:
      - $ref: "#/components/parameters/teamId"
      - name: endpointId
        in: path
        required: true
        schema:
          type: string
        description: Endpoint ID to unshare

    delete:
      operationId: unshareEndpointFromTeam
      tags: [Teams]
      summary: Unshare endpoint from team
      description: Remove an endpoint from a team. The endpoint itself is not deleted.
      responses:
        "204":
          description: Endpoint unshared
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/teams/{teamId}/invite:
    parameters:
      - $ref: "#/components/parameters/teamId"

    post:
      operationId: createTeamInvite
      tags: [Teams]
      summary: Invite to team
      description: |
        Send an invitation to join a team by email.
        Rate limited to 20 requests per 10 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  description: Email address to invite
      responses:
        "200":
          description: Invite sent
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TeamInvite"
        "400":
          $ref: "#/components/responses/BadRequestRateLimited"
        "401":
          $ref: "#/components/responses/UnauthorizedRateLimited"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/teams/{teamId}/invites/{inviteId}:
    parameters:
      - $ref: "#/components/parameters/teamId"
      - name: inviteId
        in: path
        required: true
        schema:
          type: string
        description: ID of the pending invite to revoke

    delete:
      operationId: revokeTeamInvite
      tags: [Teams]
      summary: Revoke a pending invite
      description: |
        Withdraw a pending invite. Only the team owner can revoke; accepted and
        declined invites cannot be revoked. Shares the invite rate limit of 20
        requests per 10 minutes.
      responses:
        "200":
          description: Invite revoked
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked:
                    type: boolean
                    const: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalErrorRateLimited"

  /api/teams/{teamId}/leave:
    parameters:
      - $ref: "#/components/parameters/teamId"

    post:
      operationId: leaveTeam
      tags: [Teams]
      summary: Leave team
      description: Leave a team. Only non-owner members can leave. Owners must delete the team.
      responses:
        "200":
          description: Left team
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # -- Invites -----------------------------------------------------------------

  /api/invites:
    get:
      operationId: listInvites
      tags: [Invites]
      summary: List pending invites
      description: List all pending team invitations for the authenticated user.
      responses:
        "200":
          description: Array of pending invites
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/TeamInvite"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/invites/{inviteId}/accept:
    parameters:
      - name: inviteId
        in: path
        required: true
        schema:
          type: string
        description: Invite ID to accept

    post:
      operationId: acceptInvite
      tags: [Invites]
      summary: Accept invite
      description: Accept a pending team invitation.
      responses:
        "200":
          description: Invite accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/invites/{inviteId}/decline:
    parameters:
      - name: inviteId
        in: path
        required: true
        schema:
          type: string
        description: Invite ID to decline

    post:
      operationId: declineInvite
      tags: [Invites]
      summary: Decline invite
      description: Decline a pending team invitation.
      responses:
        "200":
          description: Invite declined
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

  # -- Agents ------------------------------------------------------------------

  /api/agent/identity/challenge:
    post:
      operationId: createAgentChallenge
      tags: [Agents]
      summary: Get a proof-of-work challenge
      description: |
        Issues a proof-of-work challenge for anonymous agent registration (scheme
        `sha256-zero-bits`). No body and no credentials. Solve it by finding, for each sub-puzzle
        `i` from `0` to `count - 1`, a decimal nonce such that SHA-256 of the UTF-8 string
        `<challenge>.<i>.<nonce>` starts with `difficulty` zero bits, then send the nonces as
        `proof_of_work` to `POST /api/agent/identity` before `expires_at`. Each challenge works
        once. The SDKs ship a solver.

        Rate limited to 30 requests per minute per IP.
      security: []
      responses:
        "200":
          description: A fresh challenge
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PowChallenge"
        "400":
          description: Anonymous registration is turned off (`anonymous_not_enabled`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"
        "503":
          $ref: "#/components/responses/AgentUnavailable"

  /api/agent/identity:
    post:
      operationId: registerAgent
      tags: [Agents]
      summary: Register an agent
      description: |
        Registers an agent (auth.md v0.6). `type` picks the kind of registration:

        - `anonymous`: needs a solved proof-of-work challenge. Returns an identity assertion for
          the sandbox (exchange it with the jwt-bearer grant for a `webhooks:sandbox` token) and a
          claim token. Sent without `proof_of_work`, the answer is `400 proof_of_work_required`
          carrying a fresh challenge, so an agent that skipped discovery registers in two calls.
        - `service_auth`: the agent names its human's email as `login_hint` and gets a claim
          attempt to show them. Nothing works until that human signs in and completes it; nothing
          is emailed.
        - `identity_assertion`: an ID-JAG from a trusted identity provider. Only available when
          the deployment trusts one; production trusts none, so this answers `issuer_not_enabled`.

        Errors are `{ error, error_description }` with auth.md's codes. Anonymous registration is
        rate limited per IP and globally, and refused while too many unclaimed registrations are
        live.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/AnonymousRegistrationRequest"
                - $ref: "#/components/schemas/ServiceAuthRegistrationRequest"
                - $ref: "#/components/schemas/IdJagRegistrationRequest"
              discriminator:
                propertyName: type
                mapping:
                  anonymous: "#/components/schemas/AnonymousRegistrationRequest"
                  service_auth: "#/components/schemas/ServiceAuthRegistrationRequest"
                  identity_assertion: "#/components/schemas/IdJagRegistrationRequest"
      responses:
        "200":
          description: Registration created
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/AnonymousRegistration"
                  - $ref: "#/components/schemas/ServiceAuthRegistration"
                  - $ref: "#/components/schemas/IdJagRegistration"
                discriminator:
                  propertyName: registration_type
                  mapping:
                    anonymous: "#/components/schemas/AnonymousRegistration"
                    service_auth: "#/components/schemas/ServiceAuthRegistration"
                    identity_assertion: "#/components/schemas/IdJagRegistration"
        "400":
          description: |
            The registration was refused. Codes: `invalid_request`, `proof_of_work_required` and
            `invalid_challenge` (both carry a fresh challenge to solve), `anonymous_not_enabled`,
            `invalid_login_hint` (not one plain address, or an address at the capture domain),
            `issuer_not_enabled`.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/ProofOfWorkRequiredError"
                  - $ref: "#/components/schemas/AgentError"
        "401":
          description: |
            ID-JAG only. `login_required` (with `max_age`): `auth_time` is missing or too old,
            re-authenticate at the provider. `interaction_required`: the identity's email belongs
            to an existing account; show the human the `claim` block and poll the claim grant.
            Both carry a `WWW-Authenticate: AgentAuth` header.
          headers:
            WWW-Authenticate:
              description: 'An `AgentAuth` challenge, for example `AgentAuth error="login_required", max_age="3600"`'
              schema:
                type: string
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/IdJagInteractionRequired"
                  - $ref: "#/components/schemas/AgentError"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"
        "503":
          $ref: "#/components/responses/AgentUnavailable"

  /api/agent/identity/claim:
    post:
      operationId: startAgentClaim
      tags: [Agents]
      summary: Start a claim attempt
      description: |
        Starts a claim attempt (auth.md Step 4a): the agent asks a human to connect it to their
        account. Send the `claim_token` from registration and `email`, the address of the one
        person who may complete the claim. An anonymous registration must send `email`; a
        `service_auth` registration keeps the email it registered with and may leave it out.

        Show the human `claim_attempt.verification_uri` and `claim_attempt.user_code`. They sign
        in (or sign up) with that email and type the code; meanwhile the agent polls the claim
        grant at `POST /api/oauth2/token` every `interval` seconds. Calling this again replaces
        the current attempt, and the old link stops working. A registration may start 10
        attempts.

        Rate limited to 20 requests per hour per IP.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [claim_token]
              properties:
                claim_token:
                  type: string
                  pattern: "^clm_"
                  description: The claim token returned at registration
                email:
                  type: string
                  format: email
                  description: |
                    The human's email address. Required for anonymous registrations. Addresses at
                    the capture domain (`mailhooks.cc`) are refused.
      responses:
        "200":
          description: Claim attempt started
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClaimAttemptStarted"
        "400":
          description: "`invalid_request` or `invalid_login_hint`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "401":
          description: "`invalid_claim_token`: unknown or revoked claim token. Register again."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "409":
          description: "`claimed_or_in_flight`: already claimed. Poll the claim grant to collect it."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "410":
          description: "`claim_expired`: the registration expired. Register again."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "429":
          description: |
            `rate_limited` (wait for `Retry-After`), or `too_many_attempts` (the registration used
            all its claim attempts; register again)
          headers:
            Retry-After:
              description: Seconds to wait (with `rate_limited`)
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "500":
          $ref: "#/components/responses/AgentServerError"

  /api/agent/sandbox/endpoints:
    get:
      operationId: listSandboxEndpoints
      tags: [Agents]
      summary: List sandbox endpoints
      description: |
        Lists the live sandbox endpoints of the registration behind the bearer token, newest
        first. Takes the access token of an anonymous, unclaimed registration (scope
        `webhooks:sandbox`); API keys and account tokens get `403 sandbox_only`. Reads are
        limited to 120 per minute per registration.
      security:
        - BearerAuth: []
        - OAuth2: [webhooks:sandbox]
      responses:
        "200":
          description: Sandbox endpoints and the registration's limits
          content:
            application/json:
              schema:
                type: object
                required: [endpoints, sandbox]
                properties:
                  endpoints:
                    type: array
                    items:
                      $ref: "#/components/schemas/SandboxEndpoint"
                  sandbox:
                    $ref: "#/components/schemas/SandboxLimits"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SandboxForbidden"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"

    post:
      operationId: createSandboxEndpoint
      tags: [Agents]
      summary: Create sandbox endpoint
      description: |
        Creates a sandbox endpoint for the bearer's registration. It captures webhooks at
        `https://go.webhooks.cc/w/{slug}` and lives as long as the registration (24 hours unless
        claimed). It has no settings: no mock responses, notifications, signing, forwarding or
        email, and any request body is ignored.

        Limits: 3 live endpoints per registration, 25 captures per endpoint, 100 captures per
        registration, and a pool shared by all registrations. Rate limited to 20 creates per hour
        per registration. When a human claims the registration, its sandbox endpoints move into
        their account.
      security:
        - BearerAuth: []
        - OAuth2: [webhooks:sandbox]
      responses:
        "201":
          description: Created sandbox endpoint
          headers:
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SandboxEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SandboxForbidden"
        "409":
          description: "`sandbox_endpoint_limit`: the registration holds 3 endpoints. Delete one first."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"
        "503":
          description: "`sandbox_full`: the shared pool is full. Retry later, or ask a human to sign up."
          headers:
            Retry-After:
              description: Seconds to wait before retrying
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"

  /api/agent/sandbox/endpoints/{slug}:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: getSandboxEndpoint
      tags: [Agents]
      summary: Get sandbox endpoint
      description: |
        One sandbox endpoint of the bearer's registration. A slug of another registration, or of
        any account, answers the same `404` as a missing one.
      security:
        - BearerAuth: []
        - OAuth2: [webhooks:sandbox]
      responses:
        "200":
          description: Sandbox endpoint
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SandboxEndpoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SandboxForbidden"
        "404":
          $ref: "#/components/responses/SandboxNotFound"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"

    delete:
      operationId: deleteSandboxEndpoint
      tags: [Agents]
      summary: Delete sandbox endpoint
      description: |
        Deletes the sandbox endpoint and its captures, freeing a slot in the pool. The
        registration's capture budget is not refunded.
      security:
        - BearerAuth: []
        - OAuth2: [webhooks:sandbox]
      responses:
        "204":
          description: Sandbox endpoint deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SandboxForbidden"
        "404":
          $ref: "#/components/responses/SandboxNotFound"
        "500":
          $ref: "#/components/responses/AgentServerError"

  /api/agent/sandbox/endpoints/{slug}/requests:
    parameters:
      - $ref: "#/components/parameters/slug"

    get:
      operationId: listSandboxRequests
      tags: [Agents]
      summary: List sandbox requests
      description: |
        Requests a sandbox endpoint captured, newest first, in the same shape as
        `GET /api/endpoints/{slug}/requests`.
      security:
        - BearerAuth: []
        - OAuth2: [webhooks:sandbox]
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
          description: Maximum number of requests to return (at most 100)
        - name: since
          in: query
          schema:
            type: integer
            minimum: 0
          description: Only return requests received at or after this Unix timestamp (ms)
      responses:
        "200":
          description: Array of captured requests
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Request"
        "400":
          description: "`invalid_request`: `limit` or `since` is not a valid number"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SandboxForbidden"
        "404":
          $ref: "#/components/responses/SandboxNotFound"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"

  /api/agent/sandbox/requests/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
        description: Request ID

    get:
      operationId: getSandboxRequest
      tags: [Agents]
      summary: Get sandbox request
      description: |
        One captured request, when one of the bearer's sandbox endpoints captured it. Any other
        id answers `404`.
      security:
        - BearerAuth: []
        - OAuth2: [webhooks:sandbox]
      responses:
        "200":
          description: Request details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Request"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SandboxForbidden"
        "404":
          $ref: "#/components/responses/SandboxNotFound"
        "429":
          $ref: "#/components/responses/AgentRateLimited"
        "500":
          $ref: "#/components/responses/AgentServerError"

  # -- OAuth -------------------------------------------------------------------

  /api/oauth2/token:
    post:
      operationId: issueToken
      tags: [OAuth]
      summary: Token endpoint
      description: |
        Issues access tokens (RFC 6749). The request is form-encoded
        (`application/x-www-form-urlencoded`); JSON is accepted too. `grant_type` picks one of
        four grants:

        - `authorization_code`: redeems the code from `https://webhooks.cc/oauth/authorize` with
          its PKCE `code_verifier`. Returns an access token and a refresh token.
        - `refresh_token`: trades a refresh token for a new access token and a new refresh token.
          Refresh tokens rotate. A client that lost the answer may retry with the old one within
          60 seconds and gets a new pair; after that, the old one (or one a retry replaced) ends
          the connection. A refresh token stops working after 90 days unused.
        - `urn:ietf:params:oauth:grant-type:jwt-bearer` (RFC 7523, auth.md Step 5): exchanges a
          registered agent's identity assertion for a one-hour access token. A pre-claim
          assertion yields a `webhooks:sandbox` token (no user); a claimed one yields
          `webhooks:read webhooks:write`. A registration keeps at most 5 live tokens; a new one
          drops the oldest.
        - `urn:workos:agent-auth:grant-type:claim` (auth.md Step 4c): the agent's poll during a
          claim, in RFC 8628's vocabulary. Answers `authorization_pending` until the human enters
          the code, then once returns account credentials and a claimed identity assertion; the
          claim token is used up by that answer. Poll at most every 5 seconds or the answer is
          `slow_down`.

        Every access token is an ordinary `whcc_` bearer token. Answers carry
        `Cache-Control: no-store` and `Pragma: no-cache`, and errors use the RFC 6749 section 5.2
        envelope. Rate limited per IP: 600 requests per minute for `authorization_code` and
        `refresh_token`, 60 for the jwt-bearer and claim grants.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              oneOf:
                - $ref: "#/components/schemas/AuthorizationCodeGrant"
                - $ref: "#/components/schemas/RefreshTokenGrant"
                - $ref: "#/components/schemas/JwtBearerGrant"
                - $ref: "#/components/schemas/ClaimGrant"
              discriminator:
                propertyName: grant_type
                mapping:
                  authorization_code: "#/components/schemas/AuthorizationCodeGrant"
                  refresh_token: "#/components/schemas/RefreshTokenGrant"
                  "urn:ietf:params:oauth:grant-type:jwt-bearer": "#/components/schemas/JwtBearerGrant"
                  "urn:workos:agent-auth:grant-type:claim": "#/components/schemas/ClaimGrant"
      responses:
        "200":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "400":
          description: |
            RFC 6749 error. Codes: `invalid_request`, `invalid_grant` (the code, refresh token or
            assertion is invalid, expired, used or revoked, or was issued to another `client_id`
            or redirect URI; for a registered agent, register again), `unsupported_grant_type`,
            `invalid_target` (a `resource` this server does not serve). The claim grant adds RFC
            8628's `authorization_pending`, `slow_down`, `expired_token` and `access_denied`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "429":
          $ref: "#/components/responses/OAuthRateLimited"
        "500":
          $ref: "#/components/responses/OAuthServerError"
        "503":
          description: "`temporarily_unavailable`: token issuance is not configured on this server"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"

  /api/oauth2/revoke:
    post:
      operationId: revokeToken
      tags: [OAuth]
      summary: Revoke a token
      description: |
        Revokes an access token or a refresh token (RFC 7009) issued by `POST /api/oauth2/token`.
        API keys made on the dashboard are never revoked here. The answer is `200` with an empty
        body whether or not the token existed. Revoking a registered agent's access token leaves
        its identity assertion valid for a new exchange.

        Rate limited to 60 requests per minute per IP.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: The access token (`whcc_...`) or refresh token to revoke
                token_type_hint:
                  type: string
                  enum: [access_token, refresh_token]
                  description: Which kind of token `token` is. Optional; the server finds out itself.
      responses:
        "200":
          description: Revoked, or the token was unknown. Empty body.
        "400":
          description: "`invalid_request`: `token` is missing"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "429":
          $ref: "#/components/responses/OAuthRateLimited"
        "500":
          $ref: "#/components/responses/OAuthServerError"

  /api/oauth2/register:
    post:
      operationId: registerOAuthClient
      tags: [OAuth]
      summary: Register an OAuth client
      description: |
        Dynamic client registration (RFC 7591) for OAuth clients that do not host a Client ID
        Metadata Document. Clients are public: no client secret is issued, and the client is
        registered with `token_endpoint_auth_method` `none` whatever it asked for (RFC 7591
        section 3.2.1); it proves itself with PKCE. Use the returned `client_id` at
        `https://webhooks.cc/oauth/authorize` and the token endpoint. Rate limited to 20
        registrations per hour per IP; a client nobody uses is deleted after a day.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthClientMetadata"
      responses:
        "201":
          description: Client registered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthClient"
        "400":
          description: |
            RFC 7591 error: `invalid_redirect_uri` (no redirect URIs, more than 10, more than 4096
            characters together, or one that is not allowed: see `redirect_uris`) or
            `invalid_client_metadata` (`grant_types` without `authorization_code`, or
            `response_types` without `code`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthError"
        "429":
          $ref: "#/components/responses/OAuthRateLimited"
        "500":
          $ref: "#/components/responses/OAuthServerError"

  # -- Public ------------------------------------------------------------------

  /api/stats:
    get:
      operationId: getStats
      tags: [Usage]
      summary: Get public stats
      description: Public aggregate statistics. No authentication required. Cached for 1 hour.
      security: []
      responses:
        "200":
          description: Aggregate stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_webhooks:
                    type: integer
                  total_endpoints:
                    type: integer
                  total_users:
                    type: integer

  /api/health:
    get:
      operationId: getHealth
      tags: [Usage]
      summary: Health check
      description: Check API and database health. No authentication required.
      security: []
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [ok, degraded]
                  checks:
                    type: object
                    additionalProperties:
                      type: string
                      enum: [ok, error]
        "503":
          description: Service degraded
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [degraded]
                  checks:
                    type: object
                    additionalProperties:
                      type: string
                      enum: [ok, error]

# -- Components ----------------------------------------------------------------

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key with `whcc_` prefix. Generate from https://webhooks.cc/account.

        Access tokens issued by `POST /api/oauth2/token` (OAuth 2.1 clients and registered agents)
        are `whcc_` bearer tokens too and go in the same header.

    OAuth2:
      type: oauth2
      description: |
        OAuth 2.1 for apps and MCP clients that act for a signed-in webhooks.cc user.

        - **PKCE is required**, with `code_challenge_method=S256`. Plain challenges are refused.
        - **Clients are public** (`token_endpoint_auth_method: none`): there is no client secret.
        - **`client_id`** is either a Client ID Metadata Document URL (an https URL the client
          hosts, which serves its metadata) or an id issued by dynamic client registration
          (`POST /api/oauth2/register`, RFC 7591).
        - The authorization endpoint (`https://webhooks.cc/oauth/authorize`) asks the user to sign
          in and approve the client, then redirects to `redirect_uri` with `code`, `state` and
          `iss` (RFC 9207). Check that `iss` is `https://webhooks.cc` before redeeming the code.
        - Redeem the code at the token endpoint with the `code_verifier`. The answer carries an
          access token and a refresh token. **Refresh tokens rotate**: each refresh returns a new
          one and the old one stops working.
        - Server metadata: `https://webhooks.cc/.well-known/oauth-authorization-server` (RFC 8414).

        The access token is an ordinary `whcc_` bearer token, accepted on the whole API and on the
        remote MCP server at `https://webhooks.cc/mcp`.

        **Scopes are advisory.** Account tokens always carry `webhooks:read` and `webhooks:write`;
        the routes decide what a token reaches. Tokens of an agent registration that no human has
        claimed yet carry only `webhooks:sandbox`: they have no user, work only on the
        `/api/agent/sandbox/*` routes, and get `403` on account routes.

        Agents that register without a human (anonymous or with their human's email) follow
        https://webhooks.cc/auth.md instead of this flow and use the jwt-bearer and claim grants
        of the same token endpoint.
      flows:
        authorizationCode:
          authorizationUrl: https://webhooks.cc/oauth/authorize
          tokenUrl: https://webhooks.cc/api/oauth2/token
          refreshUrl: https://webhooks.cc/api/oauth2/token
          scopes:
            webhooks:read: Read endpoints, requests and usage
            webhooks:write: Create, change and delete endpoints and requests
            webhooks:sandbox: |
              Pre-claim agent tokens: the agent sandbox only. Issued by the jwt-bearer grant to
              anonymous agent registrations, never by this flow.

  parameters:
    extracts:
      name: extracts
      in: query
      schema:
        type: string
        enum: ["true", "false"]
      description: |
        `true` adds `extracts` (the one-time codes and links found in it) to each captured email.
        Off by default.

    slug:
      name: slug
      in: path
      required: true
      schema:
        type: string
        pattern: "^[A-Za-z0-9_-]{1,50}$"
      description: Endpoint slug (URL-safe identifier)

    teamId:
      name: teamId
      in: path
      required: true
      schema:
        type: string
      description: Team ID

  headers:
    X-RateLimit-Limit:
      description: Maximum requests allowed in the current window
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Remaining requests in the current window
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix timestamp (seconds) when the window resets
      schema:
        type: integer

  schemas:
    AlertSettings:
      type: object
      properties:
        enabled:
          type: boolean
        fieldFailures:
          type: [integer, "null"]
          minimum: 1
          maximum: 100
          description: Emails in a row that miss a required field before an alert opens
        fieldFailureShare:
          type: [integer, "null"]
          minimum: 1
          maximum: 100
          description: Percent of the last 20 emails that missed a field
        requiredFields:
          type: [array, "null"]
          items:
            type: string
        silenceHours:
          type: [integer, "null"]
          minimum: 1
          maximum: 720
        destinationFailures:
          type: boolean
        notifyEmail:
          type: boolean
        notifyDestinationIds:
          type: array
          items:
            type: string
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable error message

    SuccessResponse:
      type: object
      required: [success]
      properties:
        success:
          type: boolean

    Endpoint:
      type: object
      required: [id, slug, createdAt]
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: string
        url:
          type: string
          description: Full webhook URL (e.g. https://go.webhooks.cc/w/{slug})
        mockResponse:
          $ref: "#/components/schemas/MockResponse"
        responseRules:
          type: array
          items:
            $ref: "#/components/schemas/ResponseRule"
          description: Ordered conditional response rules. First matching rule wins, then falls back to mockResponse.
        notificationUrl:
          type: string
          format: uri
          description: URL to POST a JSON summary to after each captured request (e.g. Slack/Discord webhook)
        isEphemeral:
          type: boolean
        expiresAt:
          type: integer
          description: Unix timestamp (ms) when the endpoint expires
        createdAt:
          type: integer
          description: Unix timestamp (ms)
        sharedWith:
          type: array
          items:
            $ref: "#/components/schemas/TeamShare"
          description: Teams this endpoint is shared with (present when you own it)
        fromTeam:
          $ref: "#/components/schemas/TeamShare"
          description: Oldest share among your teams (present when shared with you)
        fromTeams:
          type: array
          items:
            $ref: "#/components/schemas/TeamShare"
          description: Every subscribed team of yours this endpoint is shared with (present when shared with you)
        emailAddress:
          type: ["string", "null"]
          description: |
            Address that delivers email to this endpoint (`{slug}@mailhooks.cc`). Null for endpoints
            without an owner (guest endpoints), which receive no email.
        showEmailExtracts:
          type: boolean
          default: true
          description: |
            Whether the one-time codes and links found in emails are shown in the dashboard and
            included (as `codes` and `links`) in forwarded JSON
        site:
          $ref: "#/components/schemas/EndpointSite"
        favorite:
          type: boolean
          description: |
            Whether the caller marked the endpoint as a favourite (their own, also on a shared
            endpoint). Present on List endpoints and Get endpoint.
        forwardEnabled:
          type: boolean
          description: Whether every captured email is forwarded to `forwardUrl` as signed JSON
        forwardUrl:
          type: ["string", "null"]
          format: uri
          description: Where captured emails are forwarded. Only returned to the endpoint's owner.
        hasForwardSecret:
          type: boolean
          description: Whether a forwarding secret exists. The secret itself is never included.
        retentionDays:
          type: ["integer", "null"]
          description: Days requests are kept here; null keeps them as long as the plan does. Owner only.
        retentionCapDays:
          type: integer
          description: The longest retention this endpoint may have, its billing account's plan's. Owner only.
        deleteAfterDelivery:
          type: boolean
          description: Requests are deleted once every delivery is settled. Owner only.
        layouts:
          type: array
          items:
            $ref: "#/components/schemas/Layout"
          description: How the endpoint reads its emails; the first that matches applies. Owner only.
        layoutsCap:
          type: integer
          description: Layouts the plan uses; later ones are kept but not matched. Owner only.

    Layout:
      type: object
      required: [id, name]
      description: How an endpoint reads the emails of one sender or kind (see /docs/layouts).
      properties:
        id:
          type: string
          pattern: "^[A-Za-z0-9_-]{1,40}$"
        name:
          type: string
          maxLength: 100
        enabled:
          type: boolean
          default: true
        match:
          type: object
          description: When it applies, in the rule language of destination filters; no conditions takes every email.
          properties:
            logic:
              type: string
              enum: [and, or]
            conditions:
              type: array
              maxItems: 10
              items:
                $ref: "#/components/schemas/ResponseRuleCondition"
        fields:
          type: array
          maxItems: 20
          items:
            type: object
            required: [name, from, pattern]
            properties:
              name:
                type: string
              from:
                type: string
                enum: [subject, text, html, from, to, header]
              header:
                type: string
              pattern:
                type: string
                description: RE2; the first group is the value
        lineItems:
          type: ["object", "null"]
          required: [from]
          properties:
            from:
              type: string
              enum: [text, html, attachment]
            pattern:
              type: string
              description: For text and html, RE2 with named groups found again and again; each match is a row
            start:
              type: string
            end:
              type: string
            attachment:
              type: string
              description: For attachments, the file name with * and ? wildcards
            sheet:
              type: string
            columns:
              type: array
              maxItems: 30
              items:
                type: object
                required: [name, column]
                properties:
                  name:
                    type: string
                  column:
                    type: string
                    description: The header's text or a column letter
        parseAttachments:
          type: boolean
          description: Parse CSV, TSV, XLSX, XLS, ODS, PDF text and text attachments; uses parsed pages

    ParsedEmail:
      type: object
      description: What the endpoint's layouts found in an email.
      properties:
        status:
          type: string
          enum: [matched, no_layout]
        layout:
          type: ["object", "null"]
          properties:
            id:
              type: string
            name:
              type: string
        fields:
          type: object
          additionalProperties:
            type: ["string", "null"]
        lineItems:
          type: array
          items:
            type: object
            additionalProperties:
              type: ["string", "null"]
        lineItemColumns:
          type: array
          items:
            type: string
          description: The line items' columns in order
        attachments:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
              filename:
                type: ["string", "null"]
              format:
                type: string
              pages:
                type: integer
              sheets:
                type: array
                items:
                  type: object
              text:
                type: ["string", "null"]
              truncated:
                type: boolean
              error:
                type: ["string", "null"]
                description: limit, not_kept, not_parsed, too_large, malformed, encrypted, zip_bomb or empty
        pages:
          type: integer
          description: Parsed pages this email used

    MockResponse:
      type: object
      required: [status, body, headers]
      properties:
        status:
          type: integer
          minimum: 100
          maximum: 599
          description: HTTP status code
        body:
          type: string
          description: Response body
        headers:
          type: object
          additionalProperties:
            type: string
          description: Response headers
        delay:
          type: integer
          minimum: 0
          maximum: 30000
          description: Response delay in milliseconds

    ResponseRuleCondition:
      type: object
      required: [field, op]
      properties:
        field:
          type: string
          enum: [method, path, header, body_contains, body_path, query]
          description: Request field to match against
        op:
          type: string
          enum: [eq, contains, starts_with, matches, exists]
          description: Comparison operator
        value:
          type: string
          maxLength: 4096
          description: Value to compare against (required unless op is "exists")
        name:
          type: string
          maxLength: 256
          description: Header name or query param name (required for header/query conditions)
        path:
          type: string
          maxLength: 256
          description: JSON dot-notation path (required for body_path conditions)

    ResponseRule:
      type: object
      required: [conditions, response]
      properties:
        id:
          type: string
          description: Stable identifier for UI reference
        name:
          type: string
          maxLength: 200
          description: Human-readable rule name
        enabled:
          type: boolean
          default: true
          description: Whether this rule is active
        logic:
          type: string
          enum: [and, or]
          default: and
          description: How to combine conditions ("and" = all must match, "or" = any must match)
        conditions:
          type: array
          items:
            $ref: "#/components/schemas/ResponseRuleCondition"
          minItems: 1
          maxItems: 10
          description: Conditions to evaluate (1-10)
        response:
          $ref: "#/components/schemas/MockResponse"
          description: Response to return when conditions match

    CreateEndpointRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 100
          description: Display name
        isEphemeral:
          type: boolean
          description: Whether the endpoint should auto-expire
        expiresAt:
          type: integer
          description: Unix timestamp (ms) for expiry (implies ephemeral)
        mockResponse:
          $ref: "#/components/schemas/MockResponse"
        responseRules:
          type: array
          items:
            $ref: "#/components/schemas/ResponseRule"
          maxItems: 50
          description: Ordered conditional response rules (first match wins, max 50)
        notificationUrl:
          type: string
          format: uri
          maxLength: 2048
          description: URL to POST a JSON summary to after each captured request
        site:
          allOf:
            - $ref: "#/components/schemas/EndpointSite"
          default: both
          description: |
            Which site's dashboard lists the new endpoint. The dashboard sends the site it is on;
            left out, it is `both`.

    EndpointSite:
      type: string
      enum: [webhooks, mailhooks, both]
      description: |
        Which site's dashboard lists the endpoint: webhooks.cc, mailhooks.cc or both. Only the
        listing: every endpoint keeps its HTTP URL and email address and captures both, whatever
        this says.

    UpdateEndpointRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 100
          description: New display name
        mockResponse:
          oneOf:
            - $ref: "#/components/schemas/MockResponse"
            - type: "null"
          description: Default mock response (used when no rule matches), or null to clear
        responseRules:
          oneOf:
            - type: array
              items:
                $ref: "#/components/schemas/ResponseRule"
              maxItems: 50
            - type: "null"
          description: Conditional response rules, or null to clear
        notificationUrl:
          oneOf:
            - type: string
              format: uri
              maxLength: 2048
            - type: "null"
          description: Notification webhook URL, or null to clear. Owner only.
        showEmailExtracts:
          type: boolean
          description: |
            Show the codes and links found in emails, and include them in forwarded JSON.
            Owner only, because it changes what forwarding sends.
        site:
          allOf:
            - $ref: "#/components/schemas/EndpointSite"
          description: Which site's dashboard lists the endpoint. Owner only.
        forwardEnabled:
          type: boolean
          description: Forward every captured email to `forwardUrl`. Needs a URL. Owner only.
        forwardUrl:
          oneOf:
            - type: string
              format: uri
              maxLength: 2048
            - type: string
              const: ""
            - type: "null"
          description: |
            URL to forward captured emails to, or null (or `""`) to remove it. Must be `https` on a
            public host name: no IP addresses, `localhost`, credentials in the URL, or blocked
            ports (mail, database, and other internal services). Owner only.
        retentionDays:
          type: ["integer", "null"]
          minimum: 1
          maximum: 365
          description: Keep requests this many days (up to the plan's), or null for the plan's. Owner only.
        deleteAfterDelivery:
          type: boolean
          description: Delete each request once every delivery is settled. Owner only.
        layouts:
          type: ["array", "null"]
          maxItems: 50
          items:
            $ref: "#/components/schemas/Layout"
          description: Replaces the layouts; null or [] removes them. Patterns are checked with RE2. Owner only.

    Request:
      type: object
      required: [id, endpointId, method, path, headers, queryParams, ip, size, receivedAt, kind]
      properties:
        id:
          type: string
        endpointId:
          type: string
        method:
          type: string
          description: HTTP method, or `EMAIL` for a captured email
        path:
          type: string
          description: Path after the slug, or the recipient address for a captured email
        headers:
          type: object
          additionalProperties:
            type: string
          description: |
            Request headers. For an email, the message headers, lowercase names, repeated headers
            joined with a newline.
        body:
          type: string
          description: |
            Request body. For an email, the original message, or only its headers when the message
            was over 1 MB.
        bodyRaw:
          type: string
          description: Base64 of the exact body bytes, present only when the body is not valid UTF-8
        queryParams:
          type: object
          additionalProperties:
            type: string
        contentType:
          type: string
          description: Content-Type of the request; `message/rfc822` for an email
        ip:
          type: string
          description: Client IP, or the sending mail server's IP for an email
        size:
          type: integer
        receivedAt:
          type: integer
          description: Unix timestamp (ms)
        kind:
          type: string
          enum: [http, email]
          description: "`email` for a captured email, `http` for everything else"
        email:
          oneOf:
            - $ref: "#/components/schemas/EmailCapture"
            - type: "null"
          description: The parsed message when `kind` is `email`, null otherwise
        parsed:
          oneOf:
            - $ref: "#/components/schemas/ParsedEmail"
            - type: "null"
          description: What the endpoint's layouts found, once the parse is done (emails on endpoints with layouts)
        extracts:
          oneOf:
            - $ref: "#/components/schemas/EmailExtracts"
            - type: "null"
          description: |
            Only with `extracts=true`, and only on a captured email: the one-time codes and links
            found in it, as the dashboard shows them. Null when the endpoint's `showEmailExtracts`
            is off.

    StreamRequest:
      type: object
      description: The data of a `request` event on the SSE stream.
      required:
        [_id, _creationTime, endpointId, method, path, headers, queryParams, ip, size, receivedAt]
      properties:
        _id:
          type: string
          description: The request ID, as `id` in Request
        _creationTime:
          type: integer
          description: Unix timestamp (ms), the same as `receivedAt`
        endpointId:
          type: string
        method:
          type: string
          description: HTTP method, or `EMAIL` for a captured email
        path:
          type: string
          description: Path after the slug, or the recipient address for a captured email
        headers:
          type: object
          additionalProperties:
            type: string
        body:
          type: string
        bodyRaw:
          type: string
          description: Base64 of the exact body bytes, present only when the body is not valid UTF-8
        queryParams:
          type: object
          additionalProperties:
            type: string
        contentType:
          type: string
        ip:
          type: string
        size:
          type: integer
        receivedAt:
          type: integer
          description: Unix timestamp (ms)
        kind:
          type: string
          enum: [http, email]
          description: "`email` for a captured email, `http` for everything else"

    EmailCapture:
      type: object
      description: |
        A captured email, parsed. Anything missing or malformed in the message is null or an empty
        list. Text and HTML parts are kept up to 256 KB each; attachment contents are not kept.
        See [Email capture](https://webhooks.cc/docs/email-capture).
      required:
        [
          subject,
          from,
          to,
          cc,
          replyTo,
          sender,
          date,
          messageId,
          inReplyTo,
          tag,
          text,
          html,
          textFromHtml,
          attachments,
          auth,
          smtp,
          parseError,
          truncated,
        ]
      properties:
        subject:
          type: ["string", "null"]
        from:
          type: array
          items:
            $ref: "#/components/schemas/EmailAddress"
        to:
          type: array
          items:
            $ref: "#/components/schemas/EmailAddress"
        cc:
          type: array
          items:
            $ref: "#/components/schemas/EmailAddress"
        replyTo:
          type: array
          items:
            $ref: "#/components/schemas/EmailAddress"
        sender:
          type: array
          items:
            $ref: "#/components/schemas/EmailAddress"
        date:
          type: ["string", "null"]
          format: date-time
          description: The message's Date header as RFC 3339, null when missing or invalid
        messageId:
          type: ["string", "null"]
        inReplyTo:
          type: array
          items:
            type: string
        tag:
          type: ["string", "null"]
          description: The `+tag` of the address the email was sent to, as the sender wrote it
        text:
          type: ["string", "null"]
          description: The text part
        html:
          type: ["string", "null"]
          description: The HTML part
        textFromHtml:
          type: boolean
          description: The message had no text part, so `text` was derived from the HTML
        attachments:
          type: array
          items:
            $ref: "#/components/schemas/EmailAttachment"
        auth:
          oneOf:
            - $ref: "#/components/schemas/EmailAuth"
            - type: "null"
          description: Sender checks run when the message arrived, null when there are none
        smtp:
          oneOf:
            - $ref: "#/components/schemas/EmailSmtp"
            - type: "null"
          description: The SMTP session the message arrived in
        parseError:
          type: boolean
          description: The message could not be parsed; the original is still in the request's `body`
        truncated:
          type: object
          required: [raw, text, html, headers, addresses, attachments]
          properties:
            raw:
              type: boolean
              description: The message was over 1 MB, so the request's `body` holds its headers only
            text:
              type: boolean
              description: The text part was cut at 256 KB
            html:
              type: boolean
              description: The HTML part was cut at 256 KB
            headers:
              type: boolean
              description: The header section was over 1 MB, so only its start was parsed and the body was skipped
            addresses:
              type: boolean
              description: An address list had more than 100 entries; the rest were dropped
            attachments:
              type: boolean
              description: The message had more than 100 attachments; only the first 100 are listed

    EmailExtracts:
      type: object
      required: [codes, links]
      description: |
        One-time codes and links found in an email. Codes are only taken when a word such as
        "code" or "verification" sits next to them, and come in the order they appear, the subject
        first. Links that look like what the email asks you to click (confirm, verify, reset, sign
        in) come first; unsubscribe, tracking and social links are left out.
      properties:
        codes:
          type: array
          maxItems: 20
          items:
            type: string
          description: Codes with spaces and dashes removed, such as `482913`
        links:
          type: array
          maxItems: 20
          items:
            $ref: "#/components/schemas/ExtractedLink"

    ExtractedLink:
      type: object
      required: [url, label, action]
      properties:
        url:
          type: string
        label:
          type: ["string", "null"]
          description: The link text, when the link came from the HTML part
        action:
          type: boolean
          description: Looks like what the email asks you to click (confirm, verify, reset, sign in)

    EmailAddress:
      type: object
      required: [name, address]
      properties:
        name:
          type: ["string", "null"]
        address:
          type: ["string", "null"]

    EmailAttachment:
      type: object
      required: [filename, contentType, size, contentId, inline]
      properties:
        filename:
          type: ["string", "null"]
        contentType:
          type: string
        size:
          type: integer
          description: Size in bytes. The contents are not kept.
        contentId:
          type: ["string", "null"]
          description: Content-ID, for images the HTML references with `cid:`
        inline:
          type: boolean

    EmailCheck:
      type: object
      required: [result, domain]
      properties:
        result:
          type: string
          description: Verdict such as `pass`, `fail`, `softfail`, `neutral`, `none`, `temperror`, `permerror`
        domain:
          type: ["string", "null"]

    EmailAuth:
      type: object
      required: [spf, dkim, dmarc, iprev, authenticationResults, error]
      properties:
        spf:
          oneOf:
            - $ref: "#/components/schemas/EmailCheck"
            - type: "null"
        dkim:
          type: array
          description: One result per DKIM signature
          items:
            allOf:
              - $ref: "#/components/schemas/EmailCheck"
              - type: object
                required: [selector]
                properties:
                  selector:
                    type: ["string", "null"]
        dmarc:
          oneOf:
            - allOf:
                - $ref: "#/components/schemas/EmailCheck"
                - type: object
                  required: [policy, reason]
                  properties:
                    policy:
                      type: ["string", "null"]
                    reason:
                      type: ["string", "null"]
            - type: "null"
        iprev:
          oneOf:
            - type: object
              required: [result, ptr]
              properties:
                result:
                  type: string
                ptr:
                  type: ["string", "null"]
                  description: Host name the sending server's IP address points to
            - type: "null"
          description: Reverse DNS check of the sending server
        authenticationResults:
          type: ["string", "null"]
          description: The Authentication-Results header written when the message arrived
        error:
          type: ["string", "null"]
          description: Set when the checks could not run (`unavailable`, `timeout`, `busy`, `failed`)

    EmailSmtp:
      type: object
      required: [clientIp, clientRdns, helo, tls, envelopeFrom, envelopeTo, size, test]
      properties:
        clientIp:
          type: ["string", "null"]
        clientRdns:
          type: ["string", "null"]
        helo:
          type: ["string", "null"]
        tls:
          oneOf:
            - type: object
              required: [version, cipher]
              properties:
                version:
                  type: ["string", "null"]
                cipher:
                  type: ["string", "null"]
            - type: "null"
          description: The encrypted connection, null when the message arrived without TLS
        envelopeFrom:
          type: ["string", "null"]
          description: The SMTP envelope sender (MAIL FROM)
        envelopeTo:
          type: array
          items:
            type: string
          description: The SMTP envelope recipients (RCPT TO) that reached this endpoint
        size:
          type: ["integer", "null"]
          description: Size of the whole message as received, in bytes
        test:
          type: boolean
          description: True only for the dashboard's "Send test email" sample

    ForwardingSecret:
      type: object
      required: [secret]
      properties:
        secret:
          type: string
          description: |
            `whsec_` followed by the base64 signing key. Verify `webhook-signature` with the key
            (the base64 after the prefix), as the Standard Webhooks libraries do.

    ForwardTestResult:
      type: object
      required: [status, durationMs, excerpt, error, delivered, sample]
      properties:
        status:
          type: ["integer", "null"]
          description: Status your server answered, null when no answer arrived
        durationMs:
          type: integer
        excerpt:
          type: ["string", "null"]
          description: The start of your server's response body (up to 1 KB)
        error:
          type: ["string", "null"]
          description: Why no answer arrived (timeout, refused connection, TLS problem, blocked URL), null when one did
        delivered:
          type: boolean
          description: True when your server answered with a 2xx status
        sample:
          type: boolean
          description: True when the endpoint had no email yet, so a sample was sent

    Delivery:
      type: object
      description: One forwarded copy of an email
      required:
        [
          id,
          requestId,
          status,
          attempts,
          createdAt,
          finishedAt,
          nextAttemptAt,
          lastStatus,
          lastError,
          attemptLog,
        ]
      properties:
        id:
          type: string
        requestId:
          type: string
        status:
          type: string
          enum: [pending, succeeded, failed]
        attempts:
          type: integer
          description: Tries made so far
        createdAt:
          type: integer
          description: Unix timestamp (ms) when the delivery was queued
        finishedAt:
          type: ["integer", "null"]
          description: Unix timestamp (ms) when it succeeded or failed for good, null while pending
        nextAttemptAt:
          type: ["integer", "null"]
          description: Unix timestamp (ms) of the next try, for pending deliveries only
        lastStatus:
          type: ["integer", "null"]
          description: Status your server answered on the last try
        lastError:
          type: ["string", "null"]
          description: Why the last try failed, null after a success
        attemptLog:
          type: array
          description: Every try, newest first
          items:
            $ref: "#/components/schemas/DeliveryAttempt"

    DeliveryAttempt:
      type: object
      required: [attemptedAt, status, durationMs, error, responseExcerpt]
      properties:
        attemptedAt:
          type: integer
          description: Unix timestamp (ms)
        status:
          type: ["integer", "null"]
          description: Status your server answered, null when no answer arrived
        durationMs:
          type: integer
        error:
          type: ["string", "null"]
          description: Why the try failed (including a non-2xx answer), null when it succeeded
        responseExcerpt:
          type: ["string", "null"]
          description: The start of your server's response body (up to 1 KB)

    RecentDelivery:
      type: object
      required: [id, requestId, status, attempts, createdAt, lastStatus, lastError, subject]
      properties:
        id:
          type: string
        requestId:
          type: string
        status:
          type: string
          enum: [pending, succeeded, failed]
        attempts:
          type: integer
        createdAt:
          type: integer
          description: Unix timestamp (ms) when the delivery was queued
        lastStatus:
          type: ["integer", "null"]
        lastError:
          type: ["string", "null"]
        subject:
          type: ["string", "null"]
          description: Subject of the forwarded email

    SearchResult:
      type: object
      required: [id, slug, method, path, headers, queryParams, ip, size, receivedAt]
      properties:
        id:
          type: string
          description: Synthetic ID (not compatible with requests.get/replay)
        slug:
          type: string
        method:
          type: string
        path:
          type: string
        headers:
          type: object
          additionalProperties:
            type: string
        body:
          type: string
        queryParams:
          type: object
          additionalProperties:
            type: string
        contentType:
          type: string
        ip:
          type: string
        size:
          type: integer
        receivedAt:
          type: integer
        bodyRaw:
          type: string
          description: Base64 of the exact body bytes, present only when the body is not valid UTF-8
        kind:
          type: string
          enum: [http, email]
          description: "`email` for a captured email, `http` for everything else"
        email:
          oneOf:
            - $ref: "#/components/schemas/EmailCapture"
            - type: "null"
          description: The parsed message when `kind` is `email`, null otherwise

    PaginatedRequests:
      type: object
      required: [items, hasMore]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Request"
        cursor:
          type: string
          description: Opaque cursor for the next page
        hasMore:
          type: boolean
          description: Whether another page is available

    UsageInfo:
      type: object
      required: [used, limit, remaining, plan, periodEnd]
      properties:
        used:
          type: integer
          description: Requests used of the period's allowance (limit)
        limit:
          type: integer
          description: The period's allowance, the plan's requests plus monthly blocks
        remaining:
          type: integer
          description: >-
            Requests still accepted this period: what is left of limit, plus one-time blocks,
            plus overage up to its maximum
        blockRequests:
          type: integer
          description: Requests monthly blocks add to limit this period
        creditRequests:
          type: integer
          description: One-time blocks left, in requests, used once the allowance is gone
        overage:
          type: object
          required: [active, used, max]
          properties:
            active:
              type: boolean
              description: Whether overage is counting (a live overage subscription on a paid plan)
            used:
              type: integer
              description: Overage requests counted this period
            max:
              type: integer
              description: The customer's overage maximum per period, in requests
        plan:
          type: string
          enum: [free, pro]
        periodEnd:
          type: ["integer", "null"]
          description: Unix timestamp (ms) for billing period end, null if period not started

    TeamShare:
      type: object
      required: [teamId, teamName]
      properties:
        teamId:
          type: string
        teamName:
          type: string

    Team:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
        name:
          type: string
        role:
          type: string
          enum: [owner, member]
        memberCount:
          type: integer
        createdAt:
          type: integer

    TeamMember:
      type: object
      properties:
        id:
          type: string
          description: Membership ID
        userId:
          type: string
        email:
          type: string
        name:
          type: [string, "null"]
        image:
          type: [string, "null"]
        role:
          type: string
          enum: [owner, member]
        joinedAt:
          type: integer

    TeamInvite:
      type: object
      properties:
        id:
          type: string
        teamId:
          type: string
        teamName:
          type: string
        invitedEmail:
          type: string
          description: Email address of the invited user
        invitedBy:
          type: string
          description: User ID of the person who sent the invite
        inviterEmail:
          type: string
          description: Email address of the person who sent the invite
        status:
          type: string
          enum: [pending, accepted, declined]
        createdAt:
          type: integer

    TeamSharedEndpoint:
      type: object
      properties:
        id:
          type: string
        slug:
          type: string
        name:
          type: [string, "null"]
        ownerId:
          type: string
        ownerName:
          type: [string, "null"]
        ownerEmail:
          type: string
        sharedBy:
          type: string
          description: User ID of the member who shared the endpoint
        sharedAt:
          type: integer
          description: Share time in milliseconds since the epoch
        requestCount:
          type: integer

    # -- Agents ----------------------------------------------------------------

    AgentError:
      type: object
      required: [error, error_description]
      description: |
        Error envelope of the agent routes, with auth.md's codes. Some errors carry extra members
        (a fresh proof-of-work challenge, `max_age`, a claim block).
      properties:
        error:
          type: string
          description: Machine-readable error code
        error_description:
          type: string
          description: Human-readable explanation and what to do next
      additionalProperties: true

    PowChallenge:
      type: object
      required: [challenge, algorithm, difficulty, count, expires_at]
      properties:
        challenge:
          type: string
          description: Opaque, signed challenge string (`pow1.<payload>.<mac>`). Send it back unchanged.
        algorithm:
          type: string
          const: sha256-zero-bits
        difficulty:
          type: integer
          description: Leading zero bits each hash must have
        count:
          type: integer
          description: Number of sub-puzzles, and of nonces to send
        expires_at:
          type: string
          format: date-time
          description: The challenge is refused after this time

    ProofOfWorkSolution:
      type: object
      required: [challenge, nonces]
      properties:
        challenge:
          type: string
          description: The challenge string, unchanged
        nonces:
          type: array
          description: One decimal nonce per sub-puzzle, in order (`count` items)
          items:
            type: string
            pattern: "^[0-9]{1,16}$"

    ProofOfWorkRequiredError:
      description: |
        `proof_of_work_required` or `invalid_challenge`: the error plus a fresh challenge to solve
      allOf:
        - $ref: "#/components/schemas/AgentError"
        - $ref: "#/components/schemas/PowChallenge"
        - type: object
          properties:
            challenge_endpoint:
              type: string
              format: uri
              description: Where to fetch further challenges

    AnonymousRegistrationRequest:
      type: object
      required: [type]
      properties:
        type:
          type: string
          const: anonymous
        client_name:
          type: string
          maxLength: 64
          description: Self-reported agent name, shown to humans labelled as self-reported
        proof_of_work:
          $ref: "#/components/schemas/ProofOfWorkSolution"

    ServiceAuthRegistrationRequest:
      type: object
      required: [type, login_hint]
      properties:
        type:
          type: string
          const: service_auth
        login_hint:
          type: string
          format: email
          description: |
            Email address of the human who will claim the agent. Addresses at the capture domain
            (`mailhooks.cc`) are refused.
        client_name:
          type: string
          maxLength: 64
          description: Self-reported agent name, shown to humans labelled as self-reported

    IdJagRegistrationRequest:
      type: object
      required: [type, assertion_type, assertion]
      properties:
        type:
          type: string
          const: identity_assertion
        assertion_type:
          type: string
          const: urn:ietf:params:oauth:token-type:id-jag
        assertion:
          type: string
          description: The ID-JAG issued by a trusted identity provider

    ClaimAttemptBlock:
      type: object
      required: [user_code, expires_in, verification_uri, interval]
      description: What the agent shows its human (RFC 8628 shape)
      properties:
        user_code:
          type: string
          pattern: "^[0-9]{6}$"
          description: Six-digit code the human types on the claim page
        expires_in:
          type: integer
          description: Seconds until the code expires (15 minutes when issued)
        verification_uri:
          type: string
          format: uri
          description: Claim page the human opens while signed in
        interval:
          type: integer
          description: Seconds to wait between polls of the claim grant

    AnonymousRegistration:
      type: object
      required:
        - registration_id
        - registration_type
        - identity_assertion
        - assertion_expires
        - pre_claim_scopes
        - claim_url
        - claim_token
        - claim_token_expires
        - post_claim_scopes
        - sandbox
      properties:
        registration_id:
          type: string
        registration_type:
          type: string
          const: anonymous
        identity_assertion:
          type: string
          description: Signed assertion to exchange at the token endpoint (jwt-bearer grant)
        assertion_expires:
          type: string
          format: date-time
        pre_claim_scopes:
          type: array
          items:
            type: string
          description: "`[webhooks:sandbox]`"
        claim_url:
          type: string
          format: uri
          description: "`POST` here with the claim token to start a claim"
        claim_token:
          type: string
          description: "`clm_...`. Keep it secret; it starts claims and collects the claimed credentials."
        claim_token_expires:
          type: string
          format: date-time
        post_claim_scopes:
          type: array
          items:
            type: string
          description: "`[webhooks:read, webhooks:write]`"
        sandbox:
          type: object
          required:
            - status
            - endpoints_url
            - lifetime_seconds
            - max_endpoints
            - max_requests_per_endpoint
            - max_requests
          properties:
            status:
              type: string
              enum: [available, full]
            endpoints_url:
              type: string
              format: uri
            lifetime_seconds:
              type: integer
            max_endpoints:
              type: integer
            max_requests_per_endpoint:
              type: integer
            max_requests:
              type: integer
        id:
          type: string
          description: Same as `registration_id` (auth.md v0.7 proposal shape)
        type:
          type: string
          const: anonymous
          description: Same as `registration_type` (auth.md v0.7 proposal shape)
        identity:
          type: object
          description: The assertion again, in the auth.md v0.7 proposal shape
          properties:
            assertion:
              type: string
            expires_at:
              type: string
              format: date-time
        claim:
          type: object
          description: The claim token again, in the auth.md v0.7 proposal shape
          properties:
            token:
              type: string
            expires_at:
              type: string
              format: date-time
            url:
              type: string
              format: uri
        scopes:
          type: object
          description: The scopes again, in the auth.md v0.7 proposal shape
          properties:
            pre_claim:
              type: array
              items:
                type: string
            post_claim:
              type: array
              items:
                type: string

    ServiceAuthRegistration:
      type: object
      required:
        - registration_id
        - registration_type
        - claim_url
        - claim_token
        - claim_token_expires
        - post_claim_scopes
        - claim
      properties:
        registration_id:
          type: string
        registration_type:
          type: string
          const: service_auth
        claim_url:
          type: string
          format: uri
        claim_token:
          type: string
          description: "`clm_...`. Poll the claim grant with it."
        claim_token_expires:
          type: string
          format: date-time
        post_claim_scopes:
          type: array
          items:
            type: string
        claim:
          $ref: "#/components/schemas/ClaimAttemptBlock"

    IdJagRegistration:
      type: object
      required: [registration_id, registration_type, identity_assertion, assertion_expires, scopes]
      properties:
        registration_id:
          type: string
        registration_type:
          type: string
          const: identity_assertion
        identity_assertion:
          type: string
          description: Assertion to exchange at the token endpoint (jwt-bearer grant)
        assertion_expires:
          type: string
          format: date-time
        scopes:
          type: array
          items:
            type: string

    IdJagInteractionRequired:
      description: |
        `interaction_required`: the claim ceremony runs first. Show the human the `claim` block,
        then poll the claim grant with `claim_token`.
      allOf:
        - $ref: "#/components/schemas/AgentError"
        - type: object
          required:
            - registration_id
            - registration_type
            - claim_url
            - claim_token
            - claim_token_expires
            - post_claim_scopes
            - claim
          properties:
            registration_id:
              type: string
            registration_type:
              type: string
              const: identity_assertion
            claim_url:
              type: string
              format: uri
            claim_token:
              type: string
              description: "`clm_...`. Poll the claim grant with it."
            claim_token_expires:
              type: string
              format: date-time
            post_claim_scopes:
              type: array
              items:
                type: string
            claim:
              $ref: "#/components/schemas/ClaimAttemptBlock"

    ClaimAttemptStarted:
      type: object
      required: [registration_id, claim_attempt_id, status, expires_at, claim_attempt]
      properties:
        registration_id:
          type: string
        claim_attempt_id:
          type: string
          description: "`cla_...`"
        status:
          type: string
          const: initiated
        expires_at:
          type: string
          format: date-time
          description: When the code expires
        claim_attempt:
          $ref: "#/components/schemas/ClaimAttemptBlock"

    SandboxLimits:
      type: object
      required: [expiresAt, maxEndpoints, requestLimit, budget]
      properties:
        expiresAt:
          type: integer
          description: Unix timestamp (ms) when the registration and its sandbox expire
        maxEndpoints:
          type: integer
          description: Live endpoints a registration may hold
        requestLimit:
          type: integer
          description: Captures per endpoint
        budget:
          type: object
          required: [used, limit]
          description: Captures across all of the registration's endpoints
          properties:
            used:
              type: integer
            limit:
              type: integer

    SandboxEndpoint:
      allOf:
        - $ref: "#/components/schemas/Endpoint"
        - type: object
          required: [requestCount, sandbox]
          properties:
            requestCount:
              type: integer
              description: Captures so far
            sandbox:
              $ref: "#/components/schemas/SandboxLimits"

    # -- OAuth -----------------------------------------------------------------

    OAuthError:
      type: object
      required: [error]
      description: RFC 6749 section 5.2 error envelope (also used by RFC 7009 and RFC 7591)
      properties:
        error:
          type: string
          description: Error code, for example `invalid_grant`
        error_description:
          type: string
          description: Human-readable explanation

    AuthorizationCodeGrant:
      type: object
      required: [grant_type, code, code_verifier, redirect_uri, client_id]
      properties:
        grant_type:
          type: string
          const: authorization_code
        code:
          type: string
          description: The code from the authorization response
        code_verifier:
          type: string
          description: The PKCE verifier whose S256 hash was sent as `code_challenge`
        redirect_uri:
          type: string
          format: uri
          description: The same `redirect_uri` as in the authorization request
        client_id:
          type: string
          description: Client ID Metadata Document URL or a dynamically registered id
        resource:
          type: string
          format: uri
          description: RFC 8707 resource indicator, for example `https://webhooks.cc/mcp`

    RefreshTokenGrant:
      type: object
      required: [grant_type, refresh_token, client_id]
      properties:
        grant_type:
          type: string
          const: refresh_token
        refresh_token:
          type: string
          description: The current refresh token. It stops working once this request succeeds.
        client_id:
          type: string
          description: The client the refresh token was issued to
        resource:
          type: string
          format: uri
          description: RFC 8707 resource indicator

    JwtBearerGrant:
      type: object
      required: [grant_type, assertion]
      properties:
        grant_type:
          type: string
          const: urn:ietf:params:oauth:grant-type:jwt-bearer
        assertion:
          type: string
          description: The identity assertion from agent registration or from the claim grant
        resource:
          type: string
          enum: [https://webhooks.cc/api/, https://webhooks.cc/mcp]
          description: |
            RFC 8707 resource indicator. Optional; the token is the same for both. Any other
            value answers `invalid_target`.

    ClaimGrant:
      type: object
      required: [grant_type, claim_token]
      properties:
        grant_type:
          type: string
          const: urn:workos:agent-auth:grant-type:claim
        claim_token:
          type: string
          pattern: "^clm_"
          maxLength: 64
          description: The claim token from agent registration

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token:
          type: string
          description: "`whcc_...` bearer token"
        token_type:
          type: string
          const: Bearer
        expires_in:
          type: integer
          description: Seconds until the access token expires
        scope:
          type: string
          description: Space-separated scopes, for example `webhooks:read webhooks:write`
        refresh_token:
          type: string
          description: |
            Present for the `authorization_code` and `refresh_token` grants. Use it once; the next
            refresh returns a new one.
        identity_assertion:
          type: string
          description: |
            Claim grant only: the claimed identity assertion. Exchange it with the jwt-bearer grant
            for new access tokens until `assertion_expires`.
        assertion_expires:
          type: string
          format: date-time
          description: Claim grant only

    OAuthClientMetadata:
      type: object
      required: [redirect_uris]
      properties:
        client_name:
          type: string
          description: Name shown to the user on the consent screen
        redirect_uris:
          type: array
          minItems: 1
          items:
            type: string
            format: uri
          maxItems: 10
          description: |
            Redirect URIs, absolute and without a fragment or credentials: https (not on
            webhooks.cc's own hosts), http on a loopback host (`http://127.0.0.1/...`,
            `http://localhost/...`; any port matches, RFC 8252), or an app's own scheme
            (`cursor:`, `vscode:`, `vscode-insiders:`, `vscodium:`, `windsurf:`, `claude:`,
            `zed:`, `kiro:`, or a reverse-domain scheme such as `com.example.app:`).
        client_uri:
          type: string
          format: uri
        grant_types:
          type: array
          items:
            type: string
            enum: [authorization_code, refresh_token]
        response_types:
          type: array
          items:
            type: string
            enum: [code]
        token_endpoint_auth_method:
          type: string
          description: Ignored on input; the client is always registered as `none`.
        application_type:
          type: string
          description: Echoed back when given (`web` or `native`)

    OAuthClient:
      description: The registered metadata, echoed, with the issued `client_id`
      allOf:
        - $ref: "#/components/schemas/OAuthClientMetadata"
        - type: object
          required: [client_id, client_id_issued_at]
          properties:
            client_id:
              type: string
            client_id_issued_at:
              type: integer
              description: Unix timestamp (seconds) when the id was issued

  responses:
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    Unauthorized:
      description: Invalid or missing API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    FavoriteResult:
      description: Whether the endpoint is now one of the caller's favourites
      headers:
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            type: object
            required: [favorite]
            properties:
              favorite:
                type: boolean
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          description: Seconds until the rate limit window resets
          schema:
            type: integer
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    InternalError:
      description: Server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    # Rate-limit-aware error responses (for rate-limited operations)
    BadRequestRateLimited:
      description: Validation error
      headers:
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    UnauthorizedRateLimited:
      description: Invalid or missing API key
      headers:
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    InternalErrorRateLimited:
      description: Server error
      headers:
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    # Agent and OAuth error responses (`{ error, error_description }`)
    AgentRateLimited:
      description: "`rate_limited`: wait for `Retry-After` seconds"
      headers:
        Retry-After:
          description: Seconds until the rate limit window resets
          schema:
            type: integer
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AgentError"

    AgentServerError:
      description: "`server_error`: retry later"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AgentError"

    AgentUnavailable:
      description: |
        `temporarily_unavailable`: registration is not available right now (not configured, or
        too many unclaimed registrations are live). Retry after `Retry-After` seconds.
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AgentError"

    SandboxForbidden:
      description: |
        `sandbox_only`: the token is not the access token of an anonymous agent registration (API
        keys and account tokens use `/api/endpoints`). `sandbox_closed`: the registration was
        claimed, revoked or has expired; a claimed agent uses the normal API.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AgentError"

    SandboxNotFound:
      description: "`not_found`: not an endpoint or request of this registration's sandbox"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AgentError"

    OAuthRateLimited:
      description: "`rate_limited`: wait for `Retry-After` seconds"
      headers:
        Retry-After:
          description: Seconds until the rate limit window resets
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OAuthError"

    OAuthServerError:
      description: "`server_error`: retry later"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OAuthError"
