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. Scopes:
      `pactivo.envelopes:read` (GET routes) and `pactivo.envelopes:manage`
      (create + lifecycle). The Connect workspace must be linked to a Pactivo
      organisation.

    ### 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`.

    ### 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 }`.
servers:
  - url: https://oesufgbdyhacjbvlvxfl.supabase.co/functions/v1/envelope-api
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Envelopes
  - name: Artifacts
  - name: Templates

paths:
  /envelopes:
    get:
      tags: [Envelopes]
      summary: List envelopes
      description: Org-scoped, newest-updated first.
      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.
      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: email_otp
      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
      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`. Any email failures are returned as `warnings`, never
        as a failed send.

        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.
      parameters: [{ $ref: "#/components/parameters/envelopeId" }]
      responses:
        "200":
          description: The envelope, now `sent`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Envelope" }
                  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.
      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.
      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.
      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.
      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.
      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 }
      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.
      parameters: [{ $ref: "#/components/parameters/envelopeId" }]
      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" }

  /templates:
    get:
      tags: [Templates]
      summary: List field templates
      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.
      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
      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
      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, or a Rajoka Connect access token
        (aud `pactivo`).

  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 }

  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" }
    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:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Human-readable description of what went wrong.

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

    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, example: email_otp }
            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: [name, email]
      properties:
        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_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 }

    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 }

    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 }
