openapi: 3.1.0
info:
  title: MessagePipe Send API
  version: "1.0"
  description: >
    Public high-throughput send API (spec §C.7). Accepts email / SMS / chat / push
    sends (single and bulk), returns 202 with a queued send; delivery is async.
    Authenticated with a workspace API key as a Bearer token (`mp_live_...`).
servers:
  - url: https://api.dev.messagepipe.dev
    description: Development
  - url: http://localhost:8080
    description: Local
security:
  - apiKey: []
tags:
  - { name: Send, description: Submit messages for delivery (§C.7.1) }
  - { name: Sends, description: Inspect and retry sends }
  - { name: Templates, description: List and fetch templates }
  - { name: Contacts, description: "Contacts and their consent (Campaigns C0). Test API keys are refused." }
  - { name: Suppressions, description: Addresses that must not be mailed }
  - { name: Credits, description: "The credit balance every send spends from: transactional, campaign and test sends alike. Test API keys are refused." }
  - { name: System, description: Health }
paths:
  /healthz:
    get:
      tags: [System]
      x-speakeasy-ignore: true
      summary: Liveness / readiness (Postgres + Redis)
      security: []
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Health" } } } }
        "503": { description: A dependency is unavailable }
  /v1/email/send:
    post:
      tags: [Email]
      operationId: sendEmail
      summary: Send a single email
      x-speakeasy-name-override: send
      description: >
        Requires the `send:email` scope. Provide either `template` or both `subject` and
        `html`. Field names are matched exactly; a known field spelled differently (`From`,
        `Subject`) is a 400 with `details.constraint` `key:case`.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/EmailSend" } } }
      responses:
        "200": { description: "Recorded but not sent: the address is suppressed or has no valid consent. `status` is `suppressed` and `suppression.code` says why. Nothing was queued and no credit was used.", content: { application/json: { schema: { $ref: "#/components/schemas/SendAccepted" } } } }
        "202": { description: Accepted (queued), content: { application/json: { schema: { $ref: "#/components/schemas/SendAccepted" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditsExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/sms/send:
    post:
      tags: [SMS]
      operationId: sendSMS
      summary: Send a single SMS
      x-speakeasy-name-override: send
      description: |
        Requires the `send:sms` scope. Provide either `template` or `body`.

        When the send goes through a MessagePipe managed SMS provider, `to` must be an E.164 number and the
        message is billed from the managed SMS bucket at accept time: `segments x credits(country, message_type)`,
        where the credit weight of the destination country comes from the active rate sheet. A destination
        without a price (or managed SMS not on sale) is refused with 422 `sms_destination_not_supported` and
        nothing is charged; too few credits is 402 `credits_exhausted`, naming the credits needed and available.
        Sends through your own provider keys cost one credit and are not affected.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/TextSend" } } }
      responses:
        "202": { description: Accepted (queued), content: { application/json: { schema: { $ref: "#/components/schemas/SendAccepted" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditsExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/UnprocessableSMS" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/chat/send:
    post:
      tags: [Chat]
      operationId: sendChat
      x-speakeasy-name-override: send
      summary: Send a single chat message
      description: Requires the `send:chat` scope. Provide either `template` or `body`.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/TextSend" } } }
      responses:
        "202": { description: Accepted (queued), content: { application/json: { schema: { $ref: "#/components/schemas/SendAccepted" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditsExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/push/send:
    post:
      tags: [Push]
      operationId: sendPush
      x-speakeasy-name-override: send
      summary: Send a single push notification
      description: Requires the `send:push` scope. `to` must carry a `device_token` or `user_id`. Provide either `template` or both `title` and `body`.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/PushSend" } } }
      responses:
        "202": { description: Accepted (queued), content: { application/json: { schema: { $ref: "#/components/schemas/SendAccepted" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditsExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/email/send-bulk:
    post:
      tags: [Email]
      operationId: sendBulkEmail
      x-speakeasy-name-override: sendBulk
      summary: Send up to 1,000 emails in one request
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/BulkRequest" } } }
      responses:
        "207": { description: Per-message result, content: { application/json: { schema: { $ref: "#/components/schemas/BulkResult" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/sms/send-bulk:
    post:
      tags: [SMS]
      operationId: sendBulkSMS
      x-speakeasy-name-override: sendBulk
      summary: Send up to 1,000 SMS in one request
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/BulkRequest" } } }
      responses:
        "207": { description: Per-message result, content: { application/json: { schema: { $ref: "#/components/schemas/BulkResult" } } } }
  /v1/chat/send-bulk:
    post:
      tags: [Chat]
      operationId: sendBulkChat
      x-speakeasy-name-override: sendBulk
      summary: Send up to 1,000 chat messages in one request
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/BulkRequest" } } }
      responses:
        "207": { description: Per-message result, content: { application/json: { schema: { $ref: "#/components/schemas/BulkResult" } } } }
  /v1/push/send-bulk:
    post:
      tags: [Push]
      operationId: sendBulkPush
      x-speakeasy-name-override: sendBulk
      summary: Send up to 1,000 push notifications in one request
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/BulkRequest" } } }
      responses:
        "207": { description: Per-message result, content: { application/json: { schema: { $ref: "#/components/schemas/BulkResult" } } } }
  /v1/push/devices:
    post:
      tags: [Push, Device]
      operationId: registerDevice
      x-speakeasy-name-override: register
      summary: Register (or refresh) a device token for a user
      description: >
        Requires the `send:push` scope. Stores an FCM/APNs device token against
        your own `user_id` so pushes can target `to.user_id`. Re-posting the same
        token refreshes it and re-points it at the given user. A push to a
        `user_id` fans out to all of that user's registered devices, billed as
        one send; invalid/expired tokens are pruned automatically.
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/DeviceRegistration" } } }
      responses:
        "200": { description: Registered, content: { application/json: { schema: { $ref: "#/components/schemas/DeviceRegistered" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      tags: [Push, Device]
      operationId: deleteDevice
      x-speakeasy-name-override: remove
      summary: Remove a registered device token
      description: Requires the `send:push` scope. Pass the token as the `token` query parameter or in the JSON body.
      parameters:
        - { name: token, in: query, required: false, schema: { type: string } }
      responses:
        "200": { description: Removed, content: { application/json: { schema: { type: object, properties: { status: { type: string, example: deleted } } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/sends:
    get:
      tags: [Sends]
      operationId: listSends
      x-speakeasy-name-override: list
      summary: List recent sends (filterable, cursor-paginated)
      parameters:
        - { name: channel, in: query, schema: { type: string, enum: [email, sms, chat, push] } }
        - { name: status, in: query, schema: { type: string, enum: [queued, dispatching, sent, delivered, failed, bounced, suppressed] } }
        - { name: provider, in: query, schema: { type: string } }
        - { name: template_id, in: query, schema: { type: string } }
        - { name: recipient, in: query, schema: { type: string } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SendList" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/sends/{id}:
    get:
      tags: [Sends]
      operationId: getOneSend
      x-speakeasy-name-override: get
      summary: Get a send and its event history
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SendDetail" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/sends/{id}/retry:
    post:
      tags: [Sends]
      operationId: retrySend
      x-speakeasy-name-override: retry
      summary: Retry a failed send (creates a new send with the same content)
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "202": { description: Accepted (queued), content: { application/json: { schema: { $ref: "#/components/schemas/SendAccepted" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditsExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableSMS" }
  /v1/templates:
    get:
      tags: [Templates]
      operationId: listTemplates
      x-speakeasy-name-override: list
      summary: List templates (optionally filtered by channel)
      description: Requires the `templates:read` scope.
      parameters:
        - { name: channel, in: query, schema: { type: string, enum: [email, sms, chat, push] } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/TemplateList" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/templates/{id}:
    get:
      tags: [Templates]
      operationId: getOneTemplate
      x-speakeasy-name-override: get
      summary: Get a template by id
      description: Requires the `templates:read` scope.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Template" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/contacts:
    post:
      tags: [Contacts]
      operationId: upsertContact
      x-speakeasy-name-override: upsert
      summary: Create or update a contact
      description: >
        Requires `contacts:write`. Matches an existing contact by `email`, `phone` or `external_id`
        (email and phone are normalised first) and merges the supplied fields into it; otherwise
        creates one. Returns 201 when created, 200 when updated. An identity the contact lacks is
        added; a different value for one it already has, or identities that belong to two
        contacts, is 409 `identity_conflict` (changing identity is not supported yet).
        Attribute types are inferred from the first value seen for a key (string, number, boolean,
        list of strings) and fixed after that; `null` removes an attribute. At most 50 attributes
        per request. With a `consent` object the contact and its consent are written together.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/ContactInput" } } }
      responses:
        "200": { description: Updated, content: { application/json: { schema: { $ref: "#/components/schemas/Contact" } } } }
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Contact" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Contacts]
      operationId: findContacts
      x-speakeasy-name-override: find
      summary: Find a contact by email, phone or external_id
      description: Requires `contacts:read`. Exactly one query parameter. `data` holds zero or one contact.
      parameters:
        - { name: email, in: query, schema: { type: string } }
        - { name: phone, in: query, schema: { type: string } }
        - { name: external_id, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { data: { type: array, items: { $ref: "#/components/schemas/Contact" } } } } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/contacts/{id}:
    get:
      tags: [Contacts]
      operationId: getContact
      x-speakeasy-name-override: get
      summary: Get a contact with its consent summary
      description: Requires `contacts:read`. `consent` lists records for this project's brands only. An erased contact returns status `erased` with no identity.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Contact" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Contacts]
      operationId: updateContact
      x-speakeasy-name-override: update
      summary: Update a contact's profile
      description: Requires `contacts:write`. Accepts `first_name`, `last_name`, `locale`, `timezone`, `country`, `attributes`. `null` clears a field. Email, phone and external_id cannot be changed.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/ContactPatch" } } }
      responses:
        "200": { description: Updated, content: { application/json: { schema: { $ref: "#/components/schemas/Contact" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/contacts/{id}/consent:
    post:
      tags: [Contacts]
      operationId: writeConsent
      x-speakeasy-name-override: writeConsent
      summary: Record consent (or a withdrawal) with its evidence
      description: >
        Requires `consent:write`. A subscribe write must carry the evidence in the request: the
        registered `statement` the person saw, `basis`, `method`, `captured_at`,
        `channel_context` (with `ip` and `user_agent` for web/app, or a `channel_context_reason`
        for server/pos/phone), plus the basis-specific fields. Everything missing is reported at
        once in 422 `consent_evidence_incomplete` (`details.missing`); unacceptable evidence is
        422 `consent_evidence_invalid` (`details.reason`). Valid writes earn tier B (customer-reported),
        or C when `backfill` is true. A record's tier never goes down. A topic with
        `double_opt_in: required` becomes `pending`. A request either subscribes or
        unsubscribes (`method: api_withdrawal`, no evidence needed), never both.
        409 `recent_recipient_withdrawal` when the recipient unsubscribed themselves (one-click,
        mailbox provider, complaint) within the last 24 hours.
        `warnings` may include `address_suppressed` (the consent is recorded, but sends to the
        address will be refused) and `evidence_below_topic_minimum`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/ConsentWrite" } } }
      responses:
        "200": { description: Recorded, content: { application/json: { schema: { $ref: "#/components/schemas/ConsentWriteResult" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    get:
      tags: [Contacts]
      operationId: getConsent
      x-speakeasy-name-override: getConsent
      summary: Current consent records and recent history
      description: Requires `contacts:read`. The last 50 events, newest first, for this project's brands.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ConsentState" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/contacts/{id}/consent/evidence:
    get:
      tags: [Contacts]
      operationId: getConsentEvidence
      x-speakeasy-name-override: getConsentEvidence
      summary: Export the proof of consent
      description: >
        Requires `contacts:read`. Every event with all evidence fields, the canonical JSON that
        was hashed, its place in the workspace hash chain (`sealed: false` until the asynchronous
        sealer reaches it; `chain.hash_ok` is a recomputation of the hash), and the exact
        statement text shown. Up to 1000 events; `truncated` says when there are more.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, additionalProperties: true } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/contacts/{id}/erase:
    post:
      tags: [Contacts]
      operationId: eraseContact
      x-speakeasy-name-override: erase
      summary: Erase a contact (irreversible)
      description: >
        Requires `contacts:erase`. Body `{"confirm": "<contact id>"}`. In one transaction: the
        address is suppressed (keyed hash only) so the person cannot be mailed again, live consent
        is withdrawn and deleted, the contact is reduced to a tombstone, and the consent events
        are pseudonymised (the hash chain still verifies). Send history follows the send
        retention schedule and is not changed by this call. Repeating it is a no-op.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [confirm], properties: { confirm: { type: string } } } } }
      responses:
        "200": { description: Erased, content: { application/json: { schema: { $ref: "#/components/schemas/EraseResult" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/suppressions:
    get:
      tags: [Suppressions]
      operationId: listSuppressions
      x-speakeasy-name-override: list
      summary: List suppressions, or check one address
      description: >
        Requires `suppressions:read`. Suppressions store only a keyed hash of the address, so the
        list cannot show addresses (`address_key_prefix` is a fragment of the hash). Pass
        `address` to ask whether it is suppressed. Active rows only unless `include_removed=true`.
      parameters:
        - { name: address, in: query, schema: { type: string } }
        - { name: channel, in: query, schema: { type: string, enum: [email, sms, chat, push], default: email } }
        - { name: reason, in: query, schema: { type: string } }
        - { name: include_removed, in: query, schema: { type: boolean } }
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/SuppressionList" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Suppressions]
      operationId: createSuppression
      x-speakeasy-name-override: create
      summary: Suppress an address
      description: >
        Requires `suppressions:write`. `reason` is `manual` or `unsubscribed` (bounces and
        complaints come only from provider feedback). `brand` scopes an `unsubscribed` suppression
        to one brand. 201 when created; 200 with the existing row when already suppressed.
      parameters: [ { $ref: "#/components/parameters/IdempotencyKey" } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/SuppressionInput" } } }
      responses:
        "200": { description: Already suppressed, content: { application/json: { schema: { $ref: "#/components/schemas/Suppression" } } } }
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Suppression" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/suppressions/{id}:
    delete:
      tags: [Suppressions]
      operationId: removeSuppression
      x-speakeasy-name-override: remove
      summary: Remove a suppression
      description: >
        Requires `suppressions:write`. Body `{"reason": "..."}` is required and recorded with the
        key that removed it. Removing a `complaint` or `hard_bounce` suppression needs an API key
        created by a workspace owner or admin (403 otherwise).
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      requestBody:
        required: true
        content: { application/json: { schema: { type: object, required: [reason], properties: { reason: { type: string, minLength: 3, maxLength: 500 } } } } }
      responses:
        "200": { description: Removed (or already removed), content: { application/json: { schema: { $ref: "#/components/schemas/Suppression" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/credits:
    get:
      tags: [Credits]
      operationId: getCredits
      x-speakeasy-name-override: balance
      summary: Credit balance per channel
      description: >
        One balance per credit bucket, shared by transactional sends, campaign messages and test sends.
        The buckets are `email`, `sms`, `push` and `chat` (sends through your own provider keys), followed
        by `email_managed` and `sms_managed`: sends through MessagePipe's managed providers (managed
        email on Amazon SES, managed SMS) are billed from these, not from the plain channel. A bucket
        you hold no credits in reports zero. A send is billed from the bucket of the provider it is
        routed to first; if it fails over to a provider of the other kind it stays billed from the first
        provider's bucket. Requires the `credits:read` scope.
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/CreditBalanceList" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/credits/transactions:
    get:
      tags: [Credits]
      operationId: listCreditTransactions
      x-speakeasy-name-override: listTransactions
      summary: Credit ledger (grants, top-ups, refunds, adjustments)
      description: >
        Newest first. Spend on individual messages is not a ledger row: it is the send itself
        (`GET /v1/sends`) and, for a campaign, its report. Requires the `credits:read` scope.
      parameters:
        - { name: channel, in: query, description: "Credit bucket to filter by. `email_managed` and `sms_managed` are the buckets managed sends are billed from.", schema: { type: string, enum: [email, sms, push, chat, email_managed, sms_managed] } }
        - { name: before, in: query, description: "Return entries older than this timestamp (use `next_before`).", schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/CreditLedgerList" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "Workspace API key, e.g. `Authorization: Bearer mp_live_...`"
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: "Replays within 24h return the original response with `X-Idempotency-Replay: true`."
  schemas:
    CreditBalanceList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            type: object
            required: [channel, monthly_remaining, topup_balance, total]
            properties:
              channel: { type: string, enum: [email, sms, push, chat, email_managed, sms_managed], description: "The credit bucket. The first four are the plain channels; `email_managed` and `sms_managed` are billed for sends through MessagePipe's managed providers." }
              monthly_remaining: { type: integer }
              topup_balance: { type: integer }
              total: { type: integer }
    CreditLedgerList:
      type: object
      required: [data, next_before]
      properties:
        data:
          type: array
          items:
            type: object
            required: [id, channel, type, amount, balance_after, created_at]
            properties:
              id: { type: string }
              channel: { type: string, description: "The credit bucket: email, sms, push, chat, email_managed or sms_managed." }
              type: { type: string }
              amount: { type: integer }
              balance_after: { type: integer }
              reason_code: { type: [string, "null"] }
              reference_id: { type: [string, "null"] }
              created_at: { type: string, format: date-time }
        next_before: { type: [string, "null"], format: date-time }
    Health:
      type: object
      properties: { status: { type: string, example: ok } }
    EmailSend:
      type: object
      required: [to]
      properties:
        to: { type: string, format: email }
        from:
          type: object
          description: >
            Sender identity, honoured on template and inline sends alike (it overrides a
            template's own sender). Optional — when omitted, the template's sender or the
            project's default sender is used; an inline send with neither is rejected with
            400. Must be an object (a bare string is rejected). The address must be on one
            of the project's sender domains (the default sender's domain plus the project's
            `sender_domains`); any other is refused with 403 `sender_not_allowed`, never
            replaced by the default. A project with neither configured accepts any `from`.
            `email` must be one bare address (no list, display name or angle brackets).
          required: [email]
          properties:
            email: { type: string, format: email }
            name: { type: string, description: Display name. Control characters are rejected. }
        subject: { type: string, description: Inline sends only. A template send takes its subject from the template. }
        html: { type: string }
        reply_to:
          type: string
          format: email
          description: >
            Reply-To address, honoured on template and inline sends alike; the template
            needn't reference it. Overrides the template's and the project's default.
            Rejected if it contains control characters.
        headers:
          type: object
          maxProperties: 20
          additionalProperties: { type: string }
          example:
            List-Unsubscribe: "<https://example.com/unsubscribe?t=abc>, <mailto:unsubscribe@example.com>"
            List-Unsubscribe-Post: List-Unsubscribe=One-Click
          description: >
            Custom email headers, delivered unchanged by every email provider, including
            after failover; a provider that can't carry them is skipped, and the send fails
            if none can. Use for `List-Unsubscribe` / `List-Unsubscribe-Post` (RFC 2369 /
            RFC 8058) and `X-` headers. 400 `validation_failed` (with `details.field` =
            `headers.<Name>`) for: a value that is empty or holds CR, LF, another control
            character or non-ASCII (`header:value`); a name that isn't printable ASCII, has
            a space or colon, or is over 64 characters (`header:name`); a platform-owned
            header: From, To, Cc, Bcc, Subject, Date, Message-ID, Reply-To, Sender,
            Return-Path, DKIM-Signature, Received, MIME-Version, Content-Type,
            Content-Transfer-Encoding, Content-Disposition, Authentication-Results,
            Received-SPF, Resent-*, ARC-*, or a provider control header (X-SES-*,
            X-Mailgun-*, X-SG-*, X-SMTPAPI) (`header:reserved`); a repeated name, case-insensitively (`header:duplicate`);
            a line over 998 characters (`header:max_length`).
        metadata:
          $ref: "#/components/schemas/Metadata"
        attachments:
          type: array
          maxItems: 20
          description: >
            Files to attach. The combined decoded size of all attachments may not exceed
            10 MB; the request body itself is capped at 16 MB, which the base64 encoding
            of the attachments counts against. Attachments apply to template sends too.
          items:
            type: object
            required: [filename, content]
            properties:
              filename:
                type: string
                maxLength: 255
                description: Leaf name only — path separators and control characters are rejected.
                example: invoice-1234.pdf
              content_type:
                type: string
                description: Defaults to application/octet-stream.
                example: application/pdf
              content:
                type: string
                format: byte
                description: Standard base64-encoded file contents.
        template: { type: string, description: Template id or name (alternative to subject+html) }
        template_version: { type: integer }
        scheduled_for: { type: string, format: date-time, nullable: true }
        idempotency_key: { type: string }
        category:
          type: string
          enum: [transactional, marketing]
          default: transactional
          description: >
            `marketing` sends are only delivered to contacts with valid consent for the given
            brand and topic, carry platform-managed `List-Unsubscribe` / `List-Unsubscribe-Post`
            (RFC 8058 one-click) headers, and require `brand` and `topic`. Supplying those
            headers yourself on a marketing send is a 400 (`header:platform_managed`). Marketing
            is email-only. Transactional sends are never blocked by an unsubscribe.
        brand: { type: string, description: "Brand id (`brd_...`) or slug in this project. Required when `category` is `marketing`; rejected otherwise." }
        topic: { type: string, description: "Topic id (`top_...`). Required when `category` is `marketing`; rejected otherwise." }
    Metadata:
      type: object
      maxProperties: 20
      additionalProperties: { type: string, maxLength: 512 }
      example: { order_id: "1234" }
      description: >
        Your own string key/value pairs (at most 20 keys, keys up to 64 bytes, values up
        to 512 bytes). Stored with the send and returned unchanged as `data.metadata` in
        every webhook event for it. Never sent to the recipient.
    TextSend:
      type: object
      required: [to]
      description: >
        Body for SMS (`/v1/sms/send`) and chat (`/v1/chat/send`) sends. Provide
        `template`, `body`, or (WhatsApp only) a `whatsapp` template.
      properties:
        to: { type: string }
        body: { type: string }
        template: { type: string }
        template_version: { type: integer }
        whatsapp:
          $ref: "#/components/schemas/WhatsAppTemplate"
        metadata:
          $ref: "#/components/schemas/Metadata"
        scheduled_for: { type: string, format: date-time, nullable: true }
        idempotency_key: { type: string }
    WhatsAppTemplate:
      type: object
      description: >
        Send a pre-approved WhatsApp (Meta) template — required to message a user
        outside the 24h session window. The template must already be approved in
        your WhatsApp Business account; MessagePipe only references it by name.
      required: [template]
      properties:
        template: { type: string, description: The approved template name. }
        language: { type: string, description: "BCP-47 language code, e.g. en_US (default en_US)." }
        variables:
          type: array
          items: { type: string }
          description: Ordered body-parameter values substituted into the template's {{1}}, {{2}}, ...
    PushSend:
      type: object
      required: [to]
      properties:
        to:
          type: object
          description: >
            Provide a raw `device_token`, or a `user_id` that has a device
            registered via POST /v1/push/devices. A user_id send is one send
            (one credit) delivered to all of that user's registered devices;
            400 if the user has none.
          properties:
            device_token: { type: string }
            user_id: { type: string }
            platform: { type: string }
        title: { type: string }
        body: { type: string }
        template: { type: string }
        template_version: { type: integer }
        metadata:
          $ref: "#/components/schemas/Metadata"
        scheduled_for: { type: string, format: date-time, nullable: true }
        idempotency_key: { type: string }
    DeviceRegistration:
      type: object
      required: [user_id, token]
      properties:
        user_id: { type: string, description: Your identifier for the end user. }
        token: { type: string, description: The FCM/APNs device token. }
        platform: { type: string, description: "Optional, e.g. ios / android." }
    DeviceRegistered:
      type: object
      properties:
        id: { type: string, example: dev_06F9... }
        user_id: { type: string }
        platform: { type: string }
        status: { type: string, example: registered }
    BulkRequest:
      type: object
      required: [messages]
      properties:
        messages:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: object, description: A single send object for the matching channel }
        global_metadata:
          type: object
          additionalProperties: true
          description: Accepted but not applied to the messages. Set `metadata` on each message instead.
    SendAccepted:
      type: object
      properties:
        id: { type: string, example: msg_06F9..., description: "The message id. Webhook events for this send carry it as `data.message_id`." }
        status: { type: string, example: queued }
        channel: { type: string, enum: [email, sms, chat, push] }
        template_id: { type: string, nullable: true }
        template_version: { type: integer, nullable: true }
        queued_at: { type: string, format: date-time }
        scheduled_for: { type: string, format: date-time, nullable: true }
        suppression:
          type: object
          description: Present when `status` is `suppressed`.
          properties:
            code:
              type: string
              enum: [suppressed_hard_bounce, suppressed_soft_bounce, suppressed_complaint, suppressed_unsubscribed,
                     suppressed_manual, suppressed_global, no_contact, no_consent, consent_pending,
                     consent_expired, evidence_below_minimum]
            since: { type: string, format: date-time, nullable: true }
        _links: { type: object, properties: { self: { type: string } } }
    BulkResult:
      type: object
      properties:
        accepted: { type: integer }
        rejected: { type: integer }
        suppressed: { type: integer, description: Messages recorded as suppressed (omitted when zero). }
        results:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              id: { type: string }
              status: { type: string }
              suppression: { type: object, description: "Present when status is `suppressed`; same shape as on a single send.", properties: { code: { type: string }, since: { type: string, format: date-time, nullable: true } } }
              error: { $ref: "#/components/schemas/Error" }
    SendDetail:
      type: object
      properties:
        id: { type: string }
        workspace_id: { type: string }
        project_id: { type: string }
        channel: { type: string }
        recipient: { type: string }
        status: { type: string }
        template_id: { type: string, nullable: true }
        template_version: { type: integer, nullable: true }
        provider: { type: string, nullable: true }
        attempt_count: { type: integer }
        queued_at: { type: string, format: date-time }
        sent_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        failed_at: { type: string, format: date-time, nullable: true }
        failure_code:
          type: string
          nullable: true
          description: Machine-readable cause of failure (e.g. provider_terminal, no_providers), or null unless the send failed.
        failure_reason:
          type: string
          nullable: true
          description: Human-readable failure detail from the provider or dispatcher, or null unless the send failed.
        events:
          type: array
          items:
            type: object
            properties:
              type: { type: string }
              occurred_at: { type: string, format: date-time }
              provider: { type: string, nullable: true }
              metadata:
                type: object
                additionalProperties: true
                description: Event-specific detail (for failed events, includes failure_code and reason).
    SendList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/SendDetail" } }
        _pagination:
          type: object
          properties:
            next_cursor: { type: string, nullable: true }
            has_more: { type: boolean }
    Template:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        channel: { type: string, enum: [email, sms, chat, push] }
        sub_type: { type: string, nullable: true }
        state: { type: string }
        current_version: { type: integer }
        variables_schema: { type: array, items: { $ref: "#/components/schemas/VariableSchema" } }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    VariableSchema:
      type: object
      properties:
        name: { type: string }
        required: { type: boolean }
        sample_value: { type: string }
        description: { type: string }
    TemplateList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Template" } }
    ContactInput:
      type: object
      description: At least one of email, phone, external_id.
      additionalProperties: false
      properties:
        email: { type: string, format: email }
        phone: { type: string, description: "E.164, e.g. +15551234567" }
        external_id: { type: string, maxLength: 255 }
        first_name: { type: string, nullable: true, maxLength: 200 }
        last_name: { type: string, nullable: true, maxLength: 200 }
        locale: { type: string, nullable: true, example: fr-CA }
        timezone: { type: string, nullable: true, example: Europe/Paris }
        country: { type: string, nullable: true, example: CA, description: ISO 3166-1 alpha-2, upper case }
        attributes: { $ref: "#/components/schemas/Attributes" }
        consent:
          allOf: [ { $ref: "#/components/schemas/ConsentWrite" } ]
          description: >
            Optional. Records the person's consent in the same transaction as the contact, with exactly
            the shape and rules of `POST /v1/contacts/{id}/consent` (requires the `consent:write` scope
            as well). Either both land or neither: if the consent evidence is rejected the contact is
            not created and the error is the consent error (422). The response is the contact plus a
            `consent` object holding the consent result. Use this from a form you built yourself.
    ContactPatch:
      type: object
      additionalProperties: false
      properties:
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        locale: { type: string, nullable: true }
        timezone: { type: string, nullable: true }
        country: { type: string, nullable: true }
        attributes: { $ref: "#/components/schemas/Attributes" }
    Attributes:
      type: object
      maxProperties: 50
      description: Keys match ^[a-z][a-z0-9_]{0,62}$. Values are string (max 1000 chars), number, boolean, list of strings, or null to remove.
      additionalProperties: true
    Contact:
      type: object
      properties:
        id: { type: string, example: con_01H }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        external_id: { type: string, nullable: true }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        locale: { type: string, nullable: true }
        timezone: { type: string, nullable: true }
        country: { type: string, nullable: true }
        attributes: { type: object, additionalProperties: true }
        status: { type: string, enum: [active, erased] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        erased_at: { type: string, format: date-time, nullable: true }
        consent: { type: array, items: { $ref: "#/components/schemas/ConsentRecord" } }
    ConsentRecord:
      type: object
      properties:
        brand_id: { type: string }
        topic_id: { type: string }
        channel: { type: string, enum: [email, sms, chat, push] }
        state: { type: string, enum: [pending, subscribed, unsubscribed, expired] }
        basis: { type: string, nullable: true }
        evidence_tier: { type: string, enum: [A, B, C], nullable: true }
        implied_expires_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time }
    ConsentWrite:
      type: object
      required: [brand, grants]
      additionalProperties: false
      properties:
        brand: { type: string, description: Brand id or slug within this API key's project. }
        grants:
          type: array
          maxItems: 50
          items:
            type: object
            required: [topic, channel, state]
            properties:
              topic: { type: string }
              channel: { type: string, enum: [email, sms, chat, push] }
              state: { type: string, enum: [subscribed, unsubscribed] }
        statement:
          type: object
          required: [id, version]
          properties:
            id: { type: string }
            version: { type: integer, minimum: 1 }
            locale: { type: string, description: Defaults to the brand's default locale. }
        basis: { type: string, enum: [express, implied_purchase, implied_contract, implied_inquiry, implied_publication, implied_disclosed, pecr_soft_opt_in] }
        method: { type: string, enum: [checkbox_unticked, typed_address, confirm_click, toggle, keyword_reply, verbal, api_withdrawal] }
        captured_at: { type: string, format: date-time, description: When the person consented. More than 30 days old needs backfill=true. }
        channel_context: { type: string, enum: [web, app, server, pos, phone] }
        channel_context_reason: { type: string, description: Required for server, pos and phone. }
        ip: { type: string, description: Required for web and app. }
        user_agent: { type: string, description: Required for web and app. }
        page_url: { type: string }
        external_ref: { type: string }
        artifact_hash: { type: string }
        relevance_note: { type: string }
        no_unsolicited_statement: { type: boolean }
        basis_event_at: { type: string, format: date-time, nullable: true, description: Purchase, contract end or inquiry date for implied bases. }
        backfill: { type: boolean, description: Declares the consent was captured long ago; the record earns tier C. }
    ConsentWriteResult:
      type: object
      properties:
        contact_id: { type: string }
        brand_id: { type: string }
        results:
          type: array
          items:
            type: object
            properties:
              topic: { type: string }
              channel: { type: string }
              state: { type: string, enum: [pending, subscribed, unsubscribed] }
              evidence_tier: { type: string, enum: [A, B, C] }
              implied_expires_at: { type: string, format: date-time }
              event_id: { type: string }
              no_op: { type: boolean }
        warnings:
          type: array
          items:
            type: object
            properties:
              code: { type: string, enum: [address_suppressed, evidence_below_topic_minimum] }
              channel: { type: string }
              topic: { type: string }
              reasons: { type: array, items: { type: string } }
    ConsentState:
      type: object
      properties:
        contact_id: { type: string }
        records: { type: array, items: { $ref: "#/components/schemas/ConsentRecord" } }
        events: { type: array, items: { type: object, additionalProperties: true } }
    EraseResult:
      type: object
      properties:
        id: { type: string }
        status: { type: string, enum: [erased] }
        erased_at: { type: string, format: date-time }
        events_pseudonymised: { type: integer }
        suppressions_added: { type: integer }
    SuppressionInput:
      type: object
      required: [address, reason]
      additionalProperties: false
      properties:
        address: { type: string }
        channel: { type: string, enum: [email, sms, chat, push], default: email }
        reason: { type: string, enum: [manual, unsubscribed] }
        brand: { type: string, description: Brand id or slug; only with reason unsubscribed. }
        note: { type: string, maxLength: 500 }
    Suppression:
      type: object
      properties:
        id: { type: string }
        channel: { type: string }
        reason: { type: string, enum: [hard_bounce, soft_bounce_repeated, complaint, unsubscribed, manual, global_blocklist] }
        brand_id: { type: string, nullable: true }
        address_key_prefix: { type: string }
        source_send_id: { type: string, nullable: true }
        note: { type: string, nullable: true }
        created_by: { type: string }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time, nullable: true }
        removed_at: { type: string, format: date-time, nullable: true }
        removed_reason: { type: string, nullable: true }
    SuppressionList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Suppression" } }
        next_cursor: { type: string }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            details: { type: object, nullable: true, additionalProperties: true }
            request_id: { type: string }
            documentation_url: { type: string }
  responses:
    BadRequest: { description: "validation_failed / invalid_json / template_not_found", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Unauthorized: { description: Missing or invalid API key, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Forbidden: { description: "insufficient_scope; forbidden (workspace suspended); or, on email sends, sender_not_allowed (`from` is off the project's sender domains)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NotFound: { description: not_found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    UnprocessableSMS: { description: "sms_destination_not_supported: managed SMS has no price for the destination country (or is not on sale), nothing was charged (details.country, details.message_type, details.reason = country_not_priced | unknown_country | managed_sms_unavailable)", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    CreditsExhausted: { description: "No credits remaining in the bucket the send is billed from (the plain channel, or the managed one for a send through a MessagePipe managed provider). A managed SMS costs segments x the destination's credit weight: when some credits remain but fewer than that, the message says how many were needed and details carries credits_required and credits_available", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    RateLimited: { description: Rate limit exceeded (see Retry-After), content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Conflict: { description: "identity_conflict; recent_recipient_withdrawal; idempotency_in_progress", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Unprocessable: { description: "consent_evidence_incomplete (details.missing) / consent_evidence_invalid (details.reason) / idempotency_mismatch", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
