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

# Create a webhook endpoint

> Register a destination. An endpoint created here is identical to one created in **Connectors → Webhooks**, including flipping the connector card to connected.

**Credential:** user token · **Role:** owner or admin (`manage_team`)

<Warning>**`events` is required.** Omitting it is a `400` naming the field — not a subscription to everything. Pass `["*"]` if you genuinely want every event, including ones added later.

`handoff.*` payloads carry real customer message text, so subscribing a new URL to them because a field was left blank is not a convenience.</Warning>

`channels` **is** optional and defaults to every channel, precisely because a channel filter can only ever narrow an event selection. The four `billing.*` events have no channel, so a channel filter never hides them.

<Warning>**The signing secret is returned by this response only** (and by [rotate-secret](/api/v1/webhooks/rotate-a-signing-secret)). It is never on a list, a GET or a PATCH, and there is no way to recover it later — store it now.</Warning>



## OpenAPI

````yaml /openapi/v1.json post /v1/webhooks
openapi: 3.1.0
info:
  title: Peeve API
  version: '2026-08-17'
  description: >-
    The `/v1` API. Server-to-server only — no CORS headers are sent, no `Origin`
    is checked, and publishable keys are rejected everywhere.


    Most endpoints take a **user token** (`pv_ut_…`), which carries its owner's
    live workspace role. `/v1/users` and `/v1/answer` take the **workspace
    secret key** (`sk_…`). The credential and the required role are stated on
    every operation — they are not uniform.
servers:
  - url: https://api.peeve.ai
security:
  - userToken: []
tags:
  - name: Sessions
    description: Agent conversations and their transcripts.
  - name: Contacts
    description: >-
      Everyone Peeve knows about: **users** your product identified, and
      anonymous **visitors**. Filter with `?kind=`.
  - name: Users
    description: Push your identified users into Peeve from your backend.
  - name: Ask
    description: Ask the agent a question from your backend and get a grounded answer.
  - name: Leads
    description: >-
      Visitors the agent qualified. Read and re-status only — capture happens
      through Peeve's own channels.
  - name: Meetings
    description: Meetings booked through the agent.
  - name: Hand-offs
    description: Escalations to a human, and their status.
  - name: Analytics
    description: Usage and satisfaction results.
  - name: Configuration
    description: Guardrails and the kill switch.
  - name: Webhooks
    description: >-
      Register and manage the endpoints Peeve delivers events to. Equivalent to
      the Connectors UI — an endpoint created here is the same row, with the
      same guarantees.
paths:
  /v1/webhooks:
    post:
      tags:
        - Webhooks
      summary: Create a webhook endpoint
      description: >-
        Register a destination. An endpoint created here is identical to one
        created in **Connectors → Webhooks**, including flipping the connector
        card to connected.


        **Credential:** user token · **Role:** owner or admin (`manage_team`)


        <Warning>**`events` is required.** Omitting it is a `400` naming the
        field — not a subscription to everything. Pass `["*"]` if you genuinely
        want every event, including ones added later.


        `handoff.*` payloads carry real customer message text, so subscribing a
        new URL to them because a field was left blank is not a
        convenience.</Warning>


        `channels` **is** optional and defaults to every channel, precisely
        because a channel filter can only ever narrow an event selection. The
        four `billing.*` events have no channel, so a channel filter never hides
        them.


        <Warning>**The signing secret is returned by this response only** (and
        by [rotate-secret](/api/v1/webhooks/rotate-a-signing-secret)). It is
        never on a list, a GET or a PATCH, and there is no way to recover it
        later — store it now.</Warning>
      operationId: createWebhookEndpoint
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
                - events
              additionalProperties: false
              properties:
                url:
                  type: string
                  maxLength: 2048
                  description: >-
                    HTTPS only. Private, loopback, link-local and `.internal`
                    destinations are refused.
                label:
                  type:
                    - string
                    - 'null'
                  maxLength: 80
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum:
                      - session.started
                      - session.resolved
                      - handoff.created
                      - handoff.message.created
                      - handoff.status.changed
                      - lead.created
                      - lead.status.changed
                      - meeting.booked
                      - meeting.status.changed
                      - billing.payment_failed
                      - billing.subscription.changed
                      - billing.credits_low
                      - billing.credits_expiring
                      - '*'
                  description: Required. `[]` is refused; `["*"]` means everything.
                channels:
                  type:
                    - array
                    - 'null'
                  items:
                    type: string
                    enum:
                      - human
                      - agent
                      - whatsapp
                      - messenger
                      - instagram
                      - email
                  description: Optional. Omit or `null` for every channel.
                enabled:
                  type: boolean
                  default: true
                  description: '`false` creates it paused.'
            example:
              url: https://example.com/hooks/peeve
              label: Prod
              events:
                - handoff.created
                - handoff.message.created
      responses:
        '200':
          description: >-
            Created. **The only response that carries the secret**, besides
            rotate-secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointWithSecret'
        '400':
          description: >-
            A refused destination (`invalid_webhook_url`), a missing `events`,
            an empty `events: []`, an unknown body key, or the endpoint cap.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
              example:
                error:
                  type: invalid_request_error
                  code: invalid_webhook_url
                  message: Webhook destinations must use https://.
                  param: url
                request_id: req_xxx
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  schemas:
    WebhookEndpointWithSecret:
      allOf:
        - $ref: '#/components/schemas/ObjectEnvelope'
        - type: object
          properties:
            data:
              allOf:
                - $ref: '#/components/schemas/WebhookEndpoint'
                - type: object
                  properties:
                    secret:
                      type: string
                      description: >-
                        The signing secret (`whsec_…`). **Returned only here and
                        on rotate-secret. It cannot be recovered later.**
    ErrorEnvelope:
      type: object
      required:
        - error
        - request_id
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
          properties:
            type:
              type: string
              enum:
                - authentication_error
                - invalid_request_error
                - not_found_error
                - rate_limit_error
                - api_error
              description: The coarse family. Branch on this.
            code:
              type: string
              description: The stable machine label.
            message:
              type: string
            param:
              type: string
              description: The offending query parameter or dotted body path.
        request_id:
          type: string
    ObjectEnvelope:
      type: object
      required:
        - object
        - data
        - request_id
      properties:
        object:
          type: string
          description: The resource type, e.g. `lead`.
        data:
          type: object
          additionalProperties: true
        request_id:
          type: string
          description: Echoed in the `X-Request-Id` header. Quote it to support.
    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        object:
          type: string
          const: webhook_endpoint
        url:
          type: string
          format: uri
        label:
          type:
            - string
            - 'null'
          maxLength: 80
        events:
          type: array
          items:
            type: string
            enum:
              - session.started
              - session.resolved
              - handoff.created
              - handoff.message.created
              - handoff.status.changed
              - lead.created
              - lead.status.changed
              - meeting.booked
              - meeting.status.changed
              - billing.payment_failed
              - billing.subscription.changed
              - billing.credits_low
              - billing.credits_expiring
              - '*'
        channels:
          type:
            - array
            - 'null'
          items:
            type: string
            enum:
              - human
              - agent
              - whatsapp
              - messenger
              - instagram
              - email
          description: '`null` means every channel.'
        enabled:
          type: boolean
          description: >-
            `false` is paused — reversible, keeps the secret and delivery
            history.
        secret_hint:
          type:
            - string
            - 'null'
          description: >-
            Last 4 characters of the signing secret, so a rotation can be
            confirmed without exposing it.
        secret_set_at:
          type:
            - string
            - 'null'
          format: date-time
        last_delivery_at:
          type:
            - string
            - 'null'
          format: date-time
        last_status:
          type:
            - string
            - 'null'
        last_response_code:
          type:
            - integer
            - 'null'
  responses:
    Unauthorized:
      description: >-
        Missing, invalid or revoked credential — or the wrong **kind** of
        credential.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          examples:
            wrongKind:
              summary: A secret key on a user-token endpoint
              value:
                error:
                  type: authentication_error
                  code: user_token_required
                  message: >-
                    This endpoint requires a user token (pv_ut_…), not a
                    workspace secret key. A shared key cannot express a person's
                    role, so it cannot be used to read workspace data or change
                    configuration. Create your own token in Settings → API keys.
                request_id: req_xxx
            publishable:
              summary: A publishable key
              value:
                error:
                  type: authentication_error
                  code: publishable_key_not_allowed
                  message: >-
                    Publishable keys (pk_…) are public by construction and can
                    never read or write workspace data.
                request_id: req_xxx
            invalid:
              summary: Revoked, unknown, or the owner left the workspace
              value:
                error:
                  type: authentication_error
                  code: invalid_credential
                  message: >-
                    That credential is not valid, has been revoked, or its owner
                    is no longer a member of this workspace.
                request_id: req_xxx
    Forbidden:
      description: >-
        The credential is valid but its **role** is insufficient. The message
        names the role it resolved to and the capability it lacked.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              type: invalid_request_error
              code: insufficient_role
              message: >-
                This credential acts as 'viewer', which cannot change workspace
                settings (owner or admin). A user token carries its owner's
                workspace role, so this needs a teammate whose role holds
                'manage_team'.
            request_id: req_xxx
    RateLimited:
      description: >-
        Over the per-credential ceiling. Carries `Retry-After`. All `/v1`
        endpoints share one bucket per credential.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              type: rate_limit_error
              code: rate_limit_exceeded
              message: Too many requests (key limit). Retry in 60s.
            request_id: req_xxx
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait.
  securitySchemes:
    userToken:
      type: http
      scheme: bearer
      description: >-
        A user token (`pv_ut_…`) as `Authorization: Bearer pv_ut_…`.
        `X-Peeve-Key` is accepted too. **Never a query parameter** — URLs end up
        in proxy logs and `Referer` headers.


        The token carries its owner's **live** workspace role, re-read on every
        call.

````