openapi: 3.1.0
info:
  title: GetSigned API
  version: 1.0.0
  summary: E-signature infrastructure for developers and business teams.
  description: |
    The GetSigned API lets you create envelopes, define signing fields,
    route signers, and receive cryptographically sealed PDFs — all
    programmatically. Every completed document carries a SHA-256 hash,
    a service-level digital seal, and a full audit trail.

    **Base URL:** `https://api.getsigned.ca`

    **Auth:** OAuth 2.0 client credentials. Exchange your `client_id` +
    `client_secret` for a bearer token at `POST /oauth/token`.
    Pass the token as `Authorization: Bearer <token>` on every request.

    **Tenancy:** Every envelope is scoped to an `app_id` (your API client)
    and a `tenant_id` (a customer within your app). If you are sending on
    behalf of a single organisation, use a fixed `tenant_id`.

    **Idempotency:** `POST` requests accept an `Idempotency-Key` header.
    Replay-safe within 24 hours.

    **Rate limiting:** `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
    `X-RateLimit-Reset` are returned on every response.
  contact:
    name: GetSigned Support
    email: support@getsigned.ca
  license:
    name: Proprietary

servers:
  - url: https://api.getsigned.ca
    description: Production
  - url: http://localhost:5000
    description: Local development

tags:
  - name: Auth
    description: OAuth 2.0 token issuance
  - name: Envelopes
    description: Create, manage, and retrieve envelopes
  - name: Signing
    description: Signer-facing endpoints (accessed via tokenized link)
  - name: Webhooks
    description: Webhook configuration
  - name: Console
    description: Self-serve console endpoints (console auth required)

# ─────────────────────────────────────────────────────────────────
# SECURITY SCHEMES
# ─────────────────────────────────────────────────────────────────
components:
  securitySchemes:
    clientCredentials:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: /oauth/token
          scopes: {}
    bearerToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
    signerToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Short-lived JWT scoped to a single envelope, issued per signer.

  # ───────────────────────────────────────────────────────────────
  # SCHEMAS
  # ───────────────────────────────────────────────────────────────
  schemas:

    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token: { type: string }
        token_type:   { type: string, example: Bearer }
        expires_in:   { type: integer, example: 3600 }

    # ── Envelope ──────────────────────────────────────────────────
    EnvelopeStatus:
      type: string
      enum: [draft, sent, in_progress, completed, declined, voided, expired]

    Signer:
      type: object
      required: [id, name, email, routingOrder, status]
      properties:
        id:            { type: string, format: uuid }
        name:          { type: string, example: Jamie Park }
        email:         { type: string, format: email }
        phone:         { type: string, nullable: true }
        routingOrder:  { type: integer, minimum: 1, example: 1 }
        status:        { type: string, enum: [pending, sent, viewed, signed, declined] }
        authMethod:    { type: string, enum: [email_otp, sms_otp, none], default: email_otp }
        signedAt:      { type: string, format: date-time, nullable: true }

    FieldType:
      type: string
      enum: [signature, initial, date, text, checkbox]

    Field:
      type: object
      required: [id, documentId, signerId, type, page, x, y, w, h]
      properties:
        id:          { type: string, format: uuid }
        documentId:  { type: string, format: uuid }
        signerId:    { type: string, format: uuid }
        type:        { $ref: '#/components/schemas/FieldType' }
        page:        { type: integer, minimum: 1, description: 1-based page number }
        x:           { type: number, description: Left edge in PDF points from top-left origin }
        y:           { type: number, description: Top edge in PDF points from top-left origin }
        w:           { type: number, description: Width in PDF points }
        h:           { type: number, description: Height in PDF points }
        required:    { type: boolean, default: true }

    Document:
      type: object
      required: [id, envelopeId, kind, mimeType, sha256, pageCount]
      properties:
        id:             { type: string, format: uuid }
        envelopeId:     { type: string, format: uuid }
        kind:           { type: string, enum: [original, sealed] }
        mimeType:       { type: string, example: application/pdf }
        sha256:         { type: string, example: a3f2... }
        pageCount:      { type: integer }
        retentionUntil: { type: string, format: date, nullable: true }
        purgeStatus:    { type: string, enum: [active, purged] }

    AuditEvent:
      type: object
      required: [id, envelopeId, event, timestamp, actor]
      properties:
        id:         { type: string, format: uuid }
        envelopeId: { type: string, format: uuid }
        event:      { type: string, example: envelope.completed }
        timestamp:  { type: string, format: date-time }
        actor:      { type: string, example: signer:uuid or system }
        ip:         { type: string, nullable: true }
        userAgent:  { type: string, nullable: true }
        detail:     { type: object, nullable: true }

    Envelope:
      type: object
      required: [id, tenantId, status, createdAt]
      properties:
        id:                  { type: string, format: uuid }
        tenantId:            { type: string }
        status:              { $ref: '#/components/schemas/EnvelopeStatus' }
        createdBy:           { type: string }
        subject:             { type: string, nullable: true, description: "Human-readable name for the envelope." }
        documentHashOriginal: { type: string, nullable: true }
        documentHashFinal:   { type: string, nullable: true }
        completedAt:         { type: string, format: date-time, nullable: true }
        expiresAt:           { type: string, format: date-time, nullable: true }
        voidedAt:            { type: string, format: date-time, nullable: true }
        createdAt:           { type: string, format: date-time }
        signers:             { type: array, items: { $ref: '#/components/schemas/Signer' } }
        fields:              { type: array, items: { $ref: '#/components/schemas/Field' } }
        documents:           { type: array, items: { $ref: '#/components/schemas/Document' } }
        auditLog:            { type: array, items: { $ref: '#/components/schemas/AuditEvent' } }

    # ── Requests ──────────────────────────────────────────────────
    CreateSignerRequest:
      type: object
      required: [name, email]
      properties:
        name:         { type: string, example: Jamie Park }
        email:        { type: string, format: email }
        phone:        { type: string, nullable: true }
        routingOrder: { type: integer, minimum: 1, default: 1 }
        authMethod:   { type: string, enum: [email_otp, sms_otp, none], default: email_otp }

    CreateFieldRequest:
      type: object
      required: [signerId, type, page, x, y, w, h]
      properties:
        signerId:  { type: string, format: uuid }
        type:      { $ref: '#/components/schemas/FieldType' }
        page:      { type: integer, minimum: 1 }
        x:         { type: number }
        y:         { type: number }
        w:         { type: number }
        h:         { type: number }
        required:  { type: boolean, default: true }

    CreateEnvelopeRequest:
      type: object
      required: [tenantId, signers]
      properties:
        tenantId:
          type: string
          description: Your tenant identifier — scopes this envelope to a customer within your app.
        subject:
          type: string
          nullable: true
          maxLength: 255
          description: Human-readable name for the envelope. Used as the email subject line and the downloaded PDF filename. Optional.
          example: "NDA — Acme Corp"
        signers:
          type: array
          items: { $ref: '#/components/schemas/CreateSignerRequest' }
          minItems: 1
        fields:
          type: array
          items: { $ref: '#/components/schemas/CreateFieldRequest' }
        expiresInDays:
          type: integer
          minimum: 1
          nullable: true
          description: Days until the envelope expires. Uses tenant default if omitted.

    SendEnvelopeRequest:
      type: object
      properties:
        message:
          type: string
          nullable: true
          description: Optional message shown to signers in the email invitation.

    # ── Signing ───────────────────────────────────────────────────
    SigningField:
      type: object
      required: [id, type, page, x, y, w, h, required]
      properties:
        id:       { type: string, format: uuid }
        type:     { $ref: '#/components/schemas/FieldType' }
        page:     { type: integer }
        x:        { type: number }
        y:        { type: number }
        w:        { type: number }
        h:        { type: number }
        required: { type: boolean }

    SigningContextResponse:
      type: object
      required: [envelopeId, signerId, signerName, signerEmail, fields, requiresOtpVerification, otpVerified]
      properties:
        envelopeId:               { type: string, format: uuid }
        signerId:                 { type: string, format: uuid }
        signerName:               { type: string }
        signerEmail:              { type: string, format: email }
        fields:                   { type: array, items: { $ref: '#/components/schemas/SigningField' } }
        requiresOtpVerification:  { type: boolean }
        otpVerified:              { type: boolean }
        savedSignatureB64:        { type: string, nullable: true }
        savedSignatureFontLabel:  { type: string, nullable: true }

    FieldSubmission:
      type: object
      required: [fieldId]
      properties:
        fieldId:              { type: string, format: uuid }
        signatureImageBase64: { type: string, nullable: true, description: PNG as base64, for signature/initial fields }
        textValue:            { type: string, nullable: true, description: Text value for date/text/checkbox fields }

    SignRequest:
      type: object
      required: [fields]
      properties:
        fields:         { type: array, items: { $ref: '#/components/schemas/FieldSubmission' } }
        fontLabel:      { type: string, nullable: true, description: Font used for typed signature rendering }

    # ── Webhooks ──────────────────────────────────────────────────
    WebhookEvent:
      type: string
      enum:
        - envelope.created
        - envelope.sent
        - envelope.completed
        - envelope.declined
        - envelope.voided
        - envelope.expired
        - signer.viewed
        - signer.signed
        - signer.declined

    WebhookConfig:
      type: object
      required: [id, url, events, active, createdAt]
      properties:
        id:        { type: string, format: uuid }
        url:       { type: string, format: uri }
        events:    { type: array, items: { $ref: '#/components/schemas/WebhookEvent' } }
        active:    { type: boolean }
        createdAt: { type: string, format: date-time }

    CreateWebhookRequest:
      type: object
      required: [url, events]
      properties:
        url:    { type: string, format: uri, example: 'https://yourapp.com/webhooks/getsigned' }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEvent' }, minItems: 1 }

    # ── Errors ────────────────────────────────────────────────────
    ErrorResponse:
      type: object
      required: [error, message]
      properties:
        error:   { type: string, example: validation_error }
        message: { type: string, example: 'signers[0].email is required' }
        details: { type: object, nullable: true }

# ─────────────────────────────────────────────────────────────────
# PATHS
# ─────────────────────────────────────────────────────────────────
paths:

  # ── AUTH ────────────────────────────────────────────────────────
  /oauth/token:
    post:
      tags: [Auth]
      summary: Issue access token
      description: |
        OAuth 2.0 client credentials grant. Returns a short-lived bearer token
        scoped to your application. Tokens expire in 3600 seconds.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type:    { type: string, enum: [client_credentials] }
                client_id:     { type: string }
                client_secret: { type: string }
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenResponse' }
        '401':
          description: Invalid credentials
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  # ── ENVELOPES ───────────────────────────────────────────────────
  /v1/envelopes:
    post:
      tags: [Envelopes]
      summary: Create envelope
      description: |
        Upload a PDF document and define the signing workflow — signers, field
        positions, and routing order. Returns the envelope in `draft` status.
        Call `POST /v1/envelopes/{id}/send` to dispatch signing links.

        The request uses `multipart/form-data`. Pass the PDF as `file` and
        the JSON payload as a `data` part, or use the flat form-field encoding
        shown in the example.
      security:
        - bearerToken: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, tenantId]
              properties:
                file:
                  type: string
                  format: binary
                  description: PDF file to sign
                tenantId:
                  type: string
                signers:
                  type: array
                  items: { $ref: '#/components/schemas/CreateSignerRequest' }
                fields:
                  type: array
                  items: { $ref: '#/components/schemas/CreateFieldRequest' }
                expiresInDays:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Envelope created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
        '400':
          description: Validation error
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { description: Unauthorized }
        '413': { description: File too large (max 25 MB) }

    get:
      tags: [Envelopes]
      summary: List envelopes
      description: Returns envelopes for the authenticated application, newest first.
      security:
        - bearerToken: []
      parameters:
        - name: tenantId
          in: query
          required: true
          schema: { type: string }
          description: Scope results to this tenant.
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/EnvelopeStatus' }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: pageSize
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: Paginated envelope list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:       { type: array, items: { $ref: '#/components/schemas/Envelope' } }
                  total:      { type: integer }
                  page:       { type: integer }
                  pageSize:   { type: integer }
        '401': { description: Unauthorized }

  /v1/envelopes/{id}:
    get:
      tags: [Envelopes]
      summary: Get envelope
      description: Returns full envelope detail including signers, fields, documents, and audit log.
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Envelope detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
        '401': { description: Unauthorized }
        '404': { description: Not found or not owned by caller }

  /v1/envelopes/{id}/send:
    post:
      tags: [Envelopes]
      summary: Send envelope
      description: |
        Transitions the envelope from `draft` → `sent`. Generates tokenized
        signing links for the first routing-order group and dispatches email
        (and SMS if configured) invitations. Fires `envelope.sent` webhook.
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendEnvelopeRequest' }
      responses:
        '200':
          description: Envelope sent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
        '400': { description: Envelope not in draft status, or missing signers/fields }
        '401': { description: Unauthorized }
        '404': { description: Not found }

  /v1/envelopes/{id}/document:
    get:
      tags: [Envelopes]
      summary: Download document
      description: |
        Downloads the PDF document for this envelope.
        Use `?kind=sealed` to download the cryptographically sealed final PDF
        (only available after `status = completed`).
        Omit or use `?kind=original` for the unsigned original.
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: kind
          in: query
          schema: { type: string, enum: [original, sealed], default: original }
      responses:
        '200':
          description: PDF file
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { description: Unauthorized }
        '404': { description: Not found }
        '409': { description: Sealed document not yet available — envelope not completed }

  /v1/envelopes/{id}/void:
    post:
      tags: [Envelopes]
      summary: Void envelope
      description: |
        Voids an in-progress envelope. All outstanding signing links are
        invalidated immediately. Fires `envelope.voided` webhook.
        Terminal — cannot be undone.
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, nullable: true }
      responses:
        '200':
          description: Envelope voided
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Envelope' }
        '400': { description: Envelope already in terminal state }
        '401': { description: Unauthorized }
        '404': { description: Not found }

  # ── SIGNERS ─────────────────────────────────────────────────────
  /v1/envelopes/{id}/signers:
    post:
      tags: [Envelopes]
      summary: Add signer
      description: Adds a signer to a draft envelope. Cannot be called after the envelope is sent.
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSignerRequest' }
      responses:
        '201':
          description: Signer added
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Signer' }
        '400': { description: Envelope not in draft status }
        '401': { description: Unauthorized }
        '404': { description: Envelope not found }

  # ── SIGNING (signer-facing) ──────────────────────────────────────
  /v1/signing/{token}:
    get:
      tags: [Signing]
      summary: Get signing context
      description: |
        Returns the signing context for a tokenized signer link — envelope
        details, fields assigned to this signer, and OTP verification state.
        No auth header required; the token itself is the credential.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Signing context
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SigningContextResponse' }
        '401': { description: Token expired or invalid }
        '410': { description: Token already used (single-use) }

  /v1/signing/{token}/otp/request:
    post:
      tags: [Signing]
      summary: Request OTP
      description: Dispatches a one-time code to the signer's email (or SMS if phone configured).
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Code dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  maskedEmail: { type: string, example: 'j***@client.io' }
        '401': { description: Token invalid }
        '429': { description: Rate limited — max 5 requests per 10 minutes }

  /v1/signing/{token}/otp/verify:
    post:
      tags: [Signing]
      summary: Verify OTP
      description: Verifies the one-time code. On success, the signer is cleared to proceed.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, minLength: 6, maxLength: 6, example: '482901' }
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
        '401': { description: Token invalid }
        '400': { description: Code incorrect or expired }

  /v1/signing/{token}/consent:
    post:
      tags: [Signing]
      summary: Record e-sign consent
      description: |
        Records the signer's explicit consent to electronic signing. Must be
        called before `POST /v1/signing/{token}/sign`. Logs IP, user agent,
        and timestamp to the audit trail.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Consent recorded }
        '401': { description: Token invalid or OTP not yet verified }
        '409': { description: Consent already given }

  /v1/signing/{token}/sign:
    post:
      tags: [Signing]
      summary: Submit signature
      description: |
        Submits completed field values for this signer. Advances routing to
        the next group when this is the last signer in the current group.
        When all signers have signed, triggers the sealing pipeline
        automatically and fires `envelope.completed` webhook.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SignRequest' }
      responses:
        '200': { description: Signature submitted }
        '400': { description: Required fields missing or invalid values }
        '401': { description: Token invalid or consent not given }
        '409': { description: Already signed }

  /v1/signing/{token}/decline:
    post:
      tags: [Signing]
      summary: Decline to sign
      description: |
        Records the signer's refusal. Sets envelope status to `declined`.
        Fires `signer.declined` and `envelope.declined` webhooks.
        Terminal — cannot be undone.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, nullable: true }
      responses:
        '200': { description: Decline recorded }
        '401': { description: Token invalid }
        '409': { description: Already signed or declined }

  /v1/signing/{token}/document:
    get:
      tags: [Signing]
      summary: Fetch document for signing
      description: |
        Returns the original PDF for rendering in the signing UI.
        Requires a valid (not yet used) signer token.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: PDF file
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { description: Token invalid }

  # ── DOWNLOAD PORTAL ─────────────────────────────────────────────
  /v1/download/{token}:
    get:
      tags: [Download]
      summary: Download portal — availability
      description: |
        Public, token-gated portal for the sealed copy when it was too large to email.
        The tokenized link is the credential (no bearer auth). Returns 410 once the blob
        has been purged under the retention policy.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Document available
          content:
            application/json:
              schema:
                type: object
                properties:
                  envelopeId: { type: string, format: uuid }
                  available: { type: boolean }
        '401': { description: Link invalid or expired }
        '410': { description: Document deleted under the retention policy }

  /v1/download/{token}/document:
    get:
      tags: [Download]
      summary: Download portal — stream the sealed PDF
      description: |
        Streams the sealed PDF for a valid download token and records the download
        (stops further deletion reminders). Returns 410 once purged.
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Sealed PDF file
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '401': { description: Link invalid or expired }
        '410': { description: Document deleted under the retention policy }

  # ── WEBHOOKS ────────────────────────────────────────────────────
  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook configs
      description: Returns all webhook endpoints registered for this application.
      security:
        - bearerToken: []
      responses:
        '200':
          description: Webhook list
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WebhookConfig' }
        '401': { description: Unauthorized }

    post:
      tags: [Webhooks]
      summary: Create webhook
      description: |
        Registers a new HTTPS endpoint to receive signing events.

        **Verification:** Every delivery includes an `X-GetSigned-Signature`
        header containing `HMAC-SHA256(secret, raw_body)` where `secret` is
        your client secret. Reject requests where the signature does not match.

        Deliveries are retried up to 5 times with exponential backoff
        (30s, 2m, 10m, 1h, 6h) if your endpoint returns a non-2xx response.
      security:
        - bearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateWebhookRequest' }
      responses:
        '201':
          description: Webhook created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookConfig' }
        '400': { description: Validation error }
        '401': { description: Unauthorized }

  /v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete webhook
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Deleted }
        '401': { description: Unauthorized }
        '404': { description: Not found }

  # ── WEBHOOK PAYLOAD (documentation-only, not a real endpoint) ───
  # Documented as a schema below; webhook deliveries are server-to-server.

  # ── CONSOLE (self-serve, console JWT auth) ───────────────────────
  /v1/console/contacts:
    get:
      tags: [Console]
      summary: List contacts
      description: Returns saved signer contacts for this application. Supports ILIKE search.
      security:
        - bearerToken: []
      parameters:
        - name: search
          in: query
          schema: { type: string }
          description: ILIKE filter applied to name and email.
      responses:
        '200':
          description: Contact list
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:        { type: string, format: uuid }
                    appId:     { type: string, format: uuid }
                    name:      { type: string }
                    email:     { type: string, format: email }
                    phone:     { type: string, nullable: true }
                    createdAt: { type: string, format: date-time }
        '401': { description: Unauthorized }

    post:
      tags: [Console]
      summary: Upsert contact
      description: Creates or updates a contact by email (ON CONFLICT on app_id + email).
      security:
        - bearerToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email]
              properties:
                name:  { type: string }
                email: { type: string, format: email }
                phone: { type: string, nullable: true }
      responses:
        '200':
          description: Contact upserted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:    { type: string, format: uuid }
                  name:  { type: string }
                  email: { type: string }
        '401': { description: Unauthorized }

  /v1/console/contacts/{id}:
    delete:
      tags: [Console]
      summary: Delete contact
      security:
        - bearerToken: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Deleted }
        '401': { description: Unauthorized }
        '404': { description: Not found }

  /v1/console/usage:
    get:
      tags: [Console]
      summary: Get usage summary
      description: Returns envelope counts and quota consumption for the current billing period.
      security:
        - bearerToken: []
      responses:
        '200':
          description: Usage summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:            { type: string, example: starter }
                  periodStart:     { type: string, format: date }
                  periodEnd:       { type: string, format: date }
                  envelopesUsed:   { type: integer }
                  envelopesLimit:  { type: integer, nullable: true }
                  overageAllowed:  { type: boolean }
        '401': { description: Unauthorized }
