openapi: 3.1.0
info:
  title: talkv API
  version: '1.0'
  summary: 'Send WhatsApp messages from your own software, without Meta''s Cloud API.'
  description: |
    Read `guide.md` beside this file first: what has to be done on talkv before
    the API works, consent, webhooks and idempotency.

    Every success is `{"data": ...}`; every list adds `meta`; every error is
    `{"error": {"code", "message", ...}}`. Times are ISO 8601 in the
    workspace's timezone. Phone numbers are accepted in any common format and
    always returned as E.164 (`+919825012345`).
  contact:
    name: talkv
    url: https://talkv.io/contact
servers:
  - url: https://app.talkv.io/api/v1
security:
  - apiKey: []

tags:
  - name: Workspace
  - name: Messages
  - name: Broadcasts
  - name: Contacts
  - name: Templates
  - name: Groups

paths:
  /me:
    get:
      tags: [Workspace]
      summary: 'The workspace this key belongs to'
      description: 'Its WhatsApp numbers and, if it is charged, its balance. A quick way to check a key works.'
      operationId: getMe
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Workspace' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }

  /messages:
    post:
      tags: [Messages]
      summary: 'Send one message'
      description: |
        A template (anyone, any time) or a free-form text (only inside the
        24-hour window after the person last wrote). Handed to WhatsApp while
        the request is open, so the answer says whether it went.

        A marketing template records the person's consent with the source
        `api`. Somebody who opted out is never sent anything — the answer is
        `opted_out`, with the date.
      operationId: sendMessage
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: '#/components/schemas/SendTemplate'
                - $ref: '#/components/schemas/SendText'
            examples:
              template:
                value: { to: '+91 98250 12345', template: order_shipped, variables: [Ravi, '#4521'] }
              headerAndBody:
                value: { to: '9825012345', template: festival_sale, language: hi, variables: { header: [Diwali], body: [Ravi] } }
              withPdf:
                value: { to: '9825012345', template: invoice_ready, variables: [Ravi, 'INV-1042'], media: { link: 'https://shop.example/invoices/INV-1042.pdf', filename: INV-1042.pdf } }
              text:
                value: { to: '+919825012345', text: We will call you at 4. }
      responses:
        '201':
          description: 'Sent'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Message' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }
        '502': { $ref: '#/components/responses/Error' }

  /messages/{id}:
    get:
      tags: [Messages]
      summary: 'One message sent through the API, as it stands now'
      operationId: getMessage
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Message' }
        '404': { $ref: '#/components/responses/Error' }

  /broadcasts:
    get:
      tags: [Broadcasts]
      summary: 'Broadcasts, newest first'
      operationId: listBroadcasts
      parameters:
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Broadcast' } }
                  meta: { $ref: '#/components/schemas/Page' }
    post:
      tags: [Broadcasts]
      summary: 'Send one template to many people'
      description: |
        To a list of numbers (`to`) or to a group kept in talkv (`group`). The
        answer comes back before anything is sent and already lists who will
        be left out from `to` and why; the rest is decided as the broadcast is
        built, and appears in `/broadcasts/{id}/recipients`.

        Numbers in `to` are recorded as having consented (source `api`) unless
        they opted out. People in a `group` keep the consent they have in
        talkv, and anybody without a recorded opt-in is skipped.
      operationId: createBroadcast
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateBroadcast' }
            example:
              name: Diwali offer
              template: diwali_offer
              variables: [{ field: first_name, fallback: there }, '20%']
              to: ['+919825012345', '98250 67890']
      responses:
        '202':
          description: 'Accepted; sending starts now, or at `send_at`'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Broadcast'
                      - type: object
                        properties:
                          requested: { type: integer, description: 'How many numbers `to` held' }
                          skipped:
                            type: array
                            items: { $ref: '#/components/schemas/Skip' }
        '400': { $ref: '#/components/responses/Error' }
        '402': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }

  /broadcasts/{id}:
    get:
      tags: [Broadcasts]
      summary: 'One broadcast and its figures'
      operationId: getBroadcast
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Broadcast' }
        '404': { $ref: '#/components/responses/Error' }

  /broadcasts/{id}/recipients:
    get:
      tags: [Broadcasts]
      summary: 'Who a broadcast went to, and what happened'
      description: 'Kept for 18 months after the broadcast finishes; the figures on the broadcast are kept for good.'
      operationId: listBroadcastRecipients
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - name: status
          in: query
          schema: { type: string, enum: [pending, sent, delivered, read, failed, skipped] }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Recipient' } }
                  meta: { $ref: '#/components/schemas/Page' }
        '404': { $ref: '#/components/responses/Error' }

  /broadcasts/{id}/cancel:
    post:
      tags: [Broadcasts]
      summary: 'Stop a broadcast'
      description: 'Nothing more is sent. Messages already sent are not recalled, and only the delivered ones are charged.'
      operationId: cancelBroadcast
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: 'Cancelled'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Broadcast' }
        '404': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }

  /contacts:
    get:
      tags: [Contacts]
      summary: 'Contacts, most recently changed first'
      operationId: listContacts
      parameters:
        - name: updated_since
          in: query
          description: 'Only contacts changed at or after this time'
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Contact' } }
                  meta: { $ref: '#/components/schemas/Page' }

  /contacts/{phone}:
    parameters:
      - name: phone
        in: path
        required: true
        description: 'Any common format; URL-encode a leading `+` as `%2B`, or leave it out.'
        schema: { type: string }
    get:
      tags: [Contacts]
      summary: 'One contact'
      operationId: getContact
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Contact' }
        '404': { $ref: '#/components/responses/Error' }
    put:
      tags: [Contacts]
      summary: 'Create or update a contact'
      description: |
        Only the fields you send are changed. `consent: opted_in` is refused
        with `opted_out` for somebody who opted out — only they can undo that.
      operationId: putContact
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContactInput' }
            example:
              first_name: Ravi
              last_name: Patel
              city: Ahmedabad
              fields: { customer_id: C-1042 }
              add_tags: [vip]
              add_to_groups: [Newsletter]
              consent: opted_in
      responses:
        '200':
          description: 'Updated'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Contact' }
        '201':
          description: 'Created'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Contact' }
        '400': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }

  /contacts/{phone}/opt-out:
    parameters:
      - { name: phone, in: path, required: true, schema: { type: string } }
    post:
      tags: [Contacts]
      summary: 'Record that this person must not be messaged'
      description: 'For an unsubscribe that reached your system rather than WhatsApp. Permanent unless the person writes START.'
      operationId: optOutContact
      responses:
        '200':
          description: 'Opted out'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Contact' }

  /opt-outs:
    get:
      tags: [Contacts]
      summary: 'Everybody who opted out, for syncing'
      operationId: listOptOuts
      parameters:
        - name: since
          in: query
          description: 'Only opt-outs at or after this time'
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        phone: { type: string, example: '+919825099999' }
                        opted_out_at: { type: string, format: date-time }
                        source: { type: string, example: inbound_message, description: 'inbound_message (they replied STOP), manual, bulk, import or api' }
                  meta: { $ref: '#/components/schemas/Page' }

  /templates:
    get:
      tags: [Templates]
      summary: 'Templates and whether they can be sent'
      operationId: listTemplates
      parameters:
        - name: status
          in: query
          schema: { type: string, enum: [APPROVED, PENDING, REJECTED, PAUSED, DISABLED] }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Template' } }
                  meta: { $ref: '#/components/schemas/Page' }

  /groups:
    get:
      tags: [Groups]
      summary: 'Groups kept in talkv'
      operationId: listGroups
      parameters:
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        type: { type: string, enum: [static, dynamic], description: 'A dynamic group is a saved search; contacts cannot be added to it.' }
                        count: { type: integer }
                  meta: { $ref: '#/components/schemas/Page' }

webhooks:
  event:
    post:
      summary: 'Something happened in the workspace'
      description: |
        Signed with `Talkv-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "t.raw body">`
        using the workspace's webhook secret. Answer 2xx within 10 seconds;
        anything else is retried for about a day. Events can repeat — use `id`.
      parameters:
        - name: Talkv-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Event' }
      responses:
        '200':
          description: 'Received'

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: 'A key from Settings → API, sent as `Authorization: Bearer tvk_live_...`.'

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: 'Up to 100 characters. The same key and body within 24 hours returns the first answer instead of acting twice.'
      schema: { type: string, maxLength: 100 }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }

  responses:
    Error:
      description: 'Refused; `error.code` says why'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - invalid_request
                - unauthenticated
                - insufficient_balance
                - api_not_enabled
                - workspace_suspended
                - not_found
                - idempotency_conflict
                - opted_out
                - invalid_number
                - window_closed
                - template_not_found
                - template_not_sendable
                - missing_variable
                - missing_media
                - unsupported_template
                - number_not_ready
                - not_cancellable
                - rate_limited
                - whatsapp_error
            message: { type: string, description: 'A sentence you can show a person.' }
            fields:
              type: object
              additionalProperties: { type: array, items: { type: string } }
              description: 'invalid_request only — what is wrong with each field.'
            since: { type: string, format: date-time, description: 'opted_out only.' }
            needed: { type: string, description: 'insufficient_balance only — what it would cost, e.g. "0.10".' }
            available: { type: string, description: 'insufficient_balance only.' }
            whatsapp_code: { type: string, description: 'whatsapp_error only — Meta''s own code, for support.' }

    Page:
      type: object
      properties:
        page: { type: integer }
        per_page: { type: integer, example: 50 }
        total: { type: integer }
        has_more: { type: boolean }

    Workspace:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        country: { type: string, example: IN, description: 'Numbers without a country code are read in this country.' }
        timezone: { type: string, example: Asia/Kolkata }
        numbers:
          type: array
          items:
            type: object
            properties:
              phone: { type: string, example: '+917940001234' }
              name: { type: string, description: 'The name customers see.' }
              ready: { type: boolean, description: 'Whether it can send now.' }
        billing:
          description: 'Null when talkv does not charge this workspace.'
          oneOf:
            - type: 'null'
            - type: object
              properties:
                currency: { type: string, example: INR }
                available: { type: string, example: '842.50', description: 'Balance not already blocked by running broadcasts.' }
                rate: { type: string, example: '0.100', description: 'Charged per delivered template message.' }

    Variables:
      description: |
        The template's {{1}}, {{2}}… in order. A list fills the body; an
        object fills header and body separately. In a broadcast, a value may
        also be a contact field with a fallback.
      oneOf:
        - type: array
          items: { $ref: '#/components/schemas/Value' }
        - type: object
          properties:
            header: { type: array, items: { $ref: '#/components/schemas/Value' } }
            body: { type: array, items: { $ref: '#/components/schemas/Value' } }

    Value:
      oneOf:
        - type: string
        - type: number
        - type: object
          description: 'Broadcasts only.'
          required: [field]
          properties:
            field: { type: string, example: first_name, description: 'name, first_name, last_name, email, city, state, company, or one of your own field keys' }
            fallback: { type: string, example: there, description: 'Used when the contact has no value. Without one, that person is skipped.' }

    SendTemplate:
      type: object
      required: [to, template]
      properties:
        to: { type: string, example: '+91 98250 12345' }
        template: { type: string, example: order_shipped }
        language: { type: string, example: en, description: 'Needed only when the template exists in more than one language.' }
        variables: { $ref: '#/components/schemas/Variables' }
        media: { $ref: '#/components/schemas/Media' }
        from: { type: string, description: 'Which of the workspace''s numbers. Needed only when it has more than one.' }

    Media:
      type: object
      description: >-
        The photo, video or PDF at the top of a template that has one — an
        invoice, a ticket, an offer's banner. WhatsApp fetches the link when it
        sends, so it has to open without signing in; a signed link that lasts
        an hour is enough. talkv does not keep the file or the link. Left out,
        the sample the template was written with in talkv is sent, if it has
        one (`media.default` on the template).
      required: [link]
      properties:
        link: { type: string, format: uri, maxLength: 2048, example: 'https://shop.example/invoices/INV-1042.pdf', description: 'https only. A JPG or PNG up to 5 MB, an MP4 up to 16 MB, or a PDF up to 100 MB — whichever the template was approved with.' }
        filename: { type: string, maxLength: 240, example: INV-1042.pdf, description: 'PDFs only: the name on the phone. Left out, the last part of the link is used.' }

    SendText:
      type: object
      required: [to, text]
      properties:
        to: { type: string }
        text: { type: string, maxLength: 4096 }
        from: { type: string }

    Message:
      type: object
      properties:
        id: { type: integer }
        to: { type: string, example: '+919825012345' }
        from: { type: string, example: '+917940001234' }
        type: { type: string, enum: [template, text] }
        template:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                name: { type: string }
                language: { type: string }
        text: { type: string, description: 'What the person sees, with the variables filled in.' }
        status: { type: string, enum: [sent, delivered, read, failed] }
        error:
          oneOf:
            - type: 'null'
            - type: object
              properties:
                message: { type: string }
                whatsapp_code: { type: string }
        charged: { type: boolean, description: 'Whether this message is charged once delivered. Templates are; replies are not.' }
        created_at: { type: string, format: date-time }
        sent_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        read_at: { type: [string, 'null'], format: date-time }

    CreateBroadcast:
      type: object
      required: [name, template]
      properties:
        name: { type: string, maxLength: 191 }
        template: { type: string }
        language: { type: string }
        variables: { $ref: '#/components/schemas/Variables' }
        media: { $ref: '#/components/schemas/Media', description: 'The same file for everybody.' }
        to:
          type: array
          maxItems: 1000
          items: { type: string }
          description: 'Numbers to send to. Give this or `group`.'
        group: { type: integer, description: 'A group''s id from `/groups`. Give this or `to`.' }
        from: { type: string }
        send_at: { type: string, format: date-time, description: 'Schedule it. Nothing is blocked from the balance until it runs.' }

    Broadcast:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        status: { type: string, enum: [scheduled, queueing, running, paused, completed, cancelled, failed] }
        status_reason: { type: [string, 'null'], description: 'Why it paused or failed, in words.' }
        template: { type: object, properties: { name: { type: string }, language: { type: string } } }
        from: { type: string }
        counts:
          type: object
          properties:
            total: { type: integer }
            sent: { type: integer }
            delivered: { type: integer }
            read: { type: integer }
            failed: { type: integer }
            skipped: { type: integer }
        charged: { type: [string, 'null'], example: '84.20', description: 'What it cost, once settled. Null while running or when the workspace is not charged.' }
        send_at: { type: [string, 'null'], format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    Skip:
      type: object
      properties:
        to: { type: string }
        reason: { type: string, enum: [opted_out, invalid_number, duplicate, no_opt_in, missing_variable, daily_cap] }
        since: { type: string, format: date-time, description: 'opted_out only.' }

    Recipient:
      type: object
      properties:
        to: { type: string }
        status: { type: string, enum: [pending, queued, sending, sent, delivered, read, failed, skipped] }
        reason: { type: [string, 'null'], description: 'For skipped and failed, in words.' }
        sent_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        read_at: { type: [string, 'null'], format: date-time }

    Contact:
      type: object
      properties:
        phone: { type: string, example: '+919825012345' }
        name: { type: [string, 'null'] }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        postal_code: { type: [string, 'null'] }
        company: { type: [string, 'null'] }
        birthday: { type: [string, 'null'], example: '1990-09-20', description: 'ISO 8601; --09-20 when the year is not known.' }
        anniversary: { type: [string, 'null'], example: '--02-12' }
        fields: { type: object, additionalProperties: true, description: 'Your own fields, by key.' }
        people:
          type: array
          description: 'Everybody else who uses this number, each with their own birthday.'
          items: { $ref: '#/components/schemas/PersonOnNumber' }
        tags: { type: array, items: { type: string } }
        groups: { type: array, items: { type: object, properties: { id: { type: integer }, name: { type: string } } } }
        consent:
          type: object
          properties:
            status: { type: string, enum: [opted_in, opted_out, unknown] }
            source: { type: [string, 'null'], example: api }
            at: { type: [string, 'null'], format: date-time }
        last_message_from_them_at: { type: [string, 'null'], format: date-time, description: 'Opens the 24-hour window.' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PersonOnNumber:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 191, example: Priya Shah }
        first_name: { type: [string, 'null'], description: 'What a message greets them by. Left out, the first word of the name that is not a title (Mrs., Dr., Baby…).' }
        birthday: { type: [string, 'null'], example: '1975-09-20', description: '--09-20 when the year is not known.' }
        ref: { type: [string, 'null'], maxLength: 64, example: P-102, description: 'Your own id for the person, so a second sync updates them rather than adding them again.' }

    ContactInput:
      type: object
      properties:
        name: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        city: { type: string }
        state: { type: string }
        postal_code: { type: string }
        company: { type: string }
        birthday: { type: string, example: '1990-09-20', description: '1990-09-20, or --09-20 without a year. 20/09/1990 is read day first.' }
        anniversary: { type: string, example: '--02-12' }
        fields: { type: object, additionalProperties: true, description: 'Your own fields by key, as set up in talkv. An unknown key is refused.' }
        people:
          type: array
          maxItems: 20
          description: >-
            Everybody else who uses this number — a family on one phone. The
            whole list: anybody left out is removed. A broadcast chosen by
            birthday greets whoever's day it is; any other reaches the number
            once.
          items: { $ref: '#/components/schemas/PersonOnNumber' }
        add_tags: { type: array, items: { type: string }, description: 'Created if they do not exist.' }
        remove_tags: { type: array, items: { type: string } }
        add_to_groups: { type: array, items: { oneOf: [{ type: integer }, { type: string }] }, description: 'Group ids or names. A new name creates the group.' }
        remove_from_groups: { type: array, items: { oneOf: [{ type: integer }, { type: string }] } }
        consent: { type: string, enum: [opted_in, opted_out] }

    Template:
      type: object
      properties:
        name: { type: string }
        language: { type: string }
        category: { type: string, enum: [MARKETING, UTILITY, AUTHENTICATION] }
        status: { type: string, enum: [APPROVED, PENDING, REJECTED, PAUSED, DISABLED] }
        sendable: { type: boolean }
        variables:
          type: object
          properties:
            header: { type: integer, description: 'How many values the header needs.' }
            body: { type: integer }
        text: { type: string, description: 'The body, with {{1}} placeholders.' }
        media:
          oneOf:
            - type: 'null'
            - type: object
              description: 'The template has a file at the top, so a send carries `media`.'
              properties:
                type: { type: string, enum: [image, video, document] }
                default: { type: boolean, description: 'Whether talkv has a sample it sends when `media` is left out.' }

    Event:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, example: evt_01JB8Z3T6Q0K7V9X2M4N5P6R7S }
        type:
          type: string
          enum:
            - message.sent
            - message.delivered
            - message.read
            - message.failed
            - message.received
            - contact.opted_out
            - contact.opted_in
            - broadcast.finished
            - template.approved
            - template.rejected
            - webhook.test
        created_at: { type: string, format: date-time }
        data:
          type: object
          description: |
            message.* → `{message: Message}`; message.received →
            `{message: {id, from, type, text, received_at}, contact: Contact}`;
            contact.* → `{contact: Contact}`; broadcast.finished →
            `{broadcast: Broadcast}`; template.* → `{template: Template, reason}`.
