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

# Reply to a hand-off

> Answer the customer. **This is the only endpoint that sends something to one of your customers**, so it behaves differently from the rest of the API.

**Credential:** user token · **Role:** owner, admin or responder (`reply`)

### The reply leaves the building

It is not "add a message to a thread". It goes out on **the channel the conversation started on** — back to WhatsApp, Messenger or Instagram, or by email from the connected mailbox — and is mirrored into the linked Slack thread and appended to the Zendesk or HubSpot ticket. If the customer is in the widget, it signals that a human has arrived.

<Warning>**Authorship comes from the token, never the body.** The message is attributed to the token's owner, and the name your customer sees is that teammate's profile name. There is no way to reply as someone else, and the audit trail names who answered.</Warning>

<Note>**Text only, and attachments are refused rather than dropped.** The body takes 1–10,000 characters. Sending `attachments` is a `400`, not a silent omission — attaching a file needs an authenticated upload into your workspace's own storage, so it stays a dashboard flow.</Note>

### Side effects worth knowing

Replying also stamps the first-reply time, and **auto-assigns the hand-off to you** if nobody owns it yet.

It emits `handoff.message.created` — and, with `resolve: true`, `handoff.status.changed`. If you wire both an API reply path and a [webhook consumer](/webhooks/outbound), you will receive your own reply back as an event. Dedupe on the event id.



## OpenAPI

````yaml /openapi/v1.json post /v1/handoffs/{id}/reply
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/handoffs/{id}/reply:
    post:
      tags:
        - Hand-offs
      summary: Reply to a hand-off
      description: >-
        Answer the customer. **This is the only endpoint that sends something to
        one of your customers**, so it behaves differently from the rest of the
        API.


        **Credential:** user token · **Role:** owner, admin or responder
        (`reply`)


        ### The reply leaves the building


        It is not "add a message to a thread". It goes out on **the channel the
        conversation started on** — back to WhatsApp, Messenger or Instagram, or
        by email from the connected mailbox — and is mirrored into the linked
        Slack thread and appended to the Zendesk or HubSpot ticket. If the
        customer is in the widget, it signals that a human has arrived.


        <Warning>**Authorship comes from the token, never the body.** The
        message is attributed to the token's owner, and the name your customer
        sees is that teammate's profile name. There is no way to reply as
        someone else, and the audit trail names who answered.</Warning>


        <Note>**Text only, and attachments are refused rather than dropped.**
        The body takes 1–10,000 characters. Sending `attachments` is a `400`,
        not a silent omission — attaching a file needs an authenticated upload
        into your workspace's own storage, so it stays a dashboard flow.</Note>


        ### Side effects worth knowing


        Replying also stamps the first-reply time, and **auto-assigns the
        hand-off to you** if nobody owns it yet.


        It emits `handoff.message.created` — and, with `resolve: true`,
        `handoff.status.changed`. If you wire both an API reply path and a
        [webhook consumer](/webhooks/outbound), you will receive your own reply
        back as an event. Dedupe on the event id.
      operationId: replyToHandoff
      parameters:
        - $ref: '#/components/parameters/id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - body
              additionalProperties: false
              properties:
                body:
                  type: string
                  minLength: 1
                  maxLength: 10000
                  description: The reply text. Trimmed; must not be empty.
                resolve:
                  type: boolean
                  default: false
                  description: >-
                    Answer and close in one call. Default `false`, which leaves
                    the hand-off waiting on the customer.
            examples:
              reply:
                summary: Answer
                value:
                  body: >-
                    Both charges on 12 Aug were for the same invoice — I've
                    refunded the duplicate.
              replyResolve:
                summary: Answer and close
                value:
                  body: Refunded the duplicate charge. Anything else?
                  resolve: true
      responses:
        '200':
          description: The reply was sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ObjectEnvelope'
        '400':
          description: An empty or over-long body, or an unknown key such as `attachments`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  parameters:
    id:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
  schemas:
    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.
    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
  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
    NotFound:
      description: >-
        No such record **in this workspace**. Identical whether the id belongs
        to another tenant or does not exist — a distinguishable response would
        be an existence oracle.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              type: not_found_error
              code: resource_not_found
              message: >-
                No lead with id '00000000-0000-4000-8000-000000000000' in this
                workspace.
              param: id
            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.

````