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

# Create or replace the project webhook

> Create or replace the webhook. `url` must be HTTPS. The signature scheme defaults to `v2`. A webhook cannot move straight from `v1` to `v2`, or from `v2` to `v1` (`400`): a receiver verifying the current scheme would break on the next delivery, so both directions go through `v1+v2`. Replacing keeps the secret.

Pass `disableSenderWebhooks: true` to switch off the /v1 sender webhooks of this project in the same call, so each event arrives once.



## OpenAPI

````yaml /api-reference/v2/openapi.json put /v2/webhook
openapi: 3.0.3
info:
  title: Zavu API v2
  version: 2.0.0
  description: >-
    The second version of the Zavu API. It runs on the same engine as /v1: the
    same API keys, the same per-project rate limit, the same sending limits,
    prices and reviews. /v1 keeps working unchanged, and you can mix calls to
    both.


    **Addresses, not senders.** /v2 has no sender object. Where a message leaves
    from is named the way the recipient sees it: an E.164 number, an email
    address, `@botname` for Telegram, `@username` for Instagram, the Facebook
    Page id for Messenger, and the literal `sms_oneway` for one-way SMS (sent
    from shared numbers, never a number of your own). `GET /v2/channels` lists
    one entry per (address, type); a number that carries WhatsApp and SMS is two
    entries with the same address.


    **What else changes.** No `Zavu-Sender` header (`from` or `conversationId`
    instead); every send reports how it was routed; one project webhook carries
    every event; every error is `{code, message, details}` at the root.


    **Wrong method.** A path under `/v2/messages`, `/v2/conversations`,
    `/v2/contacts`, `/v2/channels`, `/v2/templates`, `/v2/campaigns`,
    `/v2/agents`, `/v2/webhook` or `/v2/email-domains` called with a method it
    does not take answers `405 method_not_allowed`, with `details.allowed` and
    an `Allow` header.


    **Money.** Every amount is USD, rounded to 6 decimals.
servers:
  - url: https://api.zavu.dev
security:
  - bearerAuth: []
tags:
  - name: Messages
    description: Send, list and read messages.
  - name: Conversations
    description: Inbox threads, one per contact across channels.
  - name: Contacts
    description: Contacts and the identifiers they are reached at.
  - name: Channels
    description: 'Where the project can send from: one entry per (address, type).'
  - name: Templates
    description: WhatsApp message templates.
  - name: Campaigns
    description: One message to many recipients.
  - name: Webhook
    description: The one project webhook for every event.
  - name: Agents
    description: AI agents and the addresses they answer on.
  - name: Numbers
    description: Search and buy phone numbers.
  - name: Email Domains
    description: Domains to send email from.
  - name: Functions
    description: Configure Zavu Functions.
paths:
  /v2/webhook:
    put:
      tags:
        - Webhook
      summary: Create or replace the project webhook
      description: >-
        Create or replace the webhook. `url` must be HTTPS. The signature scheme
        defaults to `v2`. A webhook cannot move straight from `v1` to `v2`, or
        from `v2` to `v1` (`400`): a receiver verifying the current scheme would
        break on the next delivery, so both directions go through `v1+v2`.
        Replacing keeps the secret.


        Pass `disableSenderWebhooks: true` to switch off the /v1 sender webhooks
        of this project in the same call, so each event arrives once.
      operationId: putWebhookV2
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectWebhookV2Put'
      responses:
        '200':
          description: Replaced. `secret` is not returned.
          content:
            application/json:
              schema:
                type: object
                required:
                  - webhook
                  - senderWebhooks
                  - disabledSenderWebhooks
                properties:
                  webhook:
                    $ref: '#/components/schemas/ProjectWebhookV2'
                  senderWebhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/SenderWebhookV1'
                  disabledSenderWebhooks:
                    type: integer
                    description: How many /v1 sender webhooks this call switched off.
        '201':
          description: Created. `webhook.secret` is returned once.
          content:
            application/json:
              schema:
                type: object
                required:
                  - webhook
                  - senderWebhooks
                  - disabledSenderWebhooks
                properties:
                  webhook:
                    $ref: '#/components/schemas/ProjectWebhookV2'
                  senderWebhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/SenderWebhookV1'
                  disabledSenderWebhooks:
                    type: integer
                    description: How many /v1 sender webhooks this call switched off.
        '400':
          description: >-
            Invalid URL, an event that does not exist (`details.unknown`,
            `details.allowed`, and `details.errors` naming each by index), or
            `v1` to `v2` / `v2` to `v1` directly (go through `v1+v2`;
            `details.errors[0].path` is `["signatureVersion"]`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security:
        - bearerAuth: []
components:
  schemas:
    ProjectWebhookV2Put:
      type: object
      required:
        - url
        - events
      properties:
        url:
          type: string
          format: uri
          description: HTTPS only.
        events:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/WebhookEvent'
          description: >-
            Any event in `WebhookEvent`. An unknown value is refused with `400`
            and `details.allowed`.
        active:
          type: boolean
          default: true
        signatureVersion:
          $ref: '#/components/schemas/WebhookSignatureVersion'
        disableSenderWebhooks:
          type: boolean
          default: false
          description: >-
            Switch off the /v1 sender webhooks of this project in the same call,
            so each event arrives once. They are only switched off
            (`webhookActive: false` on /v1); URL, events and secret stay, so /v1
            can turn one back on. Off by default: the /v2 webhook is added next
            to them and both receive every event until you decide.
            `senderWebhooks` on the response lists what is still on.
    ProjectWebhookV2:
      type: object
      required:
        - url
        - events
        - active
        - signatureVersion
      description: >-
        The one webhook for everything in the project. Stored apart from the /v1
        project webhook (`/v1/invitations/webhook`); neither API reads or writes
        the other's.
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEvent'
          description: >-
            Any event in `WebhookEvent`, including `message.*`,
            `conversation.new` and `call.*`.
        active:
          type: boolean
        signatureVersion:
          $ref: '#/components/schemas/WebhookSignatureVersion'
        secret:
          type: string
          description: >-
            Returned only by `PUT /v2/webhook` when it creates the webhook, and
            by `POST /v2/webhook/secret`.
    SenderWebhookV1:
      type: object
      required:
        - address
        - url
        - v1SenderId
      description: >-
        A /v1 sender webhook that is still on. Each one also receives the events
        it is subscribed to, as a separate delivery.
      properties:
        address:
          type: string
          nullable: true
          description: >-
            The sender's first address; null for a sender with no connected
            transport.
        url:
          type: string
          format: uri
        v1SenderId:
          type: string
          description: Read-only; the sender to change on /v1.
    Error:
      type: object
      required:
        - code
        - message
      description: >-
        Every v2 error has this shape at the root. There is no `{error: {...}}`
        wrapper anywhere in v2. A missing resource is `404 not_found` with the
        message `<Resource> not found`; an address the project does not have is
        `404 not_found`, `None of this project's channels has the address
        <address>.`, with the address in `details` under the name the request
        used (`from` or `address`). A `message` is written for the caller and
        never carries a stack trace or a source path. A body that is not JSON is
        `400 invalid_json`, with `details.errors` (`path: []`, the parse error
        in `message`).
      properties:
        code:
          type: string
          example: invalid_request
        message:
          type: string
        details:
          type: object
          additionalProperties: true
          description: >-
            Machine-readable context. Keys that mean the same thing everywhere:

            - `errors`: present on every `400 invalid_request` for an invalid
            body or query parameter, `[{path, message, ...}]`, `path` naming the
            field (`["to"]`, `["contacts", 2, "recipient"]`, `["limit"]`).

            - `recommendation`: one sentence saying what to do next.

            - `dashboardUrl`: the dashboard screen that fixes it.

            - `connect`: on `409 no_channel`, the channels this project can
            connect to reach the recipient, each `{type, request, note,
            available, dashboardUrl?}`; `request` is the exact call (`POST
            /v2/channels` with its body, or `POST /v2/numbers`), or null for a
            channel connected from the dashboard (WhatsApp, Instagram,
            Messenger). `available: false` says why in `note` (one-way SMS
            closed until business verification is approved).

            - `address` / `from`: the address the error is about, under the name
            the request used for it.
          properties:
            errors:
              type: array
              items:
                type: object
                required:
                  - path
                  - message
                properties:
                  path:
                    type: array
                    items:
                      oneOf:
                        - type: string
                        - type: integer
                  message:
                    type: string
                  code:
                    type: string
                additionalProperties: true
    WebhookEvent:
      type: string
      description: >-
        Type of event that triggers the webhook.


        **Message lifecycle events:**

        - `message.queued`: Message created and queued for sending.
        `data.status` = `queued`

        - `message.sent`: Message accepted by the provider. `data.status` =
        `sent`

        - `message.delivered`: Message delivered to recipient. `data.status` =
        `delivered`

        - `message.read`: Message was read by the recipient (WhatsApp only).
        `data.status` = `read`

        - `message.failed`: Message failed to send. `data.status` = `failed`


        **Inbound events:**

        - `message.inbound`: New message received from a contact.
        `data.conversationId`, the inbox thread id (deep-link with
        `https://dashboard.zavu.dev/{locale}/inbox?conv={conversationId}`), is
        sent on **email** messages only, and is `null` there on the first
        message of a brand-new thread. On every other channel it is absent: read
        it from `GET /v2/messages/{messageId}`, which always has it, or from
        `conversation.new` for a new contact. Reactions are delivered as
        `message.inbound` with `messageType='reaction'`. When the contact
        replied to (quoted) an earlier message, `data.content` carries the reply
        context: `replyToMessageId`, `replyToProviderMessageId`, `replyToFrom`,
        `replyToText`, and `replyToMessageType`. `data.providerTimestamp` is the
        provider's original receive time in Unix milliseconds (the moment the
        channel received the message from the contact — WhatsApp, Telegram,
        Instagram, Messenger; `null` for SMS and email). Compare it against the
        top-level `timestamp` (when Zavu dispatched the webhook) to detect and
        ignore delayed deliveries. When the conversation was opened from a
        Click-to-WhatsApp ad or post, `data.referral` carries the ad attribution
        — including `ctwaClid`, the identifier Meta's Conversions API needs to
        credit a conversion back to that ad. WhatsApp only, and only on the
        first message of the thread: it is absent from every later message, so
        persist it when it arrives.

        - `message.unsupported`: Received a message type that is not supported


        **Broadcast events:**

        - `broadcast.status_changed`: Broadcast status changed (pending_review,
        approved, rejected, sending, completed, cancelled)


        **Other events:**

        - `conversation.new`: New conversation started with a contact. `data`
        carries `conversationId` (the inbox thread id — deep-link with
        `https://dashboard.zavu.dev/{locale}/inbox?conv={conversationId}`), the
        `phoneNumber` or `email` key, `channel`, `firstMessageId`,
        `firstMessageText`, and `profileName`.

        - `template.status_changed`: WhatsApp template approval status changed.
        `data` carries `templateId`, `name`, `previousStatus`, `currentStatus`,
        `rejectionReason`, and `category` — the category Meta currently bills
        the template under. Meta can recategorize a template (typically
        `UTILITY` to `MARKETING`) at approval or long afterwards, which changes
        what each message costs; `category` is how that reaches you. A
        recategorization with no status change is delivered as this same event,
        so compare `category` against what you hold rather than only reacting to
        `currentStatus`.


        **Partner events:**

        - `invitation.status_changed`: A partner invitation's stored status
        changed: to `in_progress`, `completed`, `failed`, `cancelled`, or back
        to `pending` when it is resent from the dashboard. A change to the same
        status sends nothing, and expiry is not a stored change, so no event is
        sent when an invitation expires. Delivered to the project that created
        the invitation; a parent project does not receive its sub-accounts'
        events. `data` carries `invitationId`, `clientName`, `clientEmail`,
        `connectionType` (`whatsapp_waba` or `messenger`), `previousStatus`, and
        `currentStatus`. On `completed` it also carries `connectedAccount`
        (`channel`, `id`, `name`), the WhatsApp number or Facebook Page that was
        linked, and, for WhatsApp, `wabaAccountId`. On `failed` it carries
        `failureReason`; the invitation link stays usable, so a client can retry
        it.


        **Voice Agent events:**

        For every voice event, `data` carries `callId`, `direction`, `from`,
        `to`, `status`, `durationSeconds`, `endReason`, and
        `transcriptAvailable`. The terminal events (`call.completed`,
        `call.failed`) additionally carry `cost` — what the call was billed, in
        USD, combining telephony and the managed voice pipeline — and
        `currency`. They are dispatched after the call is charged, so `cost` is
        populated rather than zero; telephony can still be settling on an
        outbound call, in which case `GET /v1/calls/{callId}` holds the
        reconciled figure.

        - `call.initiated`: An outbound call was created and is dialing, or an
        inbound call was received. `data.status` = `ringing`

        - `call.answered`: The call was answered and the voice agent is
        connected. `data.status` = `in_progress`

        - `call.completed`: The call ended after a conversation. `data.status` =
        `completed`; `durationSeconds` and `endReason` describe how it ended,
        and `transcriptAvailable` indicates whether a transcript can be fetched.

        - `call.failed`: The call could not be completed (busy, no answer,
        canceled, or an error). `data.status` is the terminal status and
        `endReason` explains the cause.


        **Custom domain events:**

        - `domain.verified`: A custom email domain passed verification (DKIM,
        and SPF/DMARC/MAIL FROM if enhanced records are enabled)

        - `domain.failed`: A custom email domain failed verification or is
        partially verified
      enum:
        - message.queued
        - message.sent
        - message.delivered
        - message.read
        - message.failed
        - message.inbound
        - message.unsupported
        - broadcast.status_changed
        - conversation.new
        - template.status_changed
        - invitation.status_changed
        - call.initiated
        - call.answered
        - call.completed
        - call.failed
        - domain.verified
        - domain.failed
    WebhookSignatureVersion:
      type: string
      description: >-
        Which `X-Zavu-Signature` scheme this receiver is sent. The header is
        `t=<unix seconds>` followed by the signatures.


        - `v1`: `v1=HMAC_SHA256(secret, body)`.

        - `v2`: `v2=HMAC_SHA256(secret, "{t}.{body}")`. The current scheme, and
        the default for a new /v2 webhook. It signs the timestamp together with
        the body.

        - `v1+v2`: both signatures, sharing one `t`. The migration setting: a
        receiver reading either one works.


        Moving straight between `v1` and `v2`, in either direction, returns
        `400`. Set `v1+v2` first. See
        https://docs.zavu.dev/guides/receiving-messages/signature-migration
      enum:
        - v1
        - v1+v2
        - v2
      example: v2
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.