openapi: 3.1.0
info:
  title: Pactivo Envelope API
  version: 1.0.0
  description: |
    E-signature infrastructure for regulated work (UK eIDAS SES tier).
    Create envelopes, send them for signature, track the tamper-evident audit
    trail, and download the sealed artifacts (PAdES-signed PDF + evidence
    certificate, both SHA-256 fingerprinted).

    **This spec is generated from the deployed code, not aspiration.** Routes,
    field names, limits, and error shapes match the live `envelope-api` edge
    function. Source of truth: `rajoka/engineering` → `own-apis/pactivo/`.

    ### Authentication
    Two integrator credentials (console sessions are a third, internal path):
    * **API key** — `Authorization: Bearer pk_live_…`. Created on the console
      Developers page; shown once, stored hashed, revocable. Org-scoped.
    * **Rajoka Connect access token** — portfolio apps authorize via the
      Rajoka Connect hub (OAuth 2.0 Authorization Code + PKCE, consent screen)
      and call the same routes with the minted token. Envelope scopes:
      `pactivo.envelopes:read` (GET routes) and `pactivo.envelopes:manage`
      (create + lifecycle). Partner scopes are `pactivo.templates:read`,
      `pactivo.orgs:provision`, `pactivo.usage:read`, and
      `pactivo.membership:read`. Partner grants mint two distinct credentials:
      a tenant-less, maximum-10-minute `partner_provisioning` token is accepted
      only by partner-level provisioning, usage, membership, and webhook
      configuration routes; a `partner_space`
      token is bound to one Pactivo organisation by `space_id`/`tenant_id` and
      is required on every sub-organisation route. A token for space A cannot
      address space B, even when both belong to the same partner. Every
      authenticated Connect route is rate-limited per verified client and
      tenant (or verified grant for partner-level calls). Connect callers must send a UUID v4
      `Idempotency-Key` only for `POST /envelopes`,
      `POST /envelopes/{id}/send`, and `POST /envelopes/{id}/void`; those three
      routes durably replay the stored result. Other mutations make no
      idempotent-replay promise and do not require the header.

    ### Errors
    Every error is `{ "error": "<human-readable message>" }` with a 4xx/5xx
    status. Validation failures are `422`; missing/invalid credentials `401`;
    missing scope `403`; unknown/foreign-org resources `404`. Every response
    carries `x-request-id`, reusing the inbound value when one is supplied.

    ### Webhooks (outbound)
    Register an `https://` endpoint on the console (Developers → Webhooks).
    Each delivery is a POST carrying headers `X-Pactivo-Event`,
    `X-Pactivo-Timestamp`, and `X-Pactivo-Signature` — an HMAC-SHA256 of
    `{timestamp}.{raw body}` with your endpoint secret (Stripe-style).
    Verify the signature and reject stale timestamps. Failed deliveries retry
    with backoff. Body: `{ id, event_type, created_at, data }`.

    Subscribable `event_type`s: `envelope.sent`, `envelope.viewed`,
    `envelope.recipient_signed`, `envelope.completed`, `envelope.declined`,
    `envelope.expired`, `envelope.voided`, `envelope.artifacts_ready`,
    `envelope.artifacts_failed`, `envelope.changes_requested` (a signer sent the
    envelope back for changes), `envelope.reassigned` (a signer delegated their
    signing to a named person), `envelope.changes_resolved` (the sender re-sent a
    sent-back envelope).

    Partner-managed organisations use a separate signed webhook queue for
    `envelope.completed`, `envelope.declined`, and `envelope.voided`. Its data
    object contains identifiers only: `partner_id`, `external_account_id`,
    `organization_id`, and `envelope_id`. Envelope content, recipient details,
    and patient documents are never included. Terminal state and the durable
    partner outbox row commit in the same database transaction; delivery then
    retries independently with the stable event id. Because partners may place
    special-category patient documents in envelopes, completion of DPIA
    rajoka/hq#108 is a launch gate.
servers:
  - url: https://oesufgbdyhacjbvlvxfl.supabase.co/functions/v1/envelope-api
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Envelopes
  - name: Artifacts
  - name: Templates
  - name: Partner
  - name: Health

paths:
  /connect-health:
    get:
      tags: [Health]
      summary: Rajoka Connect resource-server health
      description: |
        Unauthenticated live health check. Proves the resource-server's RFC
        7662 introspection credential with a deliberately invalid token and
        checks the local tenant store. Overall status is the worst live
        dependency, never process liveness alone. Results are cached for 30
        seconds per edge isolate and requests are capped per client IP.
      security: []
      responses:
        "200":
          description: All dependencies are healthy, or a dependency is degraded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectHealth" }
        "503":
          description: At least one required Connect dependency is down.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConnectHealth" }
        "429": { $ref: "#/components/responses/ConnectRateLimited" }

  /envelopes:
    get:
      tags: [Envelopes]
      summary: List envelopes
      description: Org-scoped, newest-updated first.
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          description: Comma-separated status filter.
          schema:
            type: string
            example: sent,partially_signed
        - name: q
          in: query
          description: Case-insensitive title search.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Envelope summaries with recipients and total count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EnvelopeSummary" }
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Validation" }
    post:
      tags: [Envelopes]
      summary: Create an envelope
      description: |
        JSON body (not multipart). Documents are base64-encoded PDFs, max
        **15 MB per document / 40 MB total** (decoded size). Invalid or
        oversized input is rejected before any writes — no half-built
        envelopes. Fields are placed by index into the `documents` and
        `recipients` arrays.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateEnvelope" }
            example:
              title: Client engagement letter
              message: Please sign the attached engagement letter.
              recipients:
                - name: Eleanor Hartley
                  email: eleanor@hartley.co.uk
              documents:
                - file_name: engagement-letter.pdf
                  content_base64: JVBERi0xLjcKJc…
              fields:
                - document_index: 0
                  recipient_index: 0
                  type: signature
                  page: 1
                  x: 0.62
                  y: 0.78
                  width: 0.28
                  height: 0.06
                  required: true
              signing_order: sequential
              verification:
                required: true
                type: otp_email
      parameters:
        - $ref: "#/components/parameters/idempotencyKey"
      responses:
        "201":
          description: The created envelope (status `prepared` — nothing is emailed until you send).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Validation" }

  /envelopes/{id}:
    get:
      tags: [Envelopes]
      summary: Get an envelope
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/envelopeId"
      responses:
        "200":
          description: Full envelope with recipients, documents, artifacts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /envelopes/{id}/send:
    post:
      tags: [Envelopes]
      summary: Send for signature
      description: |
        Freezes the documents (checksums recorded), mints single-use signing
        tokens, and emails each recipient their secure link — respecting
        `signing_order`. Email failures are returned in `delivery.undelivered`
        and as `warnings`; they never make the send itself fail. The envelope
        remains `sent`, even when no signer email was delivered.

        Counts against the organisation's rolling-24h signer-email allowance,
        one email per recipient contacted now (under `signing_order: sequential`
        that is the first order group only). The allowance is checked *before*
        anything changes, so a `429` leaves the envelope exactly as it was.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/envelopeId"
        - $ref: "#/components/parameters/idempotencyKey"
      responses:
        "200":
          description: The envelope, now `sent`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
                  delivery:
                    $ref: "#/components/schemas/SendDeliverySummary"
                  warnings:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/SendingSuspended" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/SendQuotaExceeded" }

  /envelopes/{id}/audit:
    get:
      tags: [Envelopes]
      summary: Audit trail
      description: |
        The envelope's append-only, SHA-256 hash-chained event log, plus a live
        chain verification (`verify_envelope_event_chain`) proving no event was
        altered or removed.
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - bearerAuth: []
      parameters: [{ $ref: "#/components/parameters/envelopeId" }]
      responses:
        "200":
          description: Ordered events + chain verification result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      envelope_id: { type: string, format: uuid }
                      events:
                        type: array
                        items: { $ref: "#/components/schemas/AuditEvent" }
                      chain_verification:
                        type: object
                        description: Result row of the chain-verification RPC.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /envelopes/{id}/signed-pdf:
    get:
      tags: [Artifacts]
      summary: Download the sealed PDF
      description: |
        Returns a 1-hour signed download URL for the completed, sealed PDF
        (PAdES digital signature + RFC 3161 timestamp), with its SHA-256
        checksum to verify after download.
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - bearerAuth: []
      parameters: [{ $ref: "#/components/parameters/envelopeId" }]
      responses:
        "200": { $ref: "#/components/responses/ArtifactDownload" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Envelope not completed, or artifact not yet generated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /envelopes/{id}/certificate:
    get:
      tags: [Artifacts]
      summary: Download the evidence certificate
      description: Same contract as `signed-pdf`, for the evidence certificate.
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - bearerAuth: []
      parameters: [{ $ref: "#/components/parameters/envelopeId" }]
      responses:
        "200": { $ref: "#/components/responses/ArtifactDownload" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Envelope not completed, or artifact not yet generated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /envelopes/{id}/remind:
    post:
      tags: [Envelopes]
      summary: Remind pending signers
      description: |
        Re-emails the signing link to recipients whose turn it is (sequential
        order respected). Capped per envelope by `max_reminders`, and counted
        against the organisation's rolling-24h signer-email allowance — only
        recipients who will actually be emailed are counted. This route does
        not provide idempotent replay and does not require `Idempotency-Key`.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/envelopeId"
      responses:
        "200":
          description: What was sent, skipped, and any email errors.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reminders_sent: { type: integer }
                      skipped:
                        type: array
                        items: { type: object }
                      errors:
                        type: array
                        items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/SendingSuspended" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/SendQuotaExceeded" }

  /envelopes/{id}/correct:
    post:
      tags: [Envelopes]
      summary: Correct a recipient's contact details
      description: |
        Fixes a mistyped recipient email (and optionally name) on an in-flight
        envelope without void-and-rebuild. The old signing link is revoked; if
        it is that signer's turn, a fresh link is emailed to the corrected
        address. Signed/declined recipients cannot be corrected. This route
        does not provide idempotent replay and does not require
        `Idempotency-Key`.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/envelopeId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipient_id, email]
              properties:
                recipient_id: { type: string, format: uuid }
                email: { type: string, format: email }
                name: { type: string, maxLength: 200 }
                first_name: { type: string, maxLength: 120 }
                last_name: { type: string, maxLength: 120 }
      responses:
        "200":
          description: The updated envelope.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /envelopes/{id}/void:
    post:
      tags: [Envelopes]
      summary: Void an envelope
      description: |
        Voids a non-terminal envelope and revokes all outstanding signing
        links. Terminal states (completed / declined / voided / expired) are
        immutable.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/envelopeId"
        - $ref: "#/components/parameters/idempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  description: Why the envelope is being voided — recorded in the audit trail.
      responses:
        "200":
          description: The envelope, now `voided`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Validation" }

  /envelopes/{id}/resolve-changes:
    post:
      tags: [Envelopes]
      summary: Re-send an envelope that was sent back for changes
      description: |
        A signer can send an envelope back for changes (during the ceremony),
        which moves it to the non-terminal `changes_requested` state and revokes
        every outstanding signing link. This re-opens it: the current-turn
        signers get fresh links (sequential order respected). Documents are not
        re-uploaded here — the same frozen documents are re-sent; to change the
        content, void and recreate. Only valid from `changes_requested`. This
        route does not provide idempotent replay and does not require
        `Idempotency-Key`.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/envelopeId"
      responses:
        "200":
          description: The re-sent envelope plus a delivery summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
                  delivery: { $ref: "#/components/schemas/SendDeliverySummary" }
                  warnings:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/SendingSuspended" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The envelope is not awaiting changes (wrong state).
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/SendQuotaExceeded" }

  /partner-provisioning:
    post:
      tags: [Partner]
      summary: Provision a partner-managed organisation
      description: |
        Requires a `partner_provisioning` token. Producer idempotency is the
        durable `(verified partner, external_account_id)` pair; retries return
        the same result and retry any incomplete Hub registration.

        Fingerprint inputs are `owner_rajoka_sub`, canonical UK
        `registration_number`, and `email_domain` (tie-break only). Owner plus
        matching registration, or the same owner with neither side holding a
        registration, returns `attach_pending` with no organisation mapping or
        access until that existing owner allows it in Pactivo. Further aliases
        for the same partner and organisation reuse that request and create no
        additional notice. A decline returns `attach_declined` with the same
        request id for a 24-hour cooldown. Registration alone creates a fresh
        flagged organisation. Owner alone never attaches. Names and postcodes
        are never identity evidence. Final provision outcomes are `created`,
        `attached`, and `created_flagged`.
      security:
        - oauth2: ["pactivo.orgs:provision"]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PartnerProvisionRequest" }
      responses:
        "200":
          description: Stable replay or an owner-approved attached organisation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerProvisionResponse" }
        "201":
          description: A created or created-and-flagged organisation, registered as a Hub partner space.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerProvisionResponse" }
        "202":
          description: Existing-owner consent is pending; organization_id is null and no mapping/access exists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerProvisionResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Validation" }
        "429": { $ref: "#/components/responses/ConnectRateLimited" }
        "503":
          description: Hub registration or its local acknowledgement is temporarily unavailable; replay the same external_account_id.

  /partner-attach-requests/{request_id}/allow:
    post:
      servers:
        - url: https://oesufgbdyhacjbvlvxfl.supabase.co/functions/v1/admin-api
          description: Production owner-action surface
      tags: [Partner]
      summary: Allow a partner to use an existing organisation
      description: |
        Console-session route for the existing organisation owner. The
        authenticated PostgreSQL RPC verifies `auth.uid()` has an owner-role
        membership before creating any partner mapping. Replaying the same
        allow is idempotent; an organisation already attached elsewhere returns
        a clean 409 conflict.
      security:
        - bearerAuth: []
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Partner attachment allowed, or the same allow replayed. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The request is no longer pending or the organisation already has a live partner mapping. }
        "422": { $ref: "#/components/responses/Validation" }

  /partner-attach-requests/{request_id}/decline:
    post:
      servers:
        - url: https://oesufgbdyhacjbvlvxfl.supabase.co/functions/v1/admin-api
          description: Production owner-action surface
      tags: [Partner]
      summary: Decline a partner request for an existing organisation
      description: |
        Console-session route for the existing organisation owner. Decline
        grants no access. Provisioning aliases for the same partner and
        organisation return this decision and request id during a 24-hour
        cooldown instead of creating another request or owner notice.
      security:
        - bearerAuth: []
      parameters:
        - name: request_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Partner attachment declined, or the same decline replayed. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The request was already resolved with another decision. }
        "422": { $ref: "#/components/responses/Validation" }

  /partner-provisioning/webhook:
    post:
      tags: [Partner]
      summary: Configure or rotate the partner webhook secret
      description: |
        Requires a `partner_provisioning` token. The HTTPS endpoint is checked
        for SSRF at registration and again at dispatch. `rotation_id` makes a
        rotation replay-safe. A new Vault-backed secret is shown only on the
        first successful call; replay returns `signing_secret: null`.
      security:
        - oauth2: ["pactivo.orgs:provision"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint_url, rotation_id]
              properties:
                endpoint_url: { type: string, format: uri, pattern: '^https://' }
                rotation_id: { type: string, format: uuid }
      responses:
        "200": { description: Rotation replay; the secret is not shown again. }
        "201": { description: Configured or rotated; returns the signing secret once. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { description: rotation_id was already used with a different endpoint. }
        "422": { $ref: "#/components/responses/Validation" }

  /partner-provisioning/repoint:
    post:
      tags: [Partner]
      summary: Re-point a flagged partner organisation to its canonical organisation
      description: |
        Pactivo owner/staff console action. Calls Connect
        `repoint_partner_space` before changing the local mapping. It never
        moves or absorbs tenant data. The flagged organisation is discarded
        only when the schema-derived inventory finds no row in any org-scoped
        relation; otherwise it is `kept_separate` and made read-only. The
        inventory covers `org_id`, `organization_id`, and `*_org_id` columns in
        public/partner schemas plus every FK to `organizations`.
        A durable reservation makes an interrupted Hub/local re-point retryable
        and blocks a competing attach or billing transition.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [provision_id, canonical_organization_id]
              properties:
                provision_id: { type: string, format: uuid }
                canonical_organization_id: { type: string, format: uuid }
      responses:
        "200": { description: Hub and local mapping re-pointed. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { description: The duplicate flag or mapping is no longer eligible. }
        "503": { description: Retry-safe Hub/local acknowledgement failure. }

  /partner-provisioning/revoke:
    post:
      tags: [Partner]
      summary: Revoke an owner-approved partner attachment
      description: |
        Owner console action. Pactivo first closes local partner-space access
        and changes the organisation to retained read-only, then calls the
        idempotent Connect `revoke_partner_space` endpoint. A Hub outage is
        safe and retryable because the local authorization gate is already shut.
        If a re-point is in flight, revocation cancels its local reservation and
        reconciles both the old and proposed Hub spaces before acknowledging.
        A proposed space that never existed in Hub is already reconciled; other
        Hub failures remain retryable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attach_request_id]
              properties:
                attach_request_id: { type: string, format: uuid }
      responses:
        "200": { description: Local and Hub partner-space access revoked. }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503": { description: Local access is revoked; replay to finish the Hub acknowledgement. }

  /partner-usage:
    get:
      tags: [Partner]
      summary: Read partner-metered organisation usage
      description: |
        Requires a `partner_provisioning` token. Returns only organisations
        whose exclusive billing owner is the verified partner and whose
        partner operating membership is active. Direct-billed, revoked, and
        other partners' organisations are excluded.
      security:
        - oauth2: ["pactivo.usage:read"]
      parameters:
        - name: from
          in: query
          description: Inclusive usage-window start; defaults to 30 days before `to`.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Exclusive usage-window end; defaults to now.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Usage totals grouped by partner external account and organisation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerUsageResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/ConnectRateLimited" }

  /partner-membership:
    get:
      tags: [Partner]
      summary: Read partner operating memberships
      description: |
        Requires a `partner_provisioning` token. This is the partner-to-org
        operating mapping, not the organisation's human member list. Revoked
        rows remain visible for retention/audit state.
      security:
        - oauth2: ["pactivo.membership:read"]
      parameters:
        - name: external_account_id
          in: query
          description: Optional exact producer account filter.
          schema: { type: string, minLength: 1, maxLength: 200 }
      responses:
        "200":
          description: Partner organisation mappings and active/revoked retention states.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerMembershipResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/ConnectRateLimited" }

  /partner-membership/revoked:
    post:
      tags: [Partner]
      summary: Receive a signed partner membership revocation
      description: |
        Signed inbound webhook; no bearer token. Send `X-Rajoka-Client-Id`,
        `X-Rajoka-Timestamp`, and `X-Rajoka-Signature`, where the signature is
        hex HMAC-SHA256 over `{timestamp}.{raw body}` using the partner secret.
        Timestamps older than five minutes are rejected. The event transitions
        the partner-to-org operating membership to revoked and the organisation
        to retained read-only, then idempotently reconciles both possible Hub
        spaces if a re-point was in flight. It does not remove human members or
        data. A proposed space that was never created in Hub is treated as
        already absent; transport and server failures remain retryable.

        Replay semantics: the durable `(partner, event_id)` key makes an exact
        replay a 200 no-op. Reusing that event_id with a different raw payload
        returns 409. The payload contains identifiers only.
      security: []
      parameters:
        - name: X-Rajoka-Client-Id
          in: header
          required: true
          schema: { type: string }
        - name: X-Rajoka-Timestamp
          in: header
          required: true
          schema: { type: string, pattern: '^\d{10}$' }
        - name: X-Rajoka-Signature
          in: header
          required: true
          schema: { type: string, pattern: '^[0-9a-f]{64}$' }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PartnerMembershipRevoked" }
      responses:
        "200": { description: Revoked, or an exact replay accepted as a no-op. }
        "401": { description: Missing, stale, or invalid signature. }
        "409": { description: event_id was already used with a different payload. }
        "422": { $ref: "#/components/responses/Validation" }
        "503": { description: Local access is closed; replay the exact event to finish Hub reconciliation or its acknowledgement. }

  /templates:
    get:
      tags: [Templates]
      summary: List field templates
      description: |
        During the scope transition this accepts either
        `pactivo.envelopes:read` or `pactivo.templates:read`. Once the planned
        template scope is live on consumer tokens, `pactivo.templates:read`
        becomes the sole Connect scope.
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - oauth2: ["pactivo.templates:read"]
        - bearerAuth: []
      responses:
        "200":
          description: The org's reusable envelope templates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TemplateSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Templates]
      summary: Create a field template
      description: |
        A reusable document + pre-placed field layout, so recurring documents
        (engagement letters, onboarding packs) don't need fields re-placed
        every time. Fields are keyed by recipient index. This route does not
        provide idempotent replay and does not require `Idempotency-Key`.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 120 }
                message: { type: string }
                signing_order: { type: string, enum: [sequential, any] }
                verification: { type: string, enum: [none, email_otp] }
                recipient_count: { type: integer, minimum: 1 }
                recipients:
                  type: array
                  items:
                    type: object
                    properties:
                      name: { type: [string, "null"] }
                documents:
                  type: array
                  items:
                    type: object
                    required: [file_name, content_base64]
                    properties:
                      file_name: { type: string }
                      content_base64: { type: string }
                fields:
                  type: array
                  items: { $ref: "#/components/schemas/Field" }
      responses:
        "201":
          description: The created template.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/TemplateSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/Validation" }

  /templates/{id}:
    get:
      tags: [Templates]
      summary: Get a template
      description: |
        During the scope transition this accepts either
        `pactivo.envelopes:read` or `pactivo.templates:read`. Once the planned
        template scope is live on consumer tokens, `pactivo.templates:read`
        becomes the sole Connect scope.
      security:
        - oauth2: ["pactivo.envelopes:read"]
        - oauth2: ["pactivo.templates:read"]
        - bearerAuth: []
      parameters: [{ $ref: "#/components/parameters/templateId" }]
      responses:
        "200":
          description: The template with its documents and fields.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/TemplateSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Templates]
      summary: Delete a template
      description: This route does not provide idempotent replay and does not require `Idempotency-Key`.
      security:
        - oauth2: ["pactivo.envelopes:manage"]
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/templateId"
      responses:
        "200":
          description: Deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        `pk_live_…` API key.
    oauth2:
      type: oauth2
      description: Rajoka Connect access token for the Pactivo resource server.
      flows:
        authorizationCode:
          authorizationUrl: https://connect.rajoka.com/v1/oauth/authorize
          tokenUrl: https://connect.rajoka.com/v1/oauth/token
          scopes:
            "pactivo.envelopes:read": Read envelopes, audit trails, and artifacts.
            "pactivo.envelopes:manage": Create envelopes and manage their lifecycle.
            "pactivo.templates:read": Read reusable envelope templates.
            "pactivo.orgs:provision": Provision or attach partner-managed organisations.
            "pactivo.usage:read": Read partner-managed organisation usage.
            "pactivo.membership:read": Resolve partner-managed organisation membership.
        clientCredentials:
          tokenUrl: https://connect.rajoka.com/v1/oauth/token
          scopes:
            "pactivo.envelopes:read": Read envelopes, audit trails, and artifacts.
            "pactivo.envelopes:manage": Create envelopes and manage their lifecycle.
            "pactivo.templates:read": Read reusable envelope templates.
            "pactivo.orgs:provision": Provision or attach partner-managed organisations.
            "pactivo.usage:read": Read partner-managed organisation usage.
            "pactivo.membership:read": Resolve partner-managed organisation membership.

  parameters:
    envelopeId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    templateId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Required only for Connect create-envelope, send, and void operations. Reuse the UUID v4 to receive the durable stored result; a different request with the same key is rejected.
      schema:
        type: string
        format: uuid
        pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'

  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Authenticated, but missing the required scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource in your organisation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Validation:
      description: The request body or parameters failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    SendingSuspended:
      description: |
        Sending is paused for this organisation (`organization_suspended`).
        Read access to existing envelopes, signed documents and audit trails is
        never affected by suspension.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    SendQuotaExceeded:
      description: |
        The organisation has reached its cap on outbound signer emails in a
        rolling 24-hour window (`daily_email_limit_reached`), so **nothing was
        sent** — no tokens were minted and the envelope's status is unchanged.
        The window is rolling, so capacity returns as earlier sends age out;
        retrying later succeeds without any other action.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ConnectRateLimited:
      description: Connect request ceiling exceeded for this client and tenant.
      headers:
        Retry-After:
          schema: { type: integer, minimum: 1 }
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotImplemented:
      description: Reserved capability; the authenticated and scope-gated route is not implemented yet.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ArtifactDownload:
      description: A 1-hour signed download URL plus integrity checksum.
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  url: { type: string, format: uri }
                  expires_in_seconds: { type: integer, example: 3600 }
                  checksum_sha256: { type: string }
                  generated_at: { type: string, format: date-time }

  schemas:
    ConnectHealth:
      type: object
      required: [status, app, checked_at, dependencies]
      properties:
        status: { type: string, enum: [ok, degraded, down] }
        app: { type: string, const: pactivo }
        checked_at: { type: string, format: date-time }
        dependencies:
          type: array
          items:
            type: object
            required: [name, status, latency_ms]
            properties:
              name: { type: string }
              status: { type: string, enum: [ok, degraded, down] }
              latency_ms: { type: integer, minimum: 0 }
              detail: { type: string }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.

    PartnerProvisionRequest:
      type: object
      required: [external_account_id, organization_name, owner_rajoka_sub]
      properties:
        external_account_id:
          type: string
          minLength: 1
          maxLength: 200
          description: Producer idempotency identity, scoped to the verified partner.
        organization_name: { type: string, minLength: 1, maxLength: 200 }
        owner_rajoka_sub:
          type: string
          description: Stable Rajoka ID subject. An OWNER-role membership is required before this can support attach_pending.
        registration_number:
          type: [string, "null"]
          minLength: 1
          maxLength: 32
          description: Canonical UK Companies House number. Short numeric input is zero-padded; malformed supplied values are rejected.
        email_domain:
          type: [string, "null"]
          description: Tie-break hint only; never sufficient identity evidence.
        postcode:
          type: [string, "null"]
          description: Accepted as non-identity metadata; never consulted by fingerprint matching.

    PartnerMembershipRevoked:
      type: object
      additionalProperties: false
      required: [event_id, event_type, organization_id, external_account_id, occurred_at]
      properties:
        event_id: { type: string, minLength: 1 }
        event_type: { type: string, const: membership.revoked }
        organization_id: { type: string, format: uuid }
        external_account_id: { type: string, minLength: 1, maxLength: 200 }
        occurred_at: { type: string, format: date-time }

    PartnerProvisionResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [status, organization_id]
          properties:
            status: { type: string, enum: [created, attached, created_flagged, attach_pending, attach_declined] }
            organization_id: { type: [string, "null"], format: uuid }
            attach_request_id: { type: string }
            decision: { type: string, enum: [declined] }
            cooldown_until: { type: string, format: date-time }
            billing_owner: { type: string, enum: [partner_metered] }
            replayed: { type: boolean }

    PartnerUsageResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [from, to, organizations]
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
            organizations:
              type: array
              items:
                type: object
                required: [organization_id, external_account_id, envelopes_created, envelopes_completed]
                properties:
                  organization_id: { type: string, format: uuid }
                  external_account_id: { type: string }
                  envelopes_created: { type: integer, minimum: 0 }
                  envelopes_completed: { type: integer, minimum: 0 }

    PartnerMembershipResponse:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            type: object
            required: [organization_id, external_account_id, access_state, billing_owner, partner_access_state]
            properties:
              organization_id: { type: string, format: uuid }
              external_account_id: { type: string }
              access_state: { type: string, enum: [active, revoked, repointed] }
              billing_owner: { type: string, enum: [partner_metered, direct] }
              partner_access_state: { type: string, enum: [active, retained_read_only, none] }

    EnvelopeStatus:
      type: string
      enum: [draft, prepared, sent, viewed, partially_signed, completed, declined, voided, expired, changes_requested]

    FieldType:
      type: string
      enum: [signature, initials, date_signed, text, checkbox]

    CreateEnvelope:
      type: object
      required: [title, recipients, documents]
      properties:
        title: { type: string }
        message:
          type: string
          description: Personal message included in the signer email.
        recipients:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/CreateRecipient" }
        documents:
          type: array
          minItems: 1
          items:
            type: object
            required: [file_name, content_base64]
            properties:
              file_name: { type: string }
              content_base64:
                type: string
                description: Base64-encoded PDF. Max 15 MB decoded; 40 MB total per envelope.
        fields:
          type: array
          items: { $ref: "#/components/schemas/Field" }
        signing_order:
          type: string
          enum: [any, sequential]
          default: any
        expires_at:
          type: string
          format: date-time
          description: ISO-8601. Envelope auto-expires past this instant.
        verification:
          type: object
          description: Signer identity challenge before the document opens.
          properties:
            required: { type: boolean, default: false }
            type:
              type: string
              enum: [otp_email, otp_sms, otp_whatsapp, email_otp, sms_otp, whatsapp_otp, none]
              description: Canonical values are otp_email, otp_sms, and otp_whatsapp; the *_otp aliases are also accepted. Use none to disable verification.
            policy: { type: object }
        external_ref:
          type: object
          description: Your own correlation data, echoed back on reads and webhooks.
        options:
          type: object
          description: >
            Whitelisted behaviour switches: completed_copy_mode,
            completed_copy_delivery_type, evidence_certificate_delivery_mode,
            allow_browser_download_on_complete, reminder_frequency_days,
            max_reminders, signature_mode, presence_mode. Unknown keys are
            ignored.

    CreateRecipient:
      type: object
      required: [email]
      description: Provide either the legacy name or first_name. Legacy name-only requests remain supported.
      properties:
        name: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        email:
          type: string
          format: email
          description: Required — signing links are delivered by email (SMS is not yet available).
        phone: { type: string }
        role:
          type: string
          enum: [signer, cc]
          default: signer
        order:
          type: integer
          description: Position in a sequential signing order.

    Field:
      type: object
      required: [document_index, recipient_index, page, x, y, width, height]
      properties:
        document_index:
          type: integer
          description: Index into the envelope's `documents` array.
        recipient_index:
          type: integer
          description: Index into the envelope's `recipients` array.
        type: { $ref: "#/components/schemas/FieldType" }
        page: { type: integer, minimum: 1 }
        x: { type: number }
        y: { type: number }
        width: { type: number }
        height: { type: number }
        required: { type: boolean }
        label: { type: string }

    EnvelopeSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        display_reference: { type: string, example: PV-000001 }
        title: { type: string }
        status: { $ref: "#/components/schemas/EnvelopeStatus" }
        signing_order: { type: string, enum: [any, sequential] }
        sent_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        expires_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        recipients:
          type: array
          items: { $ref: "#/components/schemas/RecipientSummary" }
        has_artifacts: { type: boolean }

    Envelope:
      allOf:
        - $ref: "#/components/schemas/EnvelopeSummary"
        - type: object
          description: Full row plus child collections.
          properties:
            recipients:
              type: array
              items: { $ref: "#/components/schemas/Recipient" }
            documents:
              type: array
              items: { $ref: "#/components/schemas/Document" }
            artifacts:
              type: array
              items: { $ref: "#/components/schemas/Artifact" }

    RecipientSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        role: { type: string, enum: [signer, cc] }
        snapshot_first_name: { type: [string, "null"] }
        snapshot_last_name: { type: [string, "null"] }
        snapshot_name:
          type: string
          description: Recipient name as frozen at creation (wire field is snapshot_name, not name).
        snapshot_email: { type: string }
        status: { type: string }
        signed_at: { type: [string, "null"], format: date-time }

    Recipient:
      allOf:
        - $ref: "#/components/schemas/RecipientSummary"
        - type: object
          properties:
            recipient_order: { type: integer }
            snapshot_phone: { type: [string, "null"] }
            verification_status: { type: [string, "null"] }
            viewed_at: { type: [string, "null"], format: date-time }
            declined_at: { type: [string, "null"], format: date-time }
            reminder_count: { type: integer }
            last_reminder_at: { type: [string, "null"], format: date-time }
            latest_email_status:
              type: [string, "null"]
              enum: [accepted, delivered, delivery_delayed, bounced, complained, failed, null]
              description: >-
                Latest delivery status of the most recent email to this recipient,
                from the email provider's delivery webhook. `accepted` means the
                provider took the message but has not yet confirmed the inbox;
                `bounced`/`complained`/`failed` mean it did not arrive. Null until
                a delivery record exists.
            latest_email_detail:
              type: [object, "null"]
              additionalProperties: true
              description: Provider payload extract for the latest status (bounce subtype, reason).

    Document:
      type: object
      properties:
        id: { type: string, format: uuid }
        upload_order: { type: integer }
        original_file_name: { type: string }
        source_format: { type: string }
        page_count: { type: [integer, "null"] }
        checksum_sha256:
          type: string
          description: SHA-256 of the frozen document bytes, recorded at send.
        is_frozen: { type: boolean }
        conversion_status: { type: string }

    Artifact:
      type: object
      properties:
        id: { type: string, format: uuid }
        artifact_type: { type: string, enum: [signed_pdf, evidence_certificate] }
        checksum_sha256: { type: string }
        generated_at: { type: string, format: date-time }

    SendDeliverySummary:
      type: object
      required: [attempted, delivered, failed, undelivered]
      properties:
        attempted:
          type: integer
          description: Number of signer emails attempted for this send.
        delivered:
          type: integer
          description: Number of signer emails accepted by the email provider.
        failed:
          type: integer
          description: Number of attempted signer emails that were not delivered.
        undelivered:
          type: array
          items:
            type: object
            required: [recipient_id, snapshot_email, reason]
            properties:
              recipient_id: { type: string, format: uuid }
              snapshot_first_name: { type: [string, "null"] }
              snapshot_last_name: { type: [string, "null"] }
              snapshot_name: { type: [string, "null"] }
              snapshot_email: { type: string, format: email }
              reason: { type: string }

    AuditEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        event_seq:
          type: integer
          description: Trigger-assigned, strictly increasing per envelope.
        event_type: { type: string, example: recipient.signed }
        actor_type: { type: string }
        actor_id: { type: [string, "null"] }
        recipient_id: { type: [string, "null"], format: uuid }
        summary: { type: string }
        metadata: { type: object }
        ip_address: { type: [string, "null"] }
        occurred_at: { type: string, format: date-time }
        prev_hash:
          type: [string, "null"]
          description: Hash of the previous event — the chain link.
        row_hash:
          type: string
          description: SHA-256 over this event's canonical content + prev_hash.

    TemplateSummary:
      type: object
      description: >
        List shape. `GET /templates/{id}` returns the full template row
        including its fields and stored documents.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        recipient_count: { type: integer }
        field_count: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
