openapi: 3.0.3
info:
  title: naturali.ai — Webhooks API
  version: 1.0.0
  description: >
    Webhooks and events, scoped to a project (API.md §8). Register an HTTPS
    endpoint, subscribe it to the event types you care about, and naturali POSTs
    a signed JSON envelope to it whenever one of them happens — so an
    integration reacts to a card moving or a message arriving instead of polling
    for it.

    **Every delivery is recorded.** A `WebhookDelivery` row is committed before
    any request is attempted, which is what makes retries and the
    `…:redeliver` action possible: the event survives a receiver being down, and
    it survives naturali restarting mid-flight. Deliveries are readable per
    project, so "did that event reach us, and what did our endpoint say" is a
    question the API answers.

    **Every delivery is signed.** `X-Naturali-Signature` carries a timestamped
    HMAC-SHA256 over the request body — verify it before acting on a payload,
    and reject timestamps outside a window you choose. The signing secret is
    returned exactly once, when the webhook is created and again when it is
    rotated; there is no endpoint that reads it back.
  contact:
    name: naturali.ai
    url: https://naturali.ai
servers:
  - url: '{baseUrl}'
    description: Host of your naturali.ai deployment; every path carries the /v1 prefix.
    variables:
      baseUrl:
        description: Base host URL.
        default: https://api.naturali.ai
tags:
  - name: Webhooks
    description: Subscribe an endpoint to a project's events, and audit what was delivered.
security:
  - bearerAuth: []
paths:
  /v1/projects/{project_id}/webhooks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Webhooks]
      summary: List webhooks
      description: The endpoints registered in the project, newest first.
      operationId: listWebhooks
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of webhooks.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Webhooks]
      summary: Create a webhook
      description: >
        Register an endpoint and subscribe it to one or more event types.

        The response carries `secret` — the signing key, in plaintext. **This is
        the only time it is returned.** Store it where your receiver can read
        it; if you lose it, rotate rather than re-create, so the endpoint keeps
        its delivery history.

        Returns `501` on a deployment with no credential-sealing key configured,
        since the secret could not then be stored safely.
      operationId: createWebhook
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreate'
      responses:
        '201':
          description: Webhook created. The signing secret is included, once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookWithSecret'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '501':
          $ref: '#/components/responses/NotImplemented'
  /v1/projects/{project_id}/webhooks/{webhook_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/WebhookId'
    get:
      tags: [Webhooks]
      summary: Get a webhook
      operationId: getWebhook
      responses:
        '200':
          description: The webhook.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Webhooks]
      summary: Update a webhook
      description: >
        Change the destination, the subscription, the label, or whether
        deliveries are attempted at all. At least one field is required.

        Setting `active: false` is the reversible half of `DELETE`: deliveries
        stop, the endpoint and its history stay. It is what to reach for while a
        receiver is being repaired.
      operationId: updateWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookUpdate'
      responses:
        '200':
          description: Webhook updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      description: >
        Removes the endpoint and its delivery records. To stop deliveries while
        keeping the audit trail, `PATCH` it to `active: false` instead.
      operationId: deleteWebhook
      responses:
        '204':
          description: Webhook deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  '/v1/projects/{project_id}/webhooks/{webhook_id}:rotate-secret':
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/WebhookId'
    post:
      tags: [Webhooks]
      summary: Rotate the signing secret
      description: >
        Issues a new signing secret for the same endpoint and returns it — the
        second and last time a secret is ever returned. This is the `…:rotate-secret`
        action; the path segment is `{webhook_id}:rotate-secret`.

        The change takes effect on the next delivery, including retries of
        deliveries already queued, so roll the new secret out to your receiver
        promptly. There is no overlap window in which both secrets verify.
      operationId: rotateWebhookSecret
      responses:
        '200':
          description: A new signing secret was issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookWithSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '501':
          $ref: '#/components/responses/NotImplemented'
  /v1/projects/{project_id}/webhook-deliveries:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Webhooks]
      summary: List webhook deliveries
      description: >
        Every delivery attempted in the project, newest first — what was sent,
        where, how many times, and what came back. Filter by endpoint, by
        lifecycle status, or by event type.

        Deliveries are per (event, endpoint): an event matching two subscribed
        endpoints produces two rows, retried and observed independently.
      operationId: listWebhookDeliveries
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: webhook_id
          in: query
          required: false
          description: Only deliveries addressed to this endpoint.
          schema:
            type: string
            example: whk_V1StGXR8Z5jdHi6B
        - name: status
          in: query
          required: false
          description: Only deliveries in this state.
          schema:
            type: string
            enum: [pending, success, failed]
        - name: event_type
          in: query
          required: false
          description: Only deliveries of this event type.
          schema:
            type: string
            example: task.created
      responses:
        '200':
          description: A page of deliveries.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/projects/{project_id}/webhook-deliveries/{delivery_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/DeliveryId'
    get:
      tags: [Webhooks]
      summary: Get a webhook delivery
      description: >
        One delivery, including the exact payload that was signed and sent.
      operationId: getWebhookDelivery
      responses:
        '200':
          description: The delivery.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  '/v1/projects/{project_id}/webhook-deliveries/{delivery_id}:redeliver':
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/DeliveryId'
    post:
      tags: [Webhooks]
      summary: Redeliver an event
      description: >
        Queue the same event at the same endpoint again — the recovery path for
        a delivery that failed, or one your receiver dropped. This is the
        `…:redeliver` action; the path segment is `{delivery_id}:redeliver`.

        A **new** delivery is created and returned; the original record is left
        untouched, because its attempt history is the evidence you redelivered
        on. The event's `id` is carried over unchanged, so a receiver deduping
        on the event sees the same event twice while one deduping on
        `X-Naturali-Delivery` sees a distinct delivery.
      operationId: redeliverWebhookDelivery
      responses:
        '202':
          description: A new delivery was queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A naturali API key (nat_sk_…) or a session JWT.
  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum items per page — an integer from 1 to 100 (default 20).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous response's next_cursor.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Client-supplied key to make this mutating POST idempotent.
      schema:
        type: string
    ProjectId:
      name: project_id
      in: path
      required: true
      description: Project public ID (proj_ prefix).
      schema:
        type: string
        example: proj_V1StGXR8Z5jdHi6B
    WebhookId:
      name: webhook_id
      in: path
      required: true
      description: Webhook public ID (whk_ prefix).
      schema:
        type: string
        example: whk_V1StGXR8Z5jdHi6B
    DeliveryId:
      name: delivery_id
      in: path
      required: true
      description: Delivery public ID (whd_ prefix).
      schema:
        type: string
        example: whd_V1StGXR8Z5jdHi6B
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: The resource does not exist (existence is not leaked).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotImplemented:
      description: >
        The requested path is not enabled on this deployment — a webhook secret
        cannot be stored where no credential-sealing key is configured.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: bad_request
            message:
              type: string
              example: '`url` must be an http(s) URL.'
            details:
              type: object
              additionalProperties: true
    EventType:
      type: string
      description: >
        A naturali event type. The set below is what v1 emits — deliberately
        only the events naturali itself causes, so no declared name is one that
        never fires.
      enum:
        - task.created
        - task.updated
        - conversation.started
        - message.received
        - knowledge.document_ingested
        - knowledge.ingest_failed
        - generation.completed
        - generation.failed
      example: task.created
    EventSubscription:
      type: array
      minItems: 1
      description: >
        Which events this endpoint receives. Each entry is an exact type
        (`task.created`), a resource wildcard (`task.*`), or `*` for everything.
        A bare resource name (`task`) matches nothing and is rejected.
      items:
        type: string
      example: [task.*, message.received]
    Event:
      type: object
      description: >
        The envelope POSTed to your endpoint. It is also what a delivery's
        `payload` holds, byte for byte, so the signed body and the recorded one
        are the same document.
      required:
        [id, type, project_id, resource_type, resource_id, data, created_at]
      properties:
        id:
          type: string
          description: >
            Unique per event (`evt_` prefix), and stable across redeliveries —
            dedupe on this if your receiver must process an event exactly once.
          example: evt_V1StGXR8Z5jdHi6B
        type:
          $ref: '#/components/schemas/EventType'
        project_id:
          type: string
          x-naturali-ref: project
          example: proj_V1StGXR8Z5jdHi6B
        resource_type:
          type: string
          description: What the event is about.
          enum: [task, conversation, message, knowledge_document, generation]
          example: task
        resource_id:
          type: string
          description: The id of that resource, in the form its own API uses.
          example: task_V1StGXR8Z5jdHi6B
        data:
          type: object
          additionalProperties: true
          description: >
            The resource, shaped exactly as its own API returns it — a
            `task.created` payload carries the same object `getTask` would.
        created_at:
          type: string
          format: date-time
          example: '2026-07-31T00:00:00.000Z'
    Webhook:
      type: object
      required:
        [id, project_id, url, events, description, active, created_at, updated_at]
      properties:
        id:
          type: string
          description: Public webhook ID (whk_ prefix).
          example: whk_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          x-naturali-ref: project
          example: proj_V1StGXR8Z5jdHi6B
        url:
          type: string
          format: uri
          description: Where deliveries are POSTed.
          example: https://example.com/hooks/naturali
        events:
          $ref: '#/components/schemas/EventSubscription'
        description:
          type: string
          nullable: true
          description: Operator-facing label.
          example: billing service
        active:
          type: boolean
          description: Whether deliveries are attempted.
          example: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-31T00:00:00.000Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-07-31T00:00:00.000Z'
    WebhookWithSecret:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: >
                The signing key (`whsec_` prefix), returned only by create and
                rotate. Never readable afterwards.
              example: whsec_V1StGXR8Z5jdHi6BQe4kL2mN
    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: >
            An `https://` endpoint (`http://` is accepted for localhost, so a
            tunnel works in development).
          example: https://example.com/hooks/naturali
        events:
          $ref: '#/components/schemas/EventSubscription'
        description:
          type: string
          nullable: true
          example: billing service
        active:
          type: boolean
          default: true
          example: true
    WebhookUpdate:
      type: object
      description: At least one field is required.
      properties:
        url:
          type: string
          format: uri
          example: https://example.com/hooks/naturali-v2
        events:
          $ref: '#/components/schemas/EventSubscription'
        description:
          type: string
          nullable: true
          example: billing service (staging)
        active:
          type: boolean
          example: false
    WebhookList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Webhook'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null when there are no more.
          example: null
    WebhookDelivery:
      type: object
      description: One event addressed to one endpoint, and everything that happened to it.
      required:
        - id
        - project_id
        - webhook_id
        - event_id
        - event_type
        - payload
        - status
        - status_code
        - attempts
        - next_attempt_at
        - last_attempt_at
        - response_body
        - created_at
        - updated_at
      properties:
        id:
          type: string
          description: >
            Public delivery ID (whd_ prefix). Sent with the request as
            `X-Naturali-Delivery`.
          example: whd_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          x-naturali-ref: project
          example: proj_V1StGXR8Z5jdHi6B
        webhook_id:
          type: string
          x-naturali-ref: webhook
          example: whk_V1StGXR8Z5jdHi6B
        event_id:
          type: string
          description: The event's id; shared by every delivery and redelivery of it.
          example: evt_V1StGXR8Z5jdHi6B
        event_type:
          $ref: '#/components/schemas/EventType'
        payload:
          $ref: '#/components/schemas/Event'
        status:
          type: string
          enum: [pending, success, failed]
          description: >
            `pending` until a 2xx is received (`success`) or the attempts are
            exhausted (`failed`). A failed delivery can be replayed with
            `…:redeliver`.
          example: success
        status_code:
          type: integer
          nullable: true
          description: >
            The receiver's HTTP status on the last attempt; null when the
            attempt never got a response (DNS, TLS, refused, timed out).
          example: 200
        attempts:
          type: integer
          description: Attempts made so far.
          example: 1
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: When the next retry is due; null once the delivery is terminal.
          example: null
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
          example: '2026-07-31T00:00:01.000Z'
        response_body:
          type: string
          nullable: true
          description: >
            A truncated snippet of the receiver's response, or the transport
            error when there was no response.
          example: ok
        created_at:
          type: string
          format: date-time
          example: '2026-07-31T00:00:00.000Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-07-31T00:00:01.000Z'
    WebhookDeliveryList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/WebhookDelivery'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null when there are no more.
          example: null
