openapi: 3.1.0
info:
  title: 1Funnel External Integration API
  version: 0.1.0-draft
  summary: External Integration API — Developer Preview (contract only, non-GA).
  description: |
    OpenAPI 3.1 for the **1Funnel External Integration API** (foundation through
    webhooks). Companion AsyncAPI documents outbound webhook event payloads.

    ## Status (read carefully)

    | Layer | Status |
    |-------|--------|
    | Architecture (Platform ADRs / STD-API-01) | Approved |
    | This OpenAPI document | **Developer Preview — contract only** (non-GA) |
    | Sandbox API host | Architecture **target** — not claimed operational |
    | Production API host | Architecture **target** — not claimed operational |

    This specification is published for integrator review. Documented endpoints
    and authentication services are **not** available for public integration.
    Sandbox and production availability will be announced separately. This is
    not general availability (ADR-011 DEC-5A-07 Preview API documentation).

    ## Authentication and authorization

    Callers authenticate with OAuth 2.0 Client Credentials
    (`client_secret_basic`) against the external authorization server
    (ADR-015, ADR-023). Access tokens must present audience
    `1funnel-external-api`.

    After token validation, every business-scoped operation requires:

    1. Valid application identity
    2. Active application-to-business grant for `{businessId}`
    3. Required resource permission (ADR-022)
    4. Resource ownership / isolation (ADR-016)

    `{businessId}` is a selector, not proof. OAuth scopes (when introduced) are
    coarse token vocabulary and are **not** the ADR-022 permission catalogue.
    Operations document required permissions with `x-required-permissions`.

    ## Servers and OAuth URLs

    API `servers` and OAuth token URLs are environment-specific architecture
    targets (ADR-023). OpenAPI cannot automatically switch the OAuth
    `tokenUrl` when a client selects a `servers` entry. See
    `x-1funnel-oauth` and the security scheme description.
  contact:
    name: 1Funnel Platform
    email: support@1funnel.ng
  license:
    name: Proprietary
    url: https://1funnel.ng
servers:
  - url: https://api.1-funnel.org
    description: |
      Production External Integration API host (ADR-023 target).
      Not claimed deployed or generally available.
  - url: https://api.sandbox.1-funnel.org
    description: |
      Sandbox External Integration API host (ADR-023 target).
      Not claimed deployed or generally available.
security:
  - OAuth2ClientCredentials: []
tags:
  - name: Foundation
    description: Shared conventions and cross-cutting components.
  - name: SMS
    description: |
      Individual SMS, bulk SMS, reporting, and SMS templates (ADR-018).
      Public bulk resource is `/sms/bulk-messages`. There is no `/sms/batches`.
  - name: Wallet
    description: Read-only wallet balance (ADR-019).
  - name: OTP
    description: |
      Managed OTP challenges (ADR-020 / ADR-008). Secrets are never returned.
      Service availability is not claimed by this draft.
  - name: Webhooks
    description: |
      Webhook endpoint management, event history, and delivery inspection
      (ADR-021). Signature protocol DEC-WH-S01 approved. Ownership
      verification deferred in v1. Public manual replay and public secret
      rotation are not available. See WEBHOOK-DECISION-REGISTER.md.
paths:
  /v1/businesses/{businessId}/sms/messages:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      operationId: listSmsMessages
      tags:
        - SMS
      summary: List individual SMS messages
      description: |
        Cursor-paginated message history for the granted business (ADR-018 SMS-03,
        DEC-SMS-F01 / C01). Default ordering: creation timestamp descending with a
        stable identifier tie-breaker. No arbitrary sorting in v1.
        Date filters use inclusive `created_from` and exclusive `created_to`.
        Maximum date range is 90 days.
      x-required-permissions:
        - sms.message.read
      parameters:
        - $ref: '#/components/parameters/SmsLimit'
        - $ref: '#/components/parameters/SmsCursor'
        - $ref: '#/components/parameters/RequestId'
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/MessageStatus'
        - name: classification
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/MessageClassification'
        - name: to
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/E164PhoneNumber'
        - name: from
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/SenderId'
        - name: created_from
          in: query
          required: false
          description: Inclusive lower bound on creation timestamp (RFC 3339 UTC).
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: created_to
          in: query
          required: false
          description: Exclusive upper bound on creation timestamp (RFC 3339 UTC).
          schema:
            $ref: '#/components/schemas/Timestamp'
      responses:
        '200':
          description: Message page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsMessageList'
              examples:
                page:
                  value:
                    data:
                      - id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
                        business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                        from: 1FUNNEL
                        to: '+2348012345678'
                        classification: transactional
                        message: Your order ORD-1001 is confirmed.
                        status: delivered
                        status_reason: null
                        template: null
                        accepted_at: '2026-10-13T09:00:00.000Z'
                        submitted_at: '2026-10-13T09:00:01.000Z'
                        delivered_at: '2026-10-13T09:00:05.000Z'
                        failed_at: null
                    pagination:
                      limit: 100
                      has_more: false
                      next_cursor: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    post:
      operationId: createSmsMessage
      tags:
        - SMS
      summary: Submit an individual SMS
      description: |
        Durably accept an individual SMS for asynchronous processing (ADR-018 SMS-01).
        `202` is not delivery confirmation. Requires `Idempotency-Key` (DEC-SMS-I01).
      x-required-permissions:
        - sms.message.send
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SmsMessageCreateRequest'
            examples:
              freeText:
                summary: Free-text transactional SMS
                value:
                  from: 1FUNNEL
                  to: '+2348012345678'
                  classification: transactional
                  message: Your order ORD-1001 is confirmed.
              templateResolved:
                summary: Template id (active revision pinned at acceptance)
                value:
                  from: 1FUNNEL
                  to: '+2348012345678'
                  classification: transactional
                  template_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                  template_variables:
                    first_name: Ada
                    order_id: ORD-1001
      responses:
        '202':
          description: Durably accepted for asynchronous processing
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Location:
              $ref: '#/components/headers/Location'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsMessage'
              examples:
                accepted:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    to: '+2348012345678'
                    classification: transactional
                    message: Your order ORD-1001 is confirmed.
                    status: queued
                    status_reason: null
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    submitted_at: null
                    delivered_at: null
                    failed_at: null
                idempotentReplay:
                  summary: Same key and equivalent payload within seven days
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    to: '+2348012345678'
                    classification: transactional
                    message: Your order ORD-1001 is confirmed.
                    status: queued
                    status_reason: null
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    submitted_at: null
                    delivered_at: null
                    failed_at: null
                templatePinned:
                  summary: Template submission with revision pin
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    to: '+2348012345678'
                    classification: transactional
                    message: Hi Ada, order ORD-1001 is confirmed.
                    status: queued
                    status_reason: null
                    template:
                      template_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                      revision_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789cd
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    submitted_at: null
                    delivered_at: null
                    failed_at: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/sms/messages/{messageId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/MessageId'
    get:
      operationId: getSmsMessage
      tags:
        - SMS
      summary: Retrieve an individual SMS
      description: |
        Business-scoped message detail (ADR-018 SMS-02). Cross-business existence
        is not disclosed (ADR-016).
      x-required-permissions:
        - sms.message.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Message detail
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsMessage'
              examples:
                delivered:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    to: '+2348012345678'
                    classification: transactional
                    message: Your order ORD-1001 is confirmed.
                    status: delivered
                    status_reason: null
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    submitted_at: '2026-10-13T09:00:01.000Z'
                    delivered_at: '2026-10-13T09:00:05.000Z'
                    failed_at: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/sms/bulk-messages:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      operationId: listSmsBulkMessages
      tags:
        - SMS
      summary: List bulk SMS messages
      description: |
        Cursor-paginated bulk messages (DEC-SMS-F01 / C01). Default ordering:
        creation timestamp descending with stable id tie-breaker. Inclusive
        `created_from`, exclusive `created_to`; max range 90 days.
      x-required-permissions:
        - sms.bulk_message.read
      parameters:
        - $ref: '#/components/parameters/SmsLimit'
        - $ref: '#/components/parameters/SmsCursor'
        - $ref: '#/components/parameters/RequestId'
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/BulkProcessingStatus'
        - name: classification
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/MessageClassification'
        - name: from
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/SenderId'
        - name: created_from
          in: query
          required: false
          description: Inclusive lower bound on creation timestamp.
          schema:
            $ref: '#/components/schemas/Timestamp'
        - name: created_to
          in: query
          required: false
          description: Exclusive upper bound on creation timestamp.
          schema:
            $ref: '#/components/schemas/Timestamp'
      responses:
        '200':
          description: Bulk message page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsBulkMessageList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
    post:
      operationId: createSmsBulkMessage
      tags:
        - SMS
      summary: Submit a bulk SMS
      description: |
        Durably accept a bulk SMS (ADR-018 SMS-04, DEC-SMS-B01/B02). Inline
        `recipients` only in v1. Exceeding the recipient limit rejects the entire
        request with `422` / `recipient_limit_exceeded` before acceptance.
        `202` is not delivery completion.
      x-required-permissions:
        - sms.bulk_message.create
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SmsBulkMessageCreateRequest'
            examples:
              inlineRecipients:
                value:
                  from: 1FUNNEL
                  classification: promotional
                  message: Weekend offer for Ada.
                  recipients:
                    - to: '+2348012345678'
                    - to: '+2348098765432'
      responses:
        '202':
          description: Durably accepted bulk message
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Location:
              $ref: '#/components/headers/Location'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsBulkMessage'
              examples:
                accepted:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-bbbbbbbbbbbb
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    classification: promotional
                    status: queued
                    settled: false
                    message: Weekend offer for Ada.
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    intake:
                      good: 2
                      bad: 0
                      empty: 0
                    submission: null
                    delivery_summary: null
                    warnings: []
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/sms/bulk-messages/{bulkMessageId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/BulkMessageId'
    get:
      operationId: getSmsBulkMessage
      tags:
        - SMS
      summary: Retrieve bulk SMS processing status and counts
      description: |
        Bulk processing lifecycle, intake/submission counts, `settled`, and optional
        `delivery_summary` (ADR-018 SMS-05 / SMS-14 / SMS-14a / SMS-15).
        `completed` ≠ all delivered. `settled` ≠ delivery complete.
      x-required-permissions:
        - sms.bulk_message.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Bulk message detail
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsBulkMessage'
              examples:
                processing:
                  summary: In processing
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-bbbbbbbbbbbb
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    classification: promotional
                    status: processing
                    settled: false
                    message: Weekend offer
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    intake:
                      good: 1000
                      bad: 2
                      empty: 0
                    submission:
                      processed: 400
                      success: 390
                      failed: 10
                      retryable_failed_count: 10
                      exhausted: 0
                    delivery_summary:
                      as_of: '2026-10-13T09:05:00.000Z'
                      complete: false
                      outcomes_resolved: 120
                    warnings: []
                completedDeliveryIncomplete:
                  summary: Processing completed; delivery incomplete
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-bbbbbbbbbbbb
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    classification: promotional
                    status: completed
                    settled: true
                    message: Weekend offer
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    intake:
                      good: 1000
                      bad: 2
                      empty: 0
                    submission:
                      processed: 1000
                      success: 980
                      failed: 20
                      retryable_failed_count: 0
                      exhausted: 20
                    delivery_summary:
                      as_of: '2026-10-13T09:00:00.000Z'
                      complete: false
                      outcomes_resolved: 850
                    warnings: []
                deliverySummaryUnavailable:
                  summary: Delivery summary unavailable (null-and-warning)
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-bbbbbbbbbbbb
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    from: 1FUNNEL
                    classification: promotional
                    status: processing
                    settled: false
                    message: Weekend offer
                    template: null
                    accepted_at: '2026-10-13T09:00:00.000Z'
                    intake:
                      good: 1000
                      bad: 0
                      empty: 0
                    submission:
                      processed: 100
                      success: 100
                      failed: 0
                      retryable_failed_count: 0
                      exhausted: 0
                    delivery_summary: null
                    warnings:
                      - code: delivery_summary_unavailable
                        detail: Authoritative delivery reporting is temporarily unavailable.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/sms/bulk-messages/{bulkMessageId}/messages:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/BulkMessageId'
    get:
      operationId: listSmsBulkMessageRecipients
      tags:
        - SMS
      summary: List bulk SMS recipient rows
      description: |
        Recipient-level reporting for a bulk message (ADR-018 SMS-06).
        Public path is `/sms/bulk-messages/{bulkMessageId}/messages` (not `/recipients`).
        Submission and delivery outcomes remain distinct.
      x-required-permissions:
        - sms.bulk_message.read
      parameters:
        - $ref: '#/components/parameters/SmsLimit'
        - $ref: '#/components/parameters/SmsCursor'
        - $ref: '#/components/parameters/RequestId'
        - name: status
          in: query
          required: false
          description: Filter by recipient submission or delivery status (DEC-SMS-F01).
          schema:
            $ref: '#/components/schemas/MessageStatus'
        - name: to
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/E164PhoneNumber'
      responses:
        '200':
          description: Recipient page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsBulkRecipientList'
              examples:
                page:
                  value:
                    data:
                      - id: 018f3c4e-7b2a-7c3d-8e9f-cccccccccccc
                        to: '+2348012345678'
                        submission_status: sent
                        delivery_status: delivered
                        status_reason: null
                        accepted_at: '2026-10-13T09:00:00.000Z'
                        submitted_at: '2026-10-13T09:00:02.000Z'
                        delivered_at: '2026-10-13T09:00:08.000Z'
                        failed_at: null
                    pagination:
                      limit: 100
                      has_more: false
                      next_cursor: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/sms/templates:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      operationId: listSmsTemplates
      tags:
        - SMS
      summary: List SMS templates
      description: |
        Cursor-paginated SMS templates for the granted business (ADR-018 SMS-07).
        Optional `status` filter is recommended contract detail (DEC-SMS-F01).
      x-required-permissions:
        - sms.template.read
      parameters:
        - $ref: '#/components/parameters/SmsLimit'
        - $ref: '#/components/parameters/SmsCursor'
        - $ref: '#/components/parameters/RequestId'
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/TemplateLifecycleStatus'
      responses:
        '200':
          description: Template page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsTemplateList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      operationId: createSmsTemplate
      tags:
        - SMS
      summary: Create an SMS template
      description: |
        Creates a template and its initial immutable revision (ADR-018 SMS-07/SMS-08).
      x-required-permissions:
        - sms.template.write
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SmsTemplateCreateRequest'
            examples:
              create:
                value:
                  name: order_confirmed
                  message: Hi {{first_name}}, order {{order_id}} is confirmed.
                  variable_keys:
                    - first_name
                    - order_id
      responses:
        '201':
          description: Template created
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Location:
              $ref: '#/components/headers/Location'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsTemplate'
              examples:
                created:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    name: order_confirmed
                    status: draft
                    message: Hi {{first_name}}, order {{order_id}} is confirmed.
                    variable_keys:
                      - first_name
                      - order_id
                    current_revision_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789cd
                    created_at: '2026-10-13T08:00:00.000Z'
                    updated_at: '2026-10-13T08:00:00.000Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/businesses/{businessId}/sms/templates/{templateId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/TemplateId'
    get:
      operationId: getSmsTemplate
      tags:
        - SMS
      summary: Retrieve an SMS template
      description: |
        Returns a business-owned SMS template including its current immutable
        revision pointer (ADR-018 SMS-07/SMS-08).
      x-required-permissions:
        - sms.template.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Template detail
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsTemplate'
              examples:
                active:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    name: order_confirmed
                    status: active
                    message: Hi {{first_name}}, order {{order_id}} is confirmed.
                    variable_keys:
                      - first_name
                      - order_id
                    current_revision_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789cd
                    created_at: '2026-10-13T08:00:00.000Z'
                    updated_at: '2026-10-13T08:30:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    patch:
      operationId: updateSmsTemplate
      tags:
        - SMS
      summary: Update an SMS template
      description: |
        Body changes create a new immutable `current_revision_id`. Prior revision
        IDs remain valid for pinned historical sends (ADR-018 SMS-08).
      x-required-permissions:
        - sms.template.write
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SmsTemplateUpdateRequest'
            examples:
              newRevision:
                summary: Content update creates new revision
                value:
                  message: Hello {{first_name}}, order {{order_id}} is confirmed.
                  variable_keys:
                    - first_name
                    - order_id
              archive:
                summary: Soft-archive via status
                value:
                  status: archived
      responses:
        '200':
          description: Template updated
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsTemplate'
              examples:
                revised:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    name: order_confirmed
                    status: active
                    message: Hello {{first_name}}, order {{order_id}} is confirmed.
                    variable_keys:
                      - first_name
                      - order_id
                    current_revision_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ef
                    created_at: '2026-10-13T08:00:00.000Z'
                    updated_at: '2026-10-13T09:15:00.000Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    delete:
      operationId: archiveSmsTemplate
      tags:
        - SMS
      summary: Archive an SMS template
      description: |
        Soft-deletes the template by transitioning to `archived` (ADR-018 SMS-08
        lifecycle). Physical hard-delete is not the public model. Historical
        pinned revisions remain reproducible.
      x-required-permissions:
        - sms.template.write
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '204':
          $ref: '#/components/responses/NoContent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/businesses/{businessId}/wallet/balance:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      operationId: getWalletBalance
      tags:
        - Wallet
      summary: Retrieve wallet balance
      description: |
        Read-only balance for the granted business (ADR-019). Requires an active
        application-to-business grant and `wallet.balance.read`. `{businessId}` is
        a selector, not proof (ADR-016).

        `reserved_balance` is always `"0.00"` in v1 and must not be interpreted as
        evidence of reservation accounting. When the authoritative wallet source is
        unavailable, return a Problem Details error — do not fabricate zero balances.
      x-required-permissions:
        - wallet.balance.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Authoritative balance snapshot
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WalletBalance'
              examples:
                success:
                  value:
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    business_name: Example Business
                    currency: NGN
                    current_balance: '15000.00'
                    available_balance: '15000.00'
                    reserved_balance: '0.00'
                    as_of: '2026-10-13T09:00:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          description: |
            Authoritative balance unavailable
            (`code: balance_unavailable` or `service_unavailable`).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                balanceUnavailable:
                  value:
                    type: https://developers.1-funnel.org/problems/balance_unavailable
                    title: Balance unavailable
                    status: 503
                    detail: The authoritative wallet balance source is temporarily unavailable.
                    code: balance_unavailable
                    request_id: req_01HZX3K9EXAMPLE
  /v1/businesses/{businessId}/otp/challenges:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    post:
      operationId: createOtpChallenge
      tags:
        - OTP
      summary: Create an OTP challenge
      description: |
        Durably accept an OTP challenge and enqueue SMS delivery (ADR-020 OTP-A02/A04).
        Requires `Idempotency-Key`. `202` is not delivery confirmation. OTP secrets
        are never returned. Dedicated `otp-api` is a future implementation dependency;
        this contract does not claim service availability.
      x-required-permissions:
        - otp.challenge.create
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OtpChallengeCreateRequest'
            examples:
              inline:
                value:
                  purpose: login
                  channel: sms
                  destination: '+2348012345678'
                  message: 'Acme: your login code is {{code}}. Use it to continue.'
      responses:
        '202':
          description: Challenge durably accepted; delivery asynchronous
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Location:
              $ref: '#/components/headers/Location'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OtpChallenge'
              examples:
                accepted:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    status: queued
                    purpose: login
                    channel: sms
                    delivery_mode: inline
                    code_length: 6
                    expires_at: '2026-10-13T09:05:00.000Z'
                    resend_available_at: '2026-10-13T09:01:00.000Z'
                    remaining_resends: 4
                    attempt_count: 0
                    max_verification_attempts: 5
                    verified_at: null
                    invalidated_at: null
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:00:00.000Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/otp/challenges/{challengeId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/ChallengeId'
    get:
      operationId: getOtpChallenge
      tags:
        - OTP
      summary: Retrieve OTP challenge status
      description: |
        Publicly safe challenge status (ADR-020 OTP-A05). Never returns codes,
        hashes, or message content.
      x-required-permissions:
        - otp.challenge.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Challenge status
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OtpChallenge'
              examples:
                sent:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    status: sent
                    purpose: login
                    channel: sms
                    delivery_mode: inline
                    code_length: 6
                    expires_at: '2026-10-13T09:05:00.000Z'
                    resend_available_at: '2026-10-13T09:01:00.000Z'
                    remaining_resends: 4
                    attempt_count: 1
                    max_verification_attempts: 5
                    verified_at: null
                    invalidated_at: null
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:00:08.000Z'
                expired:
                  summary: Expired challenge
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    status: expired
                    purpose: login
                    channel: sms
                    delivery_mode: inline
                    code_length: 6
                    expires_at: '2026-10-13T09:05:00.000Z'
                    resend_available_at: null
                    remaining_resends: 0
                    attempt_count: 0
                    max_verification_attempts: 5
                    verified_at: null
                    invalidated_at: null
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:05:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/otp/challenges/{challengeId}/verify:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/ChallengeId'
    post:
      operationId: verifyOtpChallenge
      tags:
        - OTP
      summary: Verify an OTP challenge
      description: |
        Atomic single-use verification (ADR-020 / ADR-008 / DEC-OTP-V01). Does not
        reveal whether a code was “close” or expose secrets. Verification outcome
        is distinct from SMS delivery outcome.
      x-required-permissions:
        - otp.challenge.verify
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OtpVerifyRequest'
            examples:
              code:
                value:
                  code: '483920'
      responses:
        '200':
          description: Challenge verified
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OtpVerifyResponse'
              examples:
                verified:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
                    status: verified
                    purpose: login
                    verified_at: '2026-10-13T09:01:12.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already consumed or invalidated challenge
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                alreadyConsumed:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_challenge_consumed
                    title: Challenge already consumed
                    status: 409
                    detail: The challenge was already verified.
                    code: otp_challenge_consumed
                    request_id: req_01HZX3K9EXAMPLE
                invalidated:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_challenge_invalidated
                    title: Challenge invalidated
                    status: 409
                    detail: The challenge was invalidated and cannot be verified.
                    code: otp_challenge_invalidated
                    request_id: req_01HZX3K9EXAMPLE
        '422':
          description: Incorrect code or expired challenge
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                verificationFailed:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_verification_failed
                    title: Verification failed
                    status: 422
                    detail: The verification code was rejected.
                    code: otp_verification_failed
                    request_id: req_01HZX3K9EXAMPLE
                expired:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_challenge_expired
                    title: Challenge expired
                    status: 422
                    detail: The challenge has expired.
                    code: otp_challenge_expired
                    request_id: req_01HZX3K9EXAMPLE
        '429':
          description: Challenge locked (attempt budget exhausted)
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                locked:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_challenge_locked
                    title: Challenge locked
                    status: 429
                    detail: Verification attempts are exhausted.
                    code: otp_challenge_locked
                    request_id: req_01HZX3K9EXAMPLE
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/otp/challenges/{challengeId}/resend:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/ChallengeId'
    post:
      operationId: resendOtpChallenge
      tags:
        - OTP
      summary: Resend an OTP challenge
      description: |
        Regenerate code and enqueue delivery subject to cooldown and resend limits
        (ADR-020 OTP-A02, DEC-OTP-V01). Requires `Idempotency-Key`. `202` is not
        delivery confirmation. Earlier codes become invalid when a new code is
        issued.
      x-required-permissions:
        - otp.challenge.create
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OtpResendRequest'
            examples:
              reusePattern:
                value: {}
      responses:
        '202':
          description: Resend durably accepted
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OtpChallenge'
              examples:
                resent:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    status: queued
                    purpose: login
                    channel: sms
                    delivery_mode: inline
                    code_length: 6
                    expires_at: '2026-10-13T09:06:00.000Z'
                    resend_available_at: '2026-10-13T09:02:00.000Z'
                    remaining_resends: 3
                    attempt_count: 0
                    max_verification_attempts: 5
                    verified_at: null
                    invalidated_at: null
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:01:00.000Z'
        '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/UnprocessableContent'
        '429':
          description: Resend cooldown or resend limit (DEC-OTP-V01)
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                cooldown:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_resend_cooldown
                    title: Resend cooldown active
                    status: 429
                    detail: Wait until resend_available_at before requesting another code.
                    code: otp_resend_cooldown
                    request_id: req_01HZX3K9EXAMPLE
                limit:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_resend_limit_exceeded
                    title: Resend limit exceeded
                    status: 429
                    detail: No resends remain for this challenge window.
                    code: otp_resend_limit_exceeded
                    request_id: req_01HZX3K9EXAMPLE
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/otp/challenges/{challengeId}/invalidate:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/ChallengeId'
    post:
      operationId: invalidateOtpChallenge
      tags:
        - OTP
      summary: Invalidate an OTP challenge
      description: |
        Terminal invalidation. Consistent with single-use verification — an
        invalidated challenge cannot be verified afterward.
      x-required-permissions:
        - otp.challenge.verify
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OtpInvalidateRequest'
            examples:
              cancel:
                value:
                  reason: user_cancelled
      responses:
        '200':
          description: Challenge invalidated
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OtpChallenge'
              examples:
                invalidated:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    status: invalidated
                    purpose: login
                    channel: sms
                    delivery_mode: inline
                    code_length: 6
                    expires_at: '2026-10-13T09:05:00.000Z'
                    resend_available_at: null
                    remaining_resends: 0
                    attempt_count: 0
                    max_verification_attempts: 5
                    verified_at: null
                    invalidated_at: '2026-10-13T09:02:00.000Z'
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:02:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Challenge already in a terminal state
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
              examples:
                alreadyConsumed:
                  value:
                    type: https://developers.1-funnel.org/problems/otp_challenge_consumed
                    title: Challenge already consumed
                    status: 409
                    detail: The challenge was already verified.
                    code: otp_challenge_consumed
                    request_id: req_01HZX3K9EXAMPLE
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/webhook-endpoints:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      operationId: listWebhookEndpoints
      tags:
        - Webhooks
      summary: List webhook endpoints
      description: |
        Cursor-paginated endpoints for the granted business (ADR-021 WH-01,
        DEC-WH-P01). Uses shared pagination (default 50 / max 200) — not SmsLimit.
        Default maximum five **active** endpoints per business.
      x-required-permissions:
        - webhook.endpoint.read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/RequestId'
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/WebhookEndpointStatus'
      responses:
        '200':
          description: Endpoint page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointList'
              examples:
                page:
                  value:
                    data:
                      - id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                        business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                        url: https://hooks.example.com/1funnel/webhooks
                        description: Production SMS delivery callbacks
                        status: active
                        event_types:
                          - sms.message.delivered.v1
                        created_at: '2026-10-13T09:00:00.000Z'
                        updated_at: '2026-10-13T09:00:00.000Z'
                    pagination:
                      limit: 50
                      has_more: false
                      next_cursor: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      operationId: createWebhookEndpoint
      tags:
        - Webhooks
      summary: Create a webhook endpoint
      description: |
        Create an HTTPS destination with explicit event subscriptions (DEC-WH-C01).
        Returns `201 Created` with `Location` and one-time `signing_secret`
        (DEC-WH-R01). Endpoint becomes `active` after successful destination
        security validation. Ownership verification is **deferred** in v1
        (DEC-WH-V01) — not required for activation.

        Mandatory controls: HTTPS-only, valid TLS, server-side URL validation,
        SSRF / DNS-rebinding / redirect protection, prohibited private/internal
        destinations, endpoint-specific HMAC signing, create/update rate limiting.
        Maximum five active endpoints per business.

        Delivery signing (DEC-WH-S01): HMAC-SHA256 over
        `timestamp + "." + raw_request_body`; headers `1Funnel-Webhook-Id`,
        `1Funnel-Webhook-Timestamp`, `1Funnel-Webhook-Signature: v1=<lowercase_hex>`.
        Requires `Idempotency-Key`.
      x-required-permissions:
        - webhook.endpoint.write
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreateRequest'
            examples:
              create:
                value:
                  url: https://hooks.example.com/1funnel/webhooks
                  description: Production SMS delivery callbacks
                  event_types:
                    - sms.message.delivered.v1
                    - sms.message.failed.v1
      responses:
        '201':
          description: Endpoint created; signing secret returned once
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
            Location:
              $ref: '#/components/headers/Location'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointCreated'
              examples:
                created:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    url: https://hooks.example.com/1funnel/webhooks
                    description: Production SMS delivery callbacks
                    status: active
                    event_types:
                      - sms.message.delivered.v1
                      - sms.message.failed.v1
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:00:00.000Z'
                    signing_secret: whsec_example_not_a_real_secret_0123456789abcdef
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
  /v1/businesses/{businessId}/webhook-endpoints/{endpointId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/EndpointId'
    get:
      operationId: getWebhookEndpoint
      tags:
        - Webhooks
      summary: Retrieve a webhook endpoint
      description: |
        Business-scoped endpoint detail. Never returns signing secrets (DEC-WH-R01).
      x-required-permissions:
        - webhook.endpoint.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Endpoint detail
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
              examples:
                active:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    url: https://hooks.example.com/1funnel/webhooks
                    description: Production SMS delivery callbacks
                    status: active
                    event_types:
                      - sms.message.delivered.v1
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T09:00:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    patch:
      operationId: updateWebhookEndpoint
      tags:
        - Webhooks
      summary: Update a webhook endpoint
      description: |
        Partial update. URL changes require destination security validation but
        not ownership reverification (DEC-WH-V01 deferred). Does not return secrets.
      x-required-permissions:
        - webhook.endpoint.write
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointUpdateRequest'
            examples:
              disable:
                value:
                  status: disabled
              changeUrl:
                value:
                  url: https://hooks.example.com/1funnel/webhooks-v2
      responses:
        '200':
          description: Updated endpoint
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
              examples:
                afterUrlChange:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    url: https://hooks.example.com/1funnel/webhooks-v2
                    description: Production SMS delivery callbacks
                    status: active
                    event_types:
                      - sms.message.delivered.v1
                    created_at: '2026-10-13T09:00:00.000Z'
                    updated_at: '2026-10-13T10:00:00.000Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    delete:
      operationId: deleteWebhookEndpoint
      tags:
        - Webhooks
      summary: Deactivate a webhook endpoint
      description: |
        Soft-deletes / deactivates the endpoint (`204`). Prevents new deliveries.
        Historical events and delivery records are retained (DEC-WH-E01). In-flight
        attempts may complete or be cancelled safely without public controls.
      x-required-permissions:
        - webhook.endpoint.write
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '204':
          $ref: '#/components/responses/NoContent'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/businesses/{businessId}/webhook-endpoints/{endpointId}/deliveries:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/EndpointId'
    get:
      operationId: listWebhookEndpointDeliveries
      tags:
        - Webhooks
      summary: List deliveries for an endpoint
      description: |
        Delivery inspection for a single endpoint (ADR-021 WH-07). Retries within
        a 24-hour window; exhausted deliveries may be dead-lettered internally.
        Public manual replay is not available in v1.
      x-required-permissions:
        - webhook.event.read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/RequestId'
        - name: status
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/WebhookDeliveryStatus'
      responses:
        '200':
          description: Delivery page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryList'
              examples:
                page:
                  value:
                    data:
                      - id: 018f3c4e-7b2a-7c3d-8e9f-111111111111
                        event_id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
                        endpoint_id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                        status: retry_scheduled
                        attempt_count: 2
                        last_http_status: 503
                        last_attempted_at: '2026-10-13T09:05:00.000Z'
                        next_retry_at: '2026-10-13T09:10:00.000Z'
                        status_reason: Destination returned 503
                        created_at: '2026-10-13T09:00:05.000Z'
                    pagination:
                      limit: 50
                      has_more: false
                      next_cursor: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/businesses/{businessId}/webhook-endpoints/{endpointId}/deliveries/{deliveryId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/EndpointId'
      - $ref: '#/components/parameters/DeliveryId'
    get:
      operationId: getWebhookEndpointDelivery
      tags:
        - Webhooks
      summary: Retrieve a delivery record
      description: |
        Single delivery inspection record. Safe diagnostics only — no secrets or
        internal infrastructure details.
      x-required-permissions:
        - webhook.event.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Delivery detail
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDelivery'
              examples:
                succeeded:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-111111111111
                    event_id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
                    endpoint_id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                    status: succeeded
                    attempt_count: 1
                    last_http_status: 200
                    last_attempted_at: '2026-10-13T09:00:06.000Z'
                    next_retry_at: null
                    status_reason: null
                    created_at: '2026-10-13T09:00:05.000Z'
                exhausted:
                  summary: Retry window exhausted
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-222222222222
                    event_id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
                    endpoint_id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
                    status: failed_exhausted
                    attempt_count: 12
                    last_http_status: 500
                    last_attempted_at: '2026-10-14T08:55:00.000Z'
                    next_retry_at: null
                    status_reason: Retry window exhausted
                    created_at: '2026-10-13T09:00:05.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/businesses/{businessId}/webhook-events:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
    get:
      operationId: listWebhookEvents
      tags:
        - Webhooks
      summary: List webhook events
      description: |
        Business-scoped event history (ADR-021 WH-07). At-least-once delivery may
        produce duplicate deliveries; consumers deduplicate by event `id`. No
        ordering guarantee. Detailed typed `data` schemas are AsyncAPI/Phase 5C.
      x-required-permissions:
        - webhook.event.read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/RequestId'
        - name: type
          in: query
          required: false
          schema:
            $ref: '#/components/schemas/WebhookEventType'
      responses:
        '200':
          description: Event page
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEventList'
              examples:
                page:
                  value:
                    data:
                      - id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
                        type: sms.message.delivered.v1
                        created_at: '2026-10-13T09:00:05.000Z'
                        business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                        api_version: v1
                        data:
                          message_id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
                    pagination:
                      limit: 50
                      has_more: false
                      next_cursor: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /v1/businesses/{businessId}/webhook-events/{eventId}:
    parameters:
      - $ref: '#/components/parameters/BusinessId'
      - $ref: '#/components/parameters/EventId'
    get:
      operationId: getWebhookEvent
      tags:
        - Webhooks
      summary: Retrieve a webhook event
      description: |
        Single event envelope. Does not expose delivery secrets or internal routing.
      x-required-permissions:
        - webhook.event.read
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Event detail
          headers:
            X-Request-Id:
              $ref: '#/components/headers/RequestIdResponse'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEventEnvelope'
              examples:
                delivered:
                  value:
                    id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
                    type: sms.message.delivered.v1
                    created_at: '2026-10-13T09:00:05.000Z'
                    business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
                    api_version: v1
                    data:
                      message_id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  securitySchemes:
    OAuth2ClientCredentials:
      type: oauth2
      description: |
        OAuth 2.0 Client Credentials for the External Integration API (ADR-015, ADR-023).

        Client authentication: HTTP Basic (`client_secret_basic`).
        Access-token audience: `1funnel-external-api`.

        The `tokenUrl` below is the **production** target. Sandbox uses a different
        host. OpenAPI cannot bind `tokenUrl` to the selected `servers` entry; see
        `x-1funnel-oauth` on the root document and the authentication guide when published.

        Coarse OAuth scopes (if any) are token-level vocabulary (ADR-013 IA-06). They
        are **not** the ADR-022 resource permission catalogue. Resource permissions
        are enforced after token validation via application-to-business grants
        (ADR-016) and are documented per operation with `x-required-permissions`.

        These URLs are architecture targets. They are not claimed to be deployed or
        generally available.
      flows:
        clientCredentials:
          tokenUrl: https://auth.1-funnel.org/oauth2/token
          scopes: {}
      x-1funnel-client-authentication: client_secret_basic
      x-1funnel-audience: 1funnel-external-api
  parameters:
    BusinessId:
      name: businessId
      in: path
      required: true
      description: |
        Business resource selector in `/v1/businesses/{businessId}/…` (ADR-006).
        UUIDv7-compatible opaque identifier. `{businessId}` is **not** authorization
        proof. The resource server must independently verify an active
        application-to-business grant, required permission, and ownership (ADR-016,
        ADR-022).
      schema:
        $ref: '#/components/schemas/UuidV7'
      examples:
        grantedBusiness:
          summary: Example business id
          value: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
    Limit:
      name: limit
      in: query
      required: false
      description: |
        Maximum number of items to return (STD-API-01 D05). Default is 50 when omitted.
        Values greater than 200 must be rejected with 400.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
        examples:
          - 50
    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor from a previous response `pagination.next_cursor`
        (STD-API-01 D05). Clients must not parse the cursor. Changing filter or sort
        parameters invalidates a previous cursor. Cursor lifetime is not set by this
        foundation and must be documented per capability when applicable.
      schema:
        type: string
        minLength: 1
        examples:
          - eyJvZmZzZXQiOiJleGFtcGxlIn0
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Client-supplied idempotency key for designated state-changing operations
        (STD-API-01 D09, ADR-017 SECOP-05/SECOP-06, DEC-SMS-I01 / shared external model).

        Scope: `(application_id, business_id, operation, idempotency_key)`.

        ## Permanent uniqueness registry

        Keys are permanently unique within the scope. Archiving the original resource
        does not release the key. Protection must survive application restarts and
        relevant data-lifecycle operations. The public contract does not prescribe
        the persistence technology.

        ## Seven-day response replay

        Within seven days of the original durable acceptance:

        - Same key + equivalent payload → original accepted response (and status).
        - Same key + different payload → `409` with `code: idempotency_conflict`.

        After seven days, the original response need not be retained for replay.
        Reusing an existing key after seven days **must never** create a new
        operation. Where the original resource reference remains available, the API
        may return a documented reference to it; otherwise return a documented
        conflict (`409` / `idempotency_conflict`).

        ## Concurrent and failed-before-acceptance

        Concurrent same-key requests yield one committed outcome. In-flight
        duplicates must not double-accept. Failed-before-acceptance retries with the
        same key and equivalent payload may proceed to a single durable acceptance.
        Successful async acceptance must not be returned before durable operation
        acceptance (ADR-017).

        Do not mark this header required on every operation. Capability contracts set
        `required: true` on designated creates/async admissions only.
      schema:
        type: string
        minLength: 1
        maxLength: 256
        examples:
          - idem_01HZX3K9EXAMPLE
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Required for this designated state-changing operation (STD-API-01 D09,
        ADR-017 SECOP-05/06, DEC-SMS-I01 shared external idempotency model).

        Scope: `(application_id, business_id, operation, idempotency_key)`.

        Permanent uniqueness within scope; seven-day response replay for equivalent
        payloads; `409` `idempotency_conflict` on payload mismatch; reuse after
        replay expiry must never create a new operation. See the optional
        `Idempotency-Key` component description for concurrent and
        failed-before-acceptance rules.
      schema:
        type: string
        minLength: 1
        maxLength: 256
        examples:
          - idem_01HZX3K9EXAMPLE
    RequestId:
      name: X-Request-Id
      in: header
      required: false
      description: |
        Request correlation identifier (STD-API-01 D12). Clients may supply a value
        of 1–128 characters matching `A-Za-z0-9._-`. Missing or invalid values are
        replaced by a server-generated identifier. Distinct from W3C `traceparent`,
        resource ids, `Idempotency-Key`, and webhook event ids.
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: ^[A-Za-z0-9._-]+$
        examples:
          - req_01HZX3K9EXAMPLE
    MessageId:
      name: messageId
      in: path
      required: true
      description: Individual SMS message identifier (UUIDv7). Not authorization proof.
      schema:
        $ref: '#/components/schemas/UuidV7'
    BulkMessageId:
      name: bulkMessageId
      in: path
      required: true
      description: Bulk SMS message identifier (UUIDv7). Not authorization proof.
      schema:
        $ref: '#/components/schemas/UuidV7'
    TemplateId:
      name: templateId
      in: path
      required: true
      description: SMS template identifier (UUIDv7). Not authorization proof.
      schema:
        $ref: '#/components/schemas/UuidV7'
    ChallengeId:
      name: challengeId
      in: path
      required: true
      description: OTP challenge identifier (UUIDv7).
      schema:
        $ref: '#/components/schemas/UuidV7'
    SmsLimit:
      name: limit
      in: query
      required: false
      description: |
        Maximum items to return for **SMS collection endpoints only** (DEC-SMS-F01).
        Default `100`. Maximum `1000`. Values above the maximum are rejected with
        `400` / `validation_failed`.

        ## Explicit SMS-specific exception

        STD-API-01 D05 retains default `50` / maximum `200` on the shared `Limit`
        parameter. The SMS defaults `100` / `1000` are an **explicitly approved
        SMS-only exception**. Do not apply `SmsLimit` to wallet, OTP, webhook, or
        other non-SMS collections. Do not change shared `Limit` or STD-API-01 here.
      schema:
        type: integer
        minimum: 1
        maximum: 1000
        default: 100
        examples:
          - 100
    SmsCursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor (DEC-SMS-C01). Lifetime is **24 hours**. Bound to
        business, resource, filters, and default ordering. Clients must not parse
        the cursor. Expired or invalid cursors return `400` / `validation_failed`.
        Cursor lifetime does not imply snapshot isolation of mutable delivery
        outcomes.
      schema:
        type: string
        minLength: 1
        examples:
          - eyJvZmZzZXQiOiJleGFtcGxlIn0
    EndpointId:
      name: endpointId
      in: path
      required: true
      description: Webhook endpoint identifier (UUIDv7).
      schema:
        $ref: '#/components/schemas/UuidV7'
    EventId:
      name: eventId
      in: path
      required: true
      description: Webhook event identifier (UUIDv7). Distinct from delivery and attempt ids.
      schema:
        $ref: '#/components/schemas/UuidV7'
    DeliveryId:
      name: deliveryId
      in: path
      required: true
      description: Webhook delivery identifier (UUIDv7). Distinct from event and attempt ids.
      schema:
        $ref: '#/components/schemas/UuidV7'
  headers:
    X-Request-Id:
      $ref: '#/components/headers/RequestIdResponse'
    Location:
      description: |
        URI of the created or accepted resource when a `Location` header applies
        (typically 201 or selected 202 responses). Capability contracts define when
        `Location` is present.
      schema:
        type: string
        format: uri-reference
    Retry-After:
      $ref: '#/components/headers/RetryAfter'
    Cache-Control:
      $ref: '#/components/headers/CacheControl'
    RequestIdResponse:
      description: |
        Correlation identifier for this response (STD-API-01 D12). Echoes a valid
        client `X-Request-Id` or a server-generated value.
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: ^[A-Za-z0-9._-]+$
        examples:
          - req_01HZX3K9EXAMPLE
    RetryAfter:
      description: |
        Hint for when the client may retry (for example 429 or 503). Value may be
        delay-seconds or an HTTP-date per RFC 9110.
      schema:
        type: string
        examples:
          - '60'
    CacheControl:
      description: |
        Cache directives. Live-sensitive reads (for example wallet balance) should
        use `no-store` or equivalent; exact guidance is capability-specific.
      schema:
        type: string
        examples:
          - no-store
  schemas:
    Uuid:
      type: string
      format: uuid
      description: |
        Opaque public resource identifier (STD-API-01 D04). Clients must treat UUID
        values as opaque and must not assume lexicographic sort or shard semantics.
        Knowledge of an identifier does not authorize access.
      examples:
        - 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
    UuidV7:
      type: string
      format: uuid
      pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-7[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$
      description: |
        UUID with version nibble `7` (UUIDv7). STD-API-01 prefers UUIDv7 for new
        platform-generated external resource identifiers. Clients must still treat
        the value as opaque. Not every path parameter is guaranteed to be v7 solely
        because it is a UUID; use this schema where the contract asserts UUIDv7.
      examples:
        - 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
    Timestamp:
      type: string
      format: date-time
      description: |
        RFC 3339 date-time. Response timestamps are UTC with a `Z` offset
        (STD-API-01 D06). Client-supplied timestamps must include an explicit offset;
        local times without an offset are rejected.
      examples:
        - '2026-10-13T09:00:00.000Z'
    Date:
      type: string
      format: date
      pattern: ^\d{4}-\d{2}-\d{2}$
      description: |
        Calendar date `YYYY-MM-DD` when a date-only value is the correct type
        (STD-API-01 D06).
      examples:
        - '2026-10-13'
    CurrencyCode:
      type: string
      pattern: ^[A-Z]{3}$
      minLength: 3
      maxLength: 3
      description: |
        ISO 4217 alphabetic currency code (STD-API-01 D07).
      examples:
        - NGN
        - USD
    MoneyDecimal:
      type: string
      pattern: ^-?(?:0|[1-9]\d*)(?:\.\d+)?$
      description: |
        Monetary amount as a decimal string (STD-API-01 D07). JSON numbers must not
        represent money. Scale and rounding are capability-specific and are stated on
        each monetary field when authored. This schema does not impose a universal
        two-decimal rule.
      examples:
        - '15000.00'
        - '0.00'
        - '1.5'
    ProblemDetails:
      type: object
      description: |
        RFC 9457 Problem Details for HTTP APIs (`application/problem+json`).
        STD-API-01 D08 requires `type`, `title`, `status`, `detail`, `code`, and
        `request_id`. `instance` is recommended. Validation failures include `errors`.

        Problem `type` namespace (DEC-SMS-E01):
        `https://developers.1-funnel.org/problems/{code}`
        These identifiers do not imply documentation pages are deployed. Resolvable
        problem-type documentation is required before GA.
      additionalProperties: true
      required:
        - type
        - title
        - status
        - detail
        - code
        - request_id
      properties:
        type:
          type: string
          format: uri-reference
          description: Problem class identifier using the developers.1-funnel.org namespace.
          examples:
            - https://developers.1-funnel.org/problems/validation_failed
        title:
          type: string
          description: Short, stable summary safe for clients.
          examples:
            - Validation failed
        status:
          type: integer
          minimum: 100
          maximum: 599
          description: HTTP status code for this occurrence.
          examples:
            - 400
        detail:
          type: string
          description: Occurrence-specific explanation. Must not include secrets or stack traces.
          examples:
            - The request failed validation.
        instance:
          type: string
          format: uri-reference
          description: URI reference for this occurrence (RFC 9457 SHOULD).
        code:
          $ref: '#/components/schemas/ProblemCode'
        request_id:
          type: string
          minLength: 1
          maxLength: 128
          pattern: ^[A-Za-z0-9._-]+$
          description: Same value as response header `X-Request-Id` (STD-API-01 MUST).
          examples:
            - req_01HZX3K9EXAMPLE
        errors:
          type: array
          description: Present for validation failures (STD-API-01). Each item has `pointer` and `code`.
          items:
            type: object
            additionalProperties: false
            required:
              - pointer
              - code
            properties:
              pointer:
                type: string
                description: JSON Pointer into the request body, or a query parameter name.
              code:
                type: string
                description: Stable field-level error code.
      examples:
        - type: https://developers.1-funnel.org/problems/validation_failed
          title: Validation failed
          status: 400
          detail: The request failed validation.
          instance: /v1/businesses/018f3c4e-7b2a-7c3d-8e9f-0123456789ab/sms/messages
          code: validation_failed
          request_id: req_01HZX3K9EXAMPLE
          errors:
            - pointer: /to
              code: field_invalid
    Pagination:
      type: object
      description: |
        Cursor pagination metadata for collection responses (STD-API-01 D05).
        Field names are normative: `limit`, `has_more`, `next_cursor`.
      additionalProperties: false
      required:
        - limit
        - has_more
        - next_cursor
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 200
          description: Page size applied to this response (default request `limit` is 50).
          examples:
            - 50
        has_more:
          type: boolean
          description: Whether another page is available.
        next_cursor:
          type:
            - string
            - 'null'
          description: |
            Opaque cursor for the next page, or `null` when `has_more` is false.
            Clients must not parse the cursor. Changing filter or sort parameters
            invalidates a previous cursor (400). Cursor lifetime is capability-specific
            and is not set by this foundation.
      examples:
        - limit: 50
          has_more: false
          next_cursor: null
    CollectionEnvelope:
      type: object
      description: |
        Collection response envelope (STD-API-01 D05). `data` is the collection, not
        a universal success wrapper. Single-resource responses must not use this envelope.
      additionalProperties: false
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          description: Page of resources. Item schemas are capability-specific.
          items: {}
        pagination:
          $ref: '#/components/schemas/Pagination'
    Permission:
      description: |
        Approved External Integration API v1 resource permissions (ADR-022).
        Possession of a permission is never sufficient authorization without a valid
        external access token, active application identity, active application-to-business
        grant, and resource ownership checks (ADR-016).
      type: string
      enum:
        - sms.message.send
        - sms.message.read
        - sms.bulk_message.create
        - sms.bulk_message.read
        - sms.template.read
        - sms.template.write
        - wallet.balance.read
        - otp.challenge.create
        - otp.challenge.verify
        - otp.challenge.read
        - webhook.endpoint.read
        - webhook.endpoint.write
        - webhook.event.read
      x-1funnel-permission-catalogue:
        - code: sms.message.send
          purpose: Individual SMS submission
        - code: sms.message.read
          purpose: Individual SMS retrieval and message history
        - code: sms.bulk_message.create
          purpose: Bulk SMS submission
        - code: sms.bulk_message.read
          purpose: Bulk SMS retrieval and reporting
        - code: sms.template.read
          purpose: SMS template retrieval
        - code: sms.template.write
          purpose: SMS template management
        - code: wallet.balance.read
          purpose: Wallet balance retrieval
        - code: otp.challenge.create
          purpose: OTP challenge creation and resend
        - code: otp.challenge.verify
          purpose: OTP verification and invalidation
        - code: otp.challenge.read
          purpose: OTP challenge status retrieval
        - code: webhook.endpoint.read
          purpose: Webhook endpoint retrieval
        - code: webhook.endpoint.write
          purpose: Webhook endpoint and subscription management
        - code: webhook.event.read
          purpose: Webhook event and delivery history
    E164PhoneNumber:
      type: string
      description: |
        Recipient MSISDN in E.164 format (`+` followed by country code and subscriber
        number). Leading `+` required.
      pattern: ^\+[1-9]\d{7,14}$
      minLength: 9
      maxLength: 16
      examples:
        - '+2348012345678'
    SenderId:
      type: string
      description: |
        Authorized sender identity for the business (`from`). The backend validates
        that the application/business may use this sender. Arbitrary sender IDs are
        not permitted. Public sender-ID management resources remain future
        (ADR-018 SMS-19 / target architecture).
      minLength: 1
      maxLength: 20
      examples:
        - 1FUNNEL
    MessageClassification:
      type: string
      description: |
        Public message classification (lowercase snake_case). Transactional vs
        promotional distinction for compliance and routing policy.
      enum:
        - transactional
        - promotional
      examples:
        - transactional
    MessageStatus:
      type: string
      description: |
        Merchant-visible individual message status (ADR-018 SMS-12). Display
        projection — not bulk processing status and not raw provider codes.
      enum:
        - queued
        - sent
        - delivered
        - failed
      examples:
        - delivered
    BulkProcessingStatus:
      type: string
      description: |
        Bulk-message **processing** lifecycle (ADR-018 SMS-13 / ADR-003 Model B).
        `completed` means the primary processing pass finished — not all delivered
        and not all retries settled.
      enum:
        - created
        - scheduled
        - queued
        - processing
        - paused
        - completed
        - failed
        - cancelled
      examples:
        - processing
    TemplateLifecycleStatus:
      type: string
      description: |
        Public SMS template lifecycle (ADR-018 SMS-08). Lowercase snake_case.
      enum:
        - draft
        - active
        - suspended
        - archived
      examples:
        - active
    TemplateVariables:
      type: object
      description: |
        Per-recipient or per-send template variables. Keys must match placeholders
        defined by the referenced template revision. Values are strings. Live OTP
        codes must not be supplied as template variables for the managed OTP product
        (ADR-008 / ADR-018 SMS-09).
      additionalProperties:
        type: string
        maxLength: 512
      examples:
        - first_name: Ada
          order_id: ORD-1001
    TemplateRef:
      type: object
      description: |
        Template reference for SMS submission (DEC-SMS-T02). Clients supply only
        `template_id`. The backend resolves the current **active** revision at
        durable acceptance and pins that revision. Explicit historical
        `revision_id` selection is not supported in v1.
      additionalProperties: false
      required:
        - template_id
      properties:
        template_id:
          $ref: '#/components/schemas/UuidV7'
      examples:
        - template_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
    TemplateRevisionPin:
      type: object
      description: |
        Immutable revision pin recorded on an accepted SMS (DEC-SMS-T02 / ADR-018
        SMS-08). Present on accepted message representations after the backend
        resolves the active revision at durable acceptance.
      additionalProperties: false
      required:
        - template_id
        - revision_id
      properties:
        template_id:
          $ref: '#/components/schemas/UuidV7'
        revision_id:
          $ref: '#/components/schemas/UuidV7'
      examples:
        - template_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          revision_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789cd
    ProblemCode:
      type: string
      description: |
        Stable machine-readable Problem Details `code` values for the External
        Integration API. Type URI form:
        `https://developers.1-funnel.org/problems/{code}`.
        Resolvable problem-type documentation is required before GA; these
        identifiers do not imply pages are deployed today.
      enum:
        - validation_failed
        - authentication_required
        - permission_denied
        - resource_not_found
        - idempotency_conflict
        - recipient_limit_exceeded
        - sender_not_authorized
        - template_not_active
        - invalid_recipient
        - rate_limit_exceeded
        - service_unavailable
        - internal_error
        - balance_unavailable
        - otp_verification_failed
        - otp_challenge_expired
        - otp_challenge_locked
        - otp_challenge_consumed
        - otp_challenge_invalidated
        - otp_resend_cooldown
        - otp_resend_limit_exceeded
      examples:
        - validation_failed
        - otp_verification_failed
    DeliverySummary:
      type: object
      description: |
        Delivery-lifecycle projection (ADR-018 SMS-14a). Not intake, not submission
        processing, and not `settled`. When authoritative delivery data is unavailable,
        omit this field or set it to `null` with a warning — do not fabricate zeros.
      additionalProperties: false
      required:
        - as_of
        - complete
        - outcomes_resolved
      properties:
        as_of:
          $ref: '#/components/schemas/Timestamp'
        complete:
          type: boolean
          description: |
            Authoritative reporting completeness for delivery outcomes. `true` only
            when the platform can assert delivery reporting is complete for the scope.
        outcomes_resolved:
          type: integer
          minimum: 0
          description: |
            Count of recipients with resolved delivery outcomes in the authoritative
            delivery store. Must not be fabricated as `0` when the source is unavailable.
      examples:
        - as_of: '2026-10-13T09:00:00.000Z'
          complete: false
          outcomes_resolved: 850
    ContractWarning:
      type: object
      description: |
        Non-fatal contract warning used for null-and-warning / stale-projection
        signalling (STD-API-01 representation rules; ADR-018 SMS-14a).
      additionalProperties: false
      required:
        - code
        - detail
      properties:
        code:
          type: string
          examples:
            - delivery_summary_unavailable
            - delivery_summary_stale
        detail:
          type: string
          examples:
            - Authoritative delivery reporting is temporarily unavailable.
    IntakeCounts:
      type: object
      description: |
        Intake classification counts (ADR-018 SMS-14). Omit this object when
        authoritative intake totals are unavailable — do not fabricate zeros.
      additionalProperties: false
      properties:
        good:
          type: integer
          minimum: 0
        bad:
          type: integer
          minimum: 0
        empty:
          type: integer
          minimum: 0
    SubmissionCounts:
      type: object
      description: |
        Submission/processing counts (ADR-018 SMS-14). Not delivery outcomes. Omit
        when unavailable — do not fabricate zeros. Do not present `success` as delivery.
      additionalProperties: false
      properties:
        processed:
          type: integer
          minimum: 0
        success:
          type: integer
          minimum: 0
          description: Provider submission successes — not delivery successes.
        failed:
          type: integer
          minimum: 0
        retryable_failed_count:
          type: integer
          minimum: 0
          description: |
            Outstanding submission retries. Settlement uses
            `status == completed && retryable_failed_count == 0` (ADR-003 / ADR-018).
        exhausted:
          type: integer
          minimum: 0
    SmsMessageCreateRequest:
      type: object
      description: |
        Individual SMS submission (ADR-018 SMS-01 / SMS-09, DEC-SMS-N01 / T02).
        Provide exactly one of free-text `message` or `template_id`. Do not supply
        both. Clients must not supply authoritative segmentation or billing values.
        Template submissions resolve and pin the current active revision at durable
        acceptance; client-selected historical revisions are rejected.
      additionalProperties: false
      required:
        - from
        - to
        - classification
      properties:
        from:
          $ref: '#/components/schemas/SenderId'
          description: |
            Authorized sender identity. Backend validates authorization.
            Invalid sender formatting → `validation_failed`. Unauthorized sender →
            `sender_not_authorized` (403).
        to:
          $ref: '#/components/schemas/E164PhoneNumber'
        classification:
          $ref: '#/components/schemas/MessageClassification'
        message:
          type: string
          description: |
            Unicode direct message content (DEC-SMS-N01). Required when `template_id`
            is omitted. Segmentation is determined by the backend.
          minLength: 1
          maxLength: 4096
        template_id:
          $ref: '#/components/schemas/UuidV7'
          description: |
            Business-owned template. Active revision resolved and pinned at acceptance
            (DEC-SMS-T02). Archived or inactive templates → `template_not_active`.
        template_variables:
          $ref: '#/components/schemas/TemplateVariables'
      examples:
        - from: 1FUNNEL
          to: '+2348012345678'
          classification: transactional
          message: Your order ORD-1001 is confirmed.
        - from: 1FUNNEL
          to: '+2348012345678'
          classification: transactional
          template_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          template_variables:
            first_name: Ada
            order_id: ORD-1001
    SmsMessage:
      type: object
      description: |
        Individual SMS resource. Acceptance is not delivery confirmation.
        Status is the merchant-visible display projection (ADR-018 SMS-12).
      additionalProperties: false
      required:
        - id
        - business_id
        - from
        - to
        - classification
        - status
        - accepted_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        from:
          $ref: '#/components/schemas/SenderId'
        to:
          $ref: '#/components/schemas/E164PhoneNumber'
        classification:
          $ref: '#/components/schemas/MessageClassification'
        message:
          type: string
          description: Effective message content after any template rendering at acceptance.
        status:
          $ref: '#/components/schemas/MessageStatus'
        status_reason:
          type:
            - string
            - 'null'
          description: Optional finer failure or state detail. Not a provider-native code.
        template:
          oneOf:
            - $ref: '#/components/schemas/TemplateRevisionPin'
            - type: 'null'
          description: Revision pinned at durable acceptance when template-based.
        accepted_at:
          $ref: '#/components/schemas/Timestamp'
          description: Durable acceptance timestamp (not provider submission or delivery).
        submitted_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
          description: Provider submission timestamp when known.
        delivered_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
          description: Delivery outcome timestamp when known.
        failed_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          from: 1FUNNEL
          to: '+2348012345678'
          classification: transactional
          message: Your order ORD-1001 is confirmed.
          status: delivered
          status_reason: null
          template: null
          accepted_at: '2026-10-13T09:00:00.000Z'
          submitted_at: '2026-10-13T09:00:01.000Z'
          delivered_at: '2026-10-13T09:00:05.000Z'
          failed_at: null
    SmsMessageList:
      type: object
      description: Cursor-paginated list of individual SMS messages (STD-API-01 D05).
      additionalProperties: false
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SmsMessage'
        pagination:
          $ref: '#/components/schemas/Pagination'
    BulkRecipientInput:
      type: object
      description: |
        Inline bulk recipient row for public bulk submission. ADR-005 merchant CSV
        portal ingestion is not this contract (ADR-018 SMS-10).
      additionalProperties: false
      required:
        - to
      properties:
        to:
          $ref: '#/components/schemas/E164PhoneNumber'
        template_variables:
          $ref: '#/components/schemas/TemplateVariables'
    SmsBulkMessageCreateRequest:
      type: object
      description: |
        Bulk SMS submission (ADR-018 SMS-04 / SMS-10, DEC-SMS-B01 / B02 / N01 / T02).
        Public v1 uses an **inline** `recipients` array. File-based ingestion is not
        in v1 (deferred to a future additive capability). Provide exactly one of
        `message` or `template_id`.

        `recipients` length counts **all submitted entries**, including duplicates
        and invalid numbers. Default maximum is **1000**. Exceeding the effective
        limit rejects the **entire** request with `422` / `recipient_limit_exceeded`
        before durable acceptance, dispatch, or billing. No partial acceptance.
      additionalProperties: false
      required:
        - from
        - classification
        - recipients
      properties:
        from:
          $ref: '#/components/schemas/SenderId'
        classification:
          $ref: '#/components/schemas/MessageClassification'
        message:
          type: string
          description: Shared Unicode content when not using a template.
          minLength: 1
          maxLength: 4096
        template_id:
          $ref: '#/components/schemas/UuidV7'
          description: Active revision resolved and pinned at durable acceptance.
        recipients:
          type: array
          minItems: 1
          maxItems: 1000
          description: |
            Inline recipients. `maxItems` reflects the default maximum (DEC-SMS-B02).
            Business-specific higher quotas are planned but not available initially
            and do not change this request schema.
          items:
            $ref: '#/components/schemas/BulkRecipientInput'
      examples:
        - from: 1FUNNEL
          classification: promotional
          message: Weekend offer for {{first_name}}.
          recipients:
            - to: '+2348012345678'
              template_variables:
                first_name: Ada
            - to: '+2348098765432'
              template_variables:
                first_name: Chidi
    SmsBulkMessage:
      type: object
      description: |
        Bulk SMS resource. Processing status is distinct from recipient delivery
        outcomes (ADR-018 SMS-11–SMS-15). `settled` means
        `status == completed && retryable_failed_count == 0` when authoritative
        submission counters are available — not delivery completion.
      additionalProperties: false
      required:
        - id
        - business_id
        - from
        - classification
        - status
        - settled
        - accepted_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        from:
          $ref: '#/components/schemas/SenderId'
        classification:
          $ref: '#/components/schemas/MessageClassification'
        status:
          $ref: '#/components/schemas/BulkProcessingStatus'
        settled:
          type:
            - boolean
            - 'null'
          description: |
            Derived when authoritative `retryable_failed_count` is available:
            `status == completed && retryable_failed_count == 0`. Null when settlement
            cannot be computed. Independent of `delivery_summary`.
        message:
          type:
            - string
            - 'null'
        template:
          oneOf:
            - $ref: '#/components/schemas/TemplateRevisionPin'
            - type: 'null'
        accepted_at:
          $ref: '#/components/schemas/Timestamp'
        intake:
          oneOf:
            - $ref: '#/components/schemas/IntakeCounts'
            - type: 'null'
        submission:
          oneOf:
            - $ref: '#/components/schemas/SubmissionCounts'
            - type: 'null'
        delivery_summary:
          description: |
            Null when authoritative delivery reporting is unavailable, with a
            machine-readable `warnings` entry. Do not fabricate zero counts. Do not
            treat unavailable reporting as delivery failure.
          oneOf:
            - $ref: '#/components/schemas/DeliverySummary'
            - type: 'null'
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/ContractWarning'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-bbbbbbbbbbbb
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          from: 1FUNNEL
          classification: promotional
          status: completed
          settled: true
          message: Weekend offer
          template: null
          accepted_at: '2026-10-13T09:00:00.000Z'
          intake:
            good: 1000
            bad: 2
            empty: 0
          submission:
            processed: 1000
            success: 980
            failed: 20
            retryable_failed_count: 0
            exhausted: 20
          delivery_summary:
            as_of: '2026-10-13T09:00:00.000Z'
            complete: false
            outcomes_resolved: 850
          warnings: []
    SmsBulkMessageList:
      type: object
      additionalProperties: false
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SmsBulkMessage'
        pagination:
          $ref: '#/components/schemas/Pagination'
    RecipientSubmissionStatus:
      type: string
      description: |
        Recipient **submission** lifecycle projection for bulk reporting (distinct
        from merchant display and from delivery). Lowercase public values mapped from
        ADR-003 submission outcomes.
      enum:
        - pending_submission
        - retry_pending
        - sent
        - exhausted
        - permanent_failure
      examples:
        - sent
    SmsBulkRecipient:
      type: object
      description: |
        Recipient-level row for a bulk message (ADR-018 SMS-06). Path is
        `/sms/bulk-messages/{bulkMessageId}/messages`. Submission and delivery must
        not be conflated. Does not expose provider credentials or internal routing.
      additionalProperties: false
      required:
        - id
        - to
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        to:
          $ref: '#/components/schemas/E164PhoneNumber'
        submission_status:
          oneOf:
            - $ref: '#/components/schemas/RecipientSubmissionStatus'
            - type: 'null'
        delivery_status:
          oneOf:
            - $ref: '#/components/schemas/MessageStatus'
            - type: 'null'
          description: Merchant-visible delivery projection when known.
        status_reason:
          type:
            - string
            - 'null'
        accepted_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        submitted_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        delivered_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        failed_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-cccccccccccc
          to: '+2348012345678'
          submission_status: sent
          delivery_status: delivered
          status_reason: null
          accepted_at: '2026-10-13T09:00:00.000Z'
          submitted_at: '2026-10-13T09:00:02.000Z'
          delivered_at: '2026-10-13T09:00:08.000Z'
          failed_at: null
    SmsBulkRecipientList:
      type: object
      additionalProperties: false
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SmsBulkRecipient'
        pagination:
          $ref: '#/components/schemas/Pagination'
    SmsTemplateCreateRequest:
      type: object
      description: Create a business-scoped SMS template (ADR-018 SMS-07/SMS-08).
      additionalProperties: false
      required:
        - name
        - message
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 128
        message:
          type: string
          description: Unicode template message content. Placeholder syntax is platform-defined.
          minLength: 1
          maxLength: 4096
        variable_keys:
          type: array
          description: Declared placeholder keys for this template.
          items:
            type: string
            minLength: 1
            maxLength: 64
            pattern: ^[a-z][a-z0-9_]*$
      examples:
        - name: order_confirmed
          message: Hi {{first_name}}, order {{order_id}} is confirmed.
          variable_keys:
            - first_name
            - order_id
    SmsTemplateUpdateRequest:
      type: object
      description: |
        Update template metadata or content. Content changes create a **new immutable
        revision**; prior revision IDs remain valid for historical sends (ADR-018 SMS-08).
        Lifecycle transitions use `status` (including `archived` for soft-delete).
      additionalProperties: false
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 128
        message:
          type: string
          minLength: 1
          maxLength: 4096
        variable_keys:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 64
            pattern: ^[a-z][a-z0-9_]*$
        status:
          $ref: '#/components/schemas/TemplateLifecycleStatus'
      examples:
        - message: Hello {{first_name}}, order {{order_id}} is confirmed.
          variable_keys:
            - first_name
            - order_id
        - status: archived
    SmsTemplate:
      type: object
      description: |
        SMS template with current revision pointer. Historical sends pin
        `current_revision_id` (or an older revision) immutably.
      additionalProperties: false
      required:
        - id
        - business_id
        - name
        - status
        - message
        - current_revision_id
        - created_at
        - updated_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        name:
          type: string
        status:
          $ref: '#/components/schemas/TemplateLifecycleStatus'
        message:
          type: string
          description: Message content of the current revision.
        variable_keys:
          type: array
          items:
            type: string
        current_revision_id:
          $ref: '#/components/schemas/UuidV7'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          name: order_confirmed
          status: active
          message: Hi {{first_name}}, order {{order_id}} is confirmed.
          variable_keys:
            - first_name
            - order_id
          current_revision_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789cd
          created_at: '2026-10-13T08:00:00.000Z'
          updated_at: '2026-10-13T08:30:00.000Z'
    SmsTemplateList:
      type: object
      additionalProperties: false
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SmsTemplate'
        pagination:
          $ref: '#/components/schemas/Pagination'
    WalletBalance:
      type: object
      description: |
        Read-only wallet balance (ADR-019 WAL-01–WAL-05). `reserved_balance` is a v1
        compatibility field always `"0.00"` — not evidence of operational reservation
        accounting. Do not fabricate balances when the authoritative source is
        unavailable; return `503` / `balance_unavailable` (or `service_unavailable`).
      additionalProperties: false
      required:
        - business_id
        - business_name
        - currency
        - current_balance
        - available_balance
        - reserved_balance
        - as_of
      properties:
        business_id:
          $ref: '#/components/schemas/UuidV7'
        business_name:
          type: string
          minLength: 1
          maxLength: 256
        currency:
          $ref: '#/components/schemas/CurrencyCode'
        current_balance:
          $ref: '#/components/schemas/MoneyDecimal'
          description: Authoritative wallet balance from the platform source of record.
        available_balance:
          $ref: '#/components/schemas/MoneyDecimal'
          description: Equals `current_balance` in v1 (compatibility contract).
        reserved_balance:
          allOf:
            - $ref: '#/components/schemas/MoneyDecimal'
          description: |
            Always `"0.00"` in v1. Reservations are not implemented. Must not be
            inferred or fabricated as nonzero.
          examples:
            - '0.00'
        as_of:
          $ref: '#/components/schemas/Timestamp'
          description: RFC 3339 UTC timestamp for this balance snapshot.
      examples:
        - business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          business_name: Example Business
          currency: NGN
          current_balance: '15000.00'
          available_balance: '15000.00'
          reserved_balance: '0.00'
          as_of: '2026-10-13T09:00:00.000Z'
    OtpChannel:
      type: string
      description: Delivery channel. SMS is the initial approved channel (ADR-020 / ADR-008).
      enum:
        - sms
      examples:
        - sms
    OtpPurpose:
      type: string
      description: |
        Challenge purpose (lowercase snake_case public enum; ADR-020 OTP-A01).
        Exact allowed set is policy-controlled; values below are the initial catalogue.
      enum:
        - login
        - register
        - password
        - transfer
        - merchant_custom
      examples:
        - login
    OtpDeliveryMode:
      type: string
      description: How the OTP message content was selected at create time.
      enum:
        - template
        - inline
      examples:
        - inline
    OtpChallengeStatus:
      type: string
      description: |
        Publicly safe challenge lifecycle status (lowercase snake_case). Distinct
        from SMS delivery lifecycle (ADR-020 OTP-A05). Never implies code exposure.
      enum:
        - queued
        - sent
        - verified
        - expired
        - locked
        - invalidated
        - failed_delivery
      examples:
        - queued
        - verified
    OtpChallengeCreateRequest:
      type: object
      description: |
        Create an OTP challenge and enqueue delivery (ADR-020 / ADR-008). Provide
        either inline `message` (must include `{{code}}`) or rely on `template_key` /
        default binding — not both. Successful acceptance does not mean the SMS was
        delivered.
      additionalProperties: false
      required:
        - purpose
        - channel
        - destination
      properties:
        purpose:
          $ref: '#/components/schemas/OtpPurpose'
        channel:
          $ref: '#/components/schemas/OtpChannel'
        destination:
          $ref: '#/components/schemas/E164PhoneNumber'
          description: E.164 destination for SMS channel.
        locale:
          type: string
          minLength: 2
          maxLength: 16
          examples:
            - en
        template_key:
          type: string
          minLength: 1
          maxLength: 128
          description: Optional approved template key when not using inline message.
        message:
          type: string
          minLength: 1
          maxLength: 160
          description: |
            Inline pattern containing `{{code}}`. Expiry wall-clock placeholders are
            not supported in message copy (otp-design).
        metadata:
          type: object
          additionalProperties:
            type: string
          description: Optional non-secret client metadata (string values only).
      examples:
        - purpose: login
          channel: sms
          destination: '+2348012345678'
          message: 'Acme: your login code is {{code}}. Use it to continue.'
        - purpose: login
          channel: sms
          destination: '+2348012345678'
          locale: en
          template_key: login-sms-en-default
    OtpChallenge:
      type: object
      description: |
        Public OTP challenge representation. Never includes plaintext codes, hashes,
        Redis keys, or rendered message bodies (ADR-020 / ADR-008).
      additionalProperties: false
      required:
        - id
        - business_id
        - status
        - purpose
        - channel
        - code_length
        - expires_at
        - created_at
        - updated_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          $ref: '#/components/schemas/OtpChallengeStatus'
        purpose:
          $ref: '#/components/schemas/OtpPurpose'
        channel:
          $ref: '#/components/schemas/OtpChannel'
        delivery_mode:
          $ref: '#/components/schemas/OtpDeliveryMode'
        code_length:
          type: integer
          minimum: 4
          maximum: 12
        expires_at:
          $ref: '#/components/schemas/Timestamp'
        resend_available_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        remaining_resends:
          type:
            - integer
            - 'null'
          minimum: 0
        attempt_count:
          type:
            - integer
            - 'null'
          minimum: 0
        max_verification_attempts:
          type:
            - integer
            - 'null'
          minimum: 1
        verified_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        invalidated_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          status: queued
          purpose: login
          channel: sms
          delivery_mode: inline
          code_length: 6
          expires_at: '2026-10-13T09:05:00.000Z'
          resend_available_at: '2026-10-13T09:01:00.000Z'
          remaining_resends: 4
          attempt_count: 0
          max_verification_attempts: 5
          verified_at: null
          invalidated_at: null
          created_at: '2026-10-13T09:00:00.000Z'
          updated_at: '2026-10-13T09:00:00.000Z'
    OtpVerifyRequest:
      type: object
      description: |
        Verify an OTP challenge (single-use, atomic). Does not return or echo the
        code in responses.
      additionalProperties: false
      required:
        - code
      properties:
        code:
          type: string
          minLength: 4
          maxLength: 12
          pattern: ^[0-9A-Za-z]+$
          description: Caller-supplied OTP code. Never logged by the API contract.
      examples:
        - code: '483920'
    OtpVerifyResponse:
      type: object
      description: Successful single-use verification result.
      additionalProperties: false
      required:
        - id
        - status
        - purpose
        - verified_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        status:
          type: string
          enum:
            - verified
        purpose:
          $ref: '#/components/schemas/OtpPurpose'
        verified_at:
          $ref: '#/components/schemas/Timestamp'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-dddddddddddd
          status: verified
          purpose: login
          verified_at: '2026-10-13T09:01:12.000Z'
    OtpResendRequest:
      type: object
      description: |
        Optional resend body. Empty object reuses the original template/inline
        pattern. Optional `message` may override inline pattern when policy allows.
        Resend regenerates the code and refreshes expiry per otp-design / ADR-008.
      additionalProperties: false
      properties:
        message:
          type: string
          minLength: 1
          maxLength: 160
          description: Optional new inline pattern containing `{{code}}`.
      examples:
        - {}
        - message: 'Acme: your new login code is {{code}}. Use it to continue.'
    OtpInvalidateRequest:
      type: object
      description: Invalidate (cancel) an open challenge. Terminal after success.
      additionalProperties: false
      properties:
        reason:
          type: string
          minLength: 1
          maxLength: 64
          description: Optional machine-readable reason (e.g. user_cancelled).
          examples:
            - user_cancelled
      examples:
        - reason: user_cancelled
    WebhookEventType:
      type: string
      description: |
        Approved v1 outbound event types (ADR-021 WH-02). Wallet and payment events
        are deferred. Do not rename these values.
      enum:
        - sms.message.sent.v1
        - sms.message.delivered.v1
        - sms.message.failed.v1
        - sms.bulk-message.processing.v1
        - sms.bulk-message.completed.v1
        - sms.bulk-message.failed.v1
        - otp.challenge.verified.v1
        - otp.challenge.expired.v1
        - otp.challenge.locked.v1
      examples:
        - sms.message.delivered.v1
    WebhookEndpointStatus:
      type: string
      description: |
        Endpoint lifecycle in v1 (DEC-WH-V01 deferred ownership verification;
        DEC-WH-E01 soft-delete). Endpoints become `active` after successful creation
        and destination security validation. `disabled` is the deactivated state
        after DELETE or explicit disable. Ownership verification is a future
        capability — not a v1 prerequisite.
      enum:
        - active
        - disabled
      examples:
        - active
    WebhookDeliveryStatus:
      type: string
      description: |
        Approved public delivery statuses (DEC-WH-D01). Internal queue/worker states
        are not exposed.

        | Status | Meaning |
        |--------|---------|
        | pending | Delivery created; no attempt started |
        | in_progress | Delivery attempt currently executing |
        | succeeded | Destination acknowledged with HTTP 2xx (200–299). Does not guarantee application-level processing success. |
        | retry_scheduled | Previous attempt failed; another attempt is scheduled within the 24h retry window |
        | failed_exhausted | Retry policy or 24h retry window exhausted |
        | cancelled | Delivery intentionally terminated before success |
      enum:
        - pending
        - in_progress
        - succeeded
        - retry_scheduled
        - failed_exhausted
        - cancelled
      examples:
        - succeeded
        - retry_scheduled
    HttpsUrl:
      type: string
      format: uri
      pattern: ^https://
      minLength: 12
      maxLength: 2048
      description: |
        HTTPS destination URL (ADR-021 WH-05 / ADR-017 SECOP-09). Backend enforces
        SSRF controls at connection time (no loopback, link-local, private/internal
        ranges, unsafe redirects, DNS rebinding). Client-side pattern checks are not
        sufficient.
      examples:
        - https://hooks.example.com/1funnel/webhooks
    WebhookEndpointCreateRequest:
      type: object
      description: |
        Create a business-scoped webhook endpoint (ADR-021 WH-01/WH-06). Default
        maximum **five active** endpoints per business (platform-configurable; not
        customer-configurable in v1). Requires `Idempotency-Key`.
      additionalProperties: false
      required:
        - url
        - event_types
      properties:
        url:
          $ref: '#/components/schemas/HttpsUrl'
        description:
          type: string
          maxLength: 256
        event_types:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: '#/components/schemas/WebhookEventType'
          description: Explicit event subscriptions for this endpoint.
      examples:
        - url: https://hooks.example.com/1funnel/webhooks
          description: Production SMS delivery callbacks
          event_types:
            - sms.message.delivered.v1
            - sms.message.failed.v1
    WebhookEndpointUpdateRequest:
      type: object
      description: |
        Partial update (DEC-WH-U01 / DEC-WH-V01). Changing `url` requires destination
        security validation (HTTPS, TLS, SSRF controls) but **not** ownership
        reverification. Updating `description`, `event_types`, or `status` does not
        require ownership verification.
      additionalProperties: false
      minProperties: 1
      properties:
        url:
          $ref: '#/components/schemas/HttpsUrl'
        description:
          type:
            - string
            - 'null'
          maxLength: 256
        event_types:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            $ref: '#/components/schemas/WebhookEventType'
        status:
          type: string
          description: Clients may set `active` or `disabled` when eligible.
          enum:
            - active
            - disabled
      examples:
        - description: Updated callback destination label
        - url: https://hooks.example.com/1funnel/webhooks-v2
        - status: disabled
    WebhookEndpoint:
      type: object
      description: |
        Webhook endpoint resource. Never includes signing secrets on read
        (DEC-WH-R01). Ownership verification is deferred in v1 (DEC-WH-V01).
      additionalProperties: false
      required:
        - id
        - business_id
        - url
        - status
        - event_types
        - created_at
        - updated_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        url:
          $ref: '#/components/schemas/HttpsUrl'
        description:
          type:
            - string
            - 'null'
        status:
          $ref: '#/components/schemas/WebhookEndpointStatus'
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          url: https://hooks.example.com/1funnel/webhooks
          description: Production SMS delivery callbacks
          status: active
          event_types:
            - sms.message.delivered.v1
            - sms.message.failed.v1
          created_at: '2026-10-13T09:00:00.000Z'
          updated_at: '2026-10-13T09:00:00.000Z'
    WebhookEndpointCreated:
      type: object
      description: |
        Create response distinguishing the ordinary endpoint representation from the
        one-time `signing_secret` disclosure (DEC-WH-R01 / DEC-WH-C01). Clients must
        store the secret securely; it cannot be retrieved later via GET/LIST.
      additionalProperties: false
      required:
        - id
        - business_id
        - url
        - status
        - event_types
        - created_at
        - updated_at
        - signing_secret
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        url:
          $ref: '#/components/schemas/HttpsUrl'
        description:
          type:
            - string
            - 'null'
        status:
          $ref: '#/components/schemas/WebhookEndpointStatus'
          description: |
            `active` after successful destination security validation. Ownership
            verification is not required in v1.
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEventType'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        updated_at:
          $ref: '#/components/schemas/Timestamp'
        signing_secret:
          type: string
          minLength: 32
          maxLength: 256
          description: |
            Cryptographically secure endpoint-specific signing secret, revealed
            **once**. Never returned from GET/LIST. Never present in events or
            delivery inspection. Public rotation API is not in v1; internal
            rotation is an implementation dependency.
          examples:
            - whsec_example_not_a_real_secret_0123456789abcdef
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          url: https://hooks.example.com/1funnel/webhooks
          description: Production SMS delivery callbacks
          status: active
          event_types:
            - sms.message.delivered.v1
            - sms.message.failed.v1
          created_at: '2026-10-13T09:00:00.000Z'
          updated_at: '2026-10-13T09:00:00.000Z'
          signing_secret: whsec_example_not_a_real_secret_0123456789abcdef
    WebhookEndpointList:
      allOf:
        - $ref: '#/components/schemas/CollectionEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/WebhookEndpoint'
      description: Cursor-paginated webhook endpoints.
    WebhookEventEnvelope:
      type: object
      description: |
        CloudEvents-inspired management-view envelope (ADR-021 WH-03 / ADR-017
        SECOP-10 / DEC-ASYNC-ENV-01). Not a claim of full CloudEvents compliance.
        Event-specific `data` payload schemas are defined in the AsyncAPI contract
        (`asyncapi/external-integration-webhooks/`). This OpenAPI view keeps `data`
        as an object so management reads stay compatible with additive AsyncAPI
        payload evolution (DEC-ASYNC-COMPAT-01).
      additionalProperties: false
      required:
        - id
        - type
        - created_at
        - business_id
        - api_version
        - data
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
          description: Stable logical event id. Retries preserve this id.
        type:
          $ref: '#/components/schemas/WebhookEventType'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        business_id:
          $ref: '#/components/schemas/UuidV7'
        api_version:
          type: string
          description: |
            Webhook event envelope API version (DEC-ASYNC-ENV-02). Constant `v1`.
            Distinct from REST path prefixes and from the event-type suffix (`.v1`).
          const: v1
          examples:
            - v1
        data:
          type: object
          additionalProperties: true
          description: |
            Minimal event data. Typed payloads are defined in AsyncAPI
            (ADR-011 / ADR-021). Consumers must tolerate additive fields.
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
          type: sms.message.delivered.v1
          created_at: '2026-10-13T09:00:05.000Z'
          business_id: 018f3c4e-7b2a-7c3d-8e9f-0123456789ab
          api_version: v1
          data:
            message_id: 018f3c4e-7b2a-7c3d-8e9f-aaaaaaaaaaaa
    WebhookEventList:
      allOf:
        - $ref: '#/components/schemas/CollectionEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/WebhookEventEnvelope'
      description: Cursor-paginated webhook events for the granted business.
    WebhookDelivery:
      type: object
      description: |
        Customer-visible delivery inspection record (ADR-021 WH-07 / DEC-WH-D01).
        Separates event, delivery, and attempt identity.

        **HTTP success criterion:** any status in 200–299 marks delivery
        `succeeded`. That does **not** guarantee the receiving application
        successfully processed the event.

        Never exposes signing secrets, credentials, internal hosts, queue ids, or
        stack traces.
      additionalProperties: false
      required:
        - id
        - event_id
        - endpoint_id
        - status
        - attempt_count
        - created_at
      properties:
        id:
          $ref: '#/components/schemas/UuidV7'
        event_id:
          $ref: '#/components/schemas/UuidV7'
        endpoint_id:
          $ref: '#/components/schemas/UuidV7'
        status:
          $ref: '#/components/schemas/WebhookDeliveryStatus'
        attempt_count:
          type: integer
          minimum: 0
        last_http_status:
          type:
            - integer
            - 'null'
          minimum: 100
          maximum: 599
        last_attempted_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        next_retry_at:
          oneOf:
            - $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        status_reason:
          type:
            - string
            - 'null'
        created_at:
          $ref: '#/components/schemas/Timestamp'
      examples:
        - id: 018f3c4e-7b2a-7c3d-8e9f-111111111111
          event_id: 018f3c4e-7b2a-7c3d-8e9f-ffffffffffff
          endpoint_id: 018f3c4e-7b2a-7c3d-8e9f-eeeeeeeeeeee
          status: succeeded
          attempt_count: 1
          last_http_status: 200
          last_attempted_at: '2026-10-13T09:00:06.000Z'
          next_retry_at: null
          status_reason: null
          created_at: '2026-10-13T09:00:05.000Z'
    WebhookDeliveryList:
      allOf:
        - $ref: '#/components/schemas/CollectionEnvelope'
        - type: object
          properties:
            data:
              type: array
              items:
                $ref: '#/components/schemas/WebhookDelivery'
      description: Cursor-paginated deliveries for an endpoint.
    WebhookSigningInfo:
      type: object
      description: |
        Approved outbound delivery signing protocol (DEC-WH-S01 / ADR-017 SECOP-10).
        Documentary schema — not an API response body.

        ## Signed bytes

        Concatenation of:

        1. ASCII/UTF-8 bytes of the timestamp string **exactly** as transmitted in
           `1Funnel-Webhook-Timestamp`
        2. One ASCII period byte (`0x2E`)
        3. Exact raw HTTP request-body bytes (no parse/normalize/reserialize)

        ## Signature header

        `1Funnel-Webhook-Signature: v1=<lowercase_hex_hmac_sha256>`

        ## Receiver verification

        1. Read the raw request body
        2. Extract signature and timestamp headers
        3. Validate timestamp format (Unix epoch seconds)
        4. Enforce ±5 minute replay tolerance (not the 24h delivery retry window)
        5. Reconstruct signed bytes
        6. Compute HMAC-SHA256 with the endpoint signing secret
        7. Compare signatures in constant time
        8. Deduplicate using webhook event ID (`1Funnel-Webhook-Id`)
        9. Process the event idempotently

        Each delivery attempt (including retries) uses a fresh timestamp and
        signature. The webhook event ID remains stable across retries.
      additionalProperties: false
      required:
        - algorithm
        - timestamp_format
        - digest_encoding
        - signature_prefix
        - replay_tolerance_seconds
        - signed_material
        - headers
      properties:
        algorithm:
          type: string
          const: HMAC-SHA256
        timestamp_format:
          type: string
          const: unix_epoch_seconds
        digest_encoding:
          type: string
          const: lowercase_hex
        signature_prefix:
          type: string
          const: v1=
        replay_tolerance_seconds:
          type: integer
          const: 300
          description: ±5 minutes. Distinct from the 24-hour delivery retry window.
        signed_material:
          type: string
          const: timestamp + '.' + raw_request_body
        headers:
          type: object
          additionalProperties: false
          required:
            - id
            - timestamp
            - signature
          properties:
            id:
              type: string
              const: 1Funnel-Webhook-Id
            timestamp:
              type: string
              const: 1Funnel-Webhook-Timestamp
            signature:
              type: string
              const: 1Funnel-Webhook-Signature
        signature_header_example:
          type: string
          examples:
            - '1Funnel-Webhook-Signature: v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
  responses:
    BadRequest:
      description: |
        Bad Request — malformed request or field validation failure
        (`code: validation_failed`, DEC-SMS-E01).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            validation:
              summary: Validation failure
              value:
                type: https://developers.1-funnel.org/problems/validation_failed
                title: Validation failed
                status: 400
                detail: One or more fields are invalid.
                code: validation_failed
                request_id: req_01HZX3K9EXAMPLE
                errors:
                  - pointer: /from
                    code: field_invalid
    Unauthorized:
      description: |
        Unauthorized — missing or invalid authentication
        (`code: authentication_required`, DEC-SMS-E01).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            authenticationRequired:
              value:
                type: https://developers.1-funnel.org/problems/authentication_required
                title: Authentication required
                status: 401
                detail: A valid OAuth 2.0 access token is required.
                code: authentication_required
                request_id: req_01HZX3K9EXAMPLE
    Forbidden:
      description: |
        Forbidden — authenticated caller lacks required permission, business grant,
        or sender authorization (`permission_denied` or `sender_not_authorized`).
        Cross-business existence may be concealed with 404 instead (ADR-016).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            permissionDenied:
              value:
                type: https://developers.1-funnel.org/problems/permission_denied
                title: Forbidden
                status: 403
                detail: The application is not permitted to perform this operation for the selected business.
                code: permission_denied
                request_id: req_01HZX3K9EXAMPLE
            senderNotAuthorized:
              value:
                type: https://developers.1-funnel.org/problems/sender_not_authorized
                title: Sender not authorized
                status: 403
                detail: The sender identity is not authorized for this business.
                code: sender_not_authorized
                request_id: req_01HZX3K9EXAMPLE
    NotFound:
      description: |
        Not Found — resource missing or concealed under authorization policy
        (`code: resource_not_found`, DEC-SMS-E01 / ADR-016).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            notFound:
              value:
                type: https://developers.1-funnel.org/problems/resource_not_found
                title: Not found
                status: 404
                detail: The requested resource was not found.
                code: resource_not_found
                request_id: req_01HZX3K9EXAMPLE
    Conflict:
      description: |
        Conflict — state conflict or idempotency key reused with a different payload
        (STD-API-01 D08/D09).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            idempotencyConflict:
              summary: Idempotency key conflict
              value:
                type: https://developers.1-funnel.org/problems/idempotency_conflict
                title: Idempotency conflict
                status: 409
                detail: The Idempotency-Key was reused with a different payload.
                code: idempotency_conflict
                request_id: req_01HZX3K9EXAMPLE
    UnprocessableContent:
      description: |
        Unprocessable Content — syntactically valid request that cannot be processed
        due to semantic constraints (DEC-SMS-E01). Examples include recipient limit,
        inactive template, or invalid recipient.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            recipientLimit:
              summary: Bulk recipient limit exceeded
              value:
                type: https://developers.1-funnel.org/problems/recipient_limit_exceeded
                title: Recipient limit exceeded
                status: 422
                detail: The request exceeds the maximum of 1000 submitted recipient entries.
                code: recipient_limit_exceeded
                request_id: req_01HZX3K9EXAMPLE
            templateNotActive:
              value:
                type: https://developers.1-funnel.org/problems/template_not_active
                title: Template not active
                status: 422
                detail: The template is archived or not active for new submissions.
                code: template_not_active
                request_id: req_01HZX3K9EXAMPLE
            invalidRecipient:
              value:
                type: https://developers.1-funnel.org/problems/invalid_recipient
                title: Invalid recipient
                status: 422
                detail: One or more recipients failed semantic validation.
                code: invalid_recipient
                request_id: req_01HZX3K9EXAMPLE
    TooManyRequests:
      description: |
        Too Many Requests — rate limit exceeded
        (`code: rate_limit_exceeded`, DEC-SMS-E01).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            rateLimited:
              value:
                type: https://developers.1-funnel.org/problems/rate_limit_exceeded
                title: Rate limit exceeded
                status: 429
                detail: The client has exceeded an applicable rate limit.
                code: rate_limit_exceeded
                request_id: req_01HZX3K9EXAMPLE
    InternalServerError:
      description: Internal Server Error — unexpected failure.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            internal:
              value:
                type: https://developers.1-funnel.org/problems/internal_error
                title: Internal server error
                status: 500
                detail: An unexpected error occurred.
                code: internal_error
                request_id: req_01HZX3K9EXAMPLE
    ServiceUnavailable:
      description: |
        Service Unavailable (`code: service_unavailable`, DEC-SMS-E01).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
          examples:
            unavailable:
              value:
                type: https://developers.1-funnel.org/problems/service_unavailable
                title: Service unavailable
                status: 503
                detail: The service is temporarily unavailable.
                code: service_unavailable
                request_id: req_01HZX3K9EXAMPLE
    Accepted:
      description: |
        Accepted — durable asynchronous acceptance (STD-API-01 D10, ADR-017).
        Does **not** mean downstream processing or delivery succeeded. Only use when
        the operation has durable acceptance semantics. Capability contracts define
        the response body.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
        Location:
          $ref: '#/components/headers/Location'
    Created:
      description: |
        Created — a new resource was created synchronously. Capability contracts
        define the response body. Prefer 202 when work is admitted asynchronously
        after durable acceptance.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
        Location:
          $ref: '#/components/headers/Location'
    NoContent:
      description: No Content — success with an empty body.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
    Problem:
      description: |
        RFC 9457 Problem Details error body.
        Content-Type: application/problem+json
      headers:
        X-Request-Id:
          $ref: '#/components/headers/RequestIdResponse'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
x-1funnel-contract:
  status: draft
  phase: 5B.4-closed
  ga: false
  architecture_refs:
    - STD-API-01 v1.6
    - ADR-006
    - ADR-011
    - ADR-013
    - ADR-014
    - ADR-015
    - ADR-016
    - ADR-017
    - ADR-018
    - ADR-019
    - ADR-020
    - ADR-021
    - ADR-022
    - ADR-023
  permission_extension: x-required-permissions
  permission_extension_schema:
    type: array
    items:
      $ref: '#/components/schemas/Permission'
    description: |
      Operation-level required ADR-022 resource permissions. Not OAuth scopes.
      Example:
        x-required-permissions:
          - wallet.balance.read
x-1funnel-oauth:
  audience: 1funnel-external-api
  client_authentication: client_secret_basic
  grant_type: client_credentials
  production:
    issuer: https://auth.1-funnel.org
    token_endpoint: https://auth.1-funnel.org/oauth2/token
    jwks_endpoint: https://auth.1-funnel.org/oauth2/jwks
  sandbox:
    issuer: https://auth.sandbox.1-funnel.org
    token_endpoint: https://auth.sandbox.1-funnel.org/oauth2/token
    jwks_endpoint: https://auth.sandbox.1-funnel.org/oauth2/jwks
  notes: |
    OpenAPI OAuth2 `tokenUrl` is set to the production target on the security
    scheme. Sandbox clients must use the sandbox token endpoint from this
    extension (or environment-specific documentation). Do not point external
    clients at internal authorization-server hosts.
