# Accordsign Public API — v1 (Phase A read surface + Phase B webhooks)
#
# HAND-SCOPED, NOT GENERATED OVER THE HOST. Nivid's ruling, 2026-08-05: a spec covering only the
# public endpoints, rather than Swagger over everything the app exposes. Swagger is Development-only
# in the other hosts by a deliberate prod-recon decision, and generating a spec across this host
# would undo that by publishing every route it happens to serve.
#
# The cost of hand-scoping is that this file can drift from the code. That is accepted here because
# the alternative publishes more than intended, and the field names are pinned by tests in
# ApiContractTests — if a wire name changes, those fail before this file is wrong for long.
#
# Spelling is "Accordsign" per brand rules. No UIDAI marks appear anywhere in this surface.

openapi: 3.0.3

info:
  title: Accordsign Public API
  version: "1.7.0"
  description: |
    Send, track and retrieve Accordsign envelopes from your own systems.

    **Availability.** Growth and Enterprise plans only. Every other plan — Individual, Trial
    and Starter — receives `403 forbidden` with code `forbidden` on every endpoint, including
    for a key that was valid before a downgrade.

    **Authentication.** `Authorization: Bearer as_live_...`. Keys are created from the main
    account only and are shown exactly once — Accordsign stores a hash and cannot recover the
    key for you. Rotate by creating a new key and revoking the old one.

    **Rate limits.** Per key, per minute: Growth 60, Enterprise 180. Every response to an
    ACCEPTED key carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`;
    a breach returns `429` with `Retry-After`. They are absent where there is no bucket to
    report: `401` and `403`, and the open endpoints `/v1/health` and `/v1/openapi.yaml`.
    That is deliberate — a rejected credential has no limit, and reporting one would tell a
    caller whether the key it tried actually exists.

    **This document.** Served at `https://api.accordsign.app/v1/openapi.yaml`, without a key,
    by the deployment it describes — so the contract you fetch is always the one that host
    actually implements.

    **Sending.** `POST /v1/envelopes` creates and sends an envelope from a template.
    Sending is **asynchronous**: the call returns `201` with the envelope in `draft`, and
    Accordsign prepares the document and emails the first recipient within a few seconds —
    the envelope moves to `in_progress` and the `envelope.sent` webhook fires. Poll
    `GET /v1/envelopes/{id}` or subscribe to webhooks; do not treat a `draft` in the 201 body
    as a failure.

    **Aadhaar templates.** Templates that require Aadhaar/KYC verification can be sent through
    the API. Identity verification happens when the recipient signs, so the send itself behaves
    like any other template send; the recipient's step reports as `sent` until they finish.
    Two conditions are refused with `422`, matching the Accordsign app: the account must be
    India-based (`aadhaar_not_available`), and every signer role on the template must have Aadhaar
    enabled (`aadhaar_requires_all_signers`) — approvers are exempt. The verification vendor is
    taken from your account settings; it is not selectable per request.

    On an Aadhaar template send, every field you place must be `aadhaar_signature` — an
    Aadhaar-verified signer signs through the verification journey, and any other field type is
    refused with `aadhaar_field_required`. Conversely, `aadhaar_signature` on a send that is not
    Aadhaar-verified is refused with `aadhaar_field_not_applicable`.

    **Revoking.** `POST /v1/envelopes/{id}/void` withdraws an envelope that is still in progress,
    whether nobody has signed or only some parties have. Signing links stop working, anyone still
    outstanding is emailed, and signatures already collected are kept as a record.
    ⚠️ **A revoked envelope still counts towards your document allowance.** It is not refunded —
    the document was spent when the envelope was sent, not when it was withdrawn.

    # Webhooks

    Register one endpoint with `POST /v1/webhooks` and Accordsign posts a signed JSON event to
    it as envelopes move. One endpoint per account: registering again replaces the previous URL
    rather than adding a second.

    ## Events

    | `type` | Emitted when | Carries `recipient` |
    |---|---|---|
    | `envelope.viewed` | A recipient opens the signing link for the first time. Once per recipient. | yes |
    | `envelope.signed` | A recipient completes their part. Once per recipient. | yes |
    | `envelope.declined` | A recipient declines. | yes |
    | `envelope.completed` | The last required signature landed and the final document exists. | no |
    | `envelope.expired` | The envelope passed its expiry without completing. | no |
    | `envelope.voided` | The sender revoked the envelope before it completed. Terminal. | no |
    | `envelope.sent` | An envelope created with `POST /v1/envelopes` was dispatched — document prepared, first request mail sent. Envelopes sent from the Accordsign app do not emit this event yet. | no |

    `envelope.completed` is emitted once the completed document has been generated and sealed, so
    an envelope or certificate read on this event returns final data rather than racing its
    creation. Downloading the document is the exception: the file reaches storage a moment later,
    so `GET /v1/envelopes/{id}/document` can answer `202` to a fast subscriber — honour
    `Retry-After` there.

    ## Payload

    The body is deliberately thin — an identifier and enough context to decide whether to
    fetch, and nothing more. Fetch `GET /v1/envelopes/{id}` for the full picture. A fat payload
    would be a second API surface to version, and it would push recipient data to a URL that may
    be misconfigured.

    ```json
    {
      "id": "evt_9f2c1a7b3e5d8c0142ab76fe93cd10b8e4f7a2c6",
      "type": "envelope.signed",
      "created": "2026-08-10T09:14:22Z",
      "data": {
        "envelope_id": "6f1c9a52-8d3b-4a71-9f0e-2c5b7d41e830",
        "envelope_number": "ENV-6F1C9A52",
        "status": "in_progress",
        "recipient": {
          "id": "b28e4d71-05fa-4c93-8e17-9d6a3f0c4512",
          "email": "priya@example.com",
          "type": "signer"
        }
      }
    }
    ```

    The `recipient` key is **absent entirely** on envelope-level events, not null.

    ## Verifying the signature

    Every delivery carries:

    ```
    X-AccordSign-Signature: t=1754759524,v1=<hex sha256>
    ```

    `v1` is `HMAC-SHA256("{t}.{raw request body}")` under your signing secret, hex-encoded
    lowercase. Verify against the **raw body bytes** — re-serialising the parsed JSON changes
    the bytes and the signature will not match.

    ```js
    const crypto = require("crypto");

    function verify(rawBody, header, secret) {
      const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
      const drift = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
      if (drift > 300) return false;                       // reject replays, in both directions
      const expected = crypto.createHmac("sha256", secret)
                             .update(`${parts.t}.${rawBody}`)
                             .digest("hex");
      return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
    }
    ```

    The timestamp is signed, not merely sent — that is what makes a captured request
    unreplayable outside the tolerance window. Reject anything more than **5 minutes** out in
    either direction, and compare in constant time.

    Two more headers ride along for your own logs: `X-AccordSign-Event-Id`,
    `X-AccordSign-Event-Type` and `X-AccordSign-Attempt` (1-based).

    ## Responding

    Return any `2xx` once you have durably accepted the event; do your own work afterwards.
    You have **10 seconds** before the attempt is recorded as a timeout.

    Return `410 Gone` to decommission an endpoint: we stop retrying that delivery immediately
    instead of working through the schedule below.

    ## Retries and disablement

    **10 attempts** — the first with no backoff, then `+1m, +5m, +15m, +30m, +1h, +2h, +4h,
    +8h, +8h`: 23 hours 51 minutes end to end, deliberately just inside a day, so an endpoint
    broken in the morning is disabled and reported before the next one. Attempts are **counted,
    not timed**, so an outage on our side cannot silently consume your retry budget.

    ⚠️ **Those offsets are backoff, not wall-clock latency.** An event is queued due immediately,
    but it is sent by a delivery pass that runs on a short interval — in practice the first
    attempt arrives **within about two minutes** of the event, not within milliseconds. Every
    later offset is measured from the attempt before it and is subject to the same granularity.
    Do not build anything that assumes a webhook beats your own `POST /v1/envelopes` response
    back, and do not treat a gap of a minute or two as a failed delivery. If you need state
    sooner than that, read it: `GET /v1/envelopes/{id}` is authoritative at all times.

    This is deliberate. Delivery is out of band from signing, so a slow or hanging endpoint of
    yours can never add latency to a signature or hold up a signer.

    When the budget is exhausted the delivery is abandoned, the endpoint is **disabled**, and
    the account owner is emailed. A disabled endpoint receives nothing further. Re-enable it
    with `POST /v1/webhooks/enable` — this does **not** replay what was missed; reconcile with
    `GET /v1/envelopes`.

    `consecutive_failures` on `GET /v1/webhooks` is your early warning. It climbs before the
    disablement, not after.

    ## Duplicates and ordering

    Delivery is **at-least-once**. We can crash after your server returned `200` but before we
    record it, and you will then see the event again. `id` is stable for a given lifecycle
    transition — de-duplicate on it, and treat handlers as idempotent.

    Order is not guaranteed. Do not infer envelope state from event arrival order; `status` in
    the payload, or a fetch, is authoritative.

    ## Finished envelopes

    Once an envelope reaches a terminal status — `signed`, `declined`, `void`, `cancelled` or
    `expired` — it emits **no further progress events**. The signing link stays reachable and
    still renders (showing the revoked or expired notice), but opening it produces no
    `envelope.viewed`, and nothing produces a later `envelope.sent` or `envelope.signed`. The one
    event a finished envelope does emit is the one announcing its own ending: `envelope.completed`
    arrives with `status: signed`, `envelope.voided` with `void`, and `envelope.expired` with
    `expired`.


    ## The signing secret

    Returned exactly twice — by `POST /v1/webhooks` and `POST /v1/webhooks/rotate-secret` — and
    never again. Accordsign cannot recover it for you.

    Rotation is an **instant cutover**: deliveries are signed with the new secret the moment the
    call returns, so deploy it as a coordinated change rather than background hygiene. Rotating
    does not re-enable a disabled endpoint.

servers:
  - url: https://api.accordsign.app
    description: Production

security:
  - bearerAuth: []

paths:
  /v1/health:
    get:
      summary: Liveness
      description: Returns 200 when the API is serving. No key required and no data returned.
      security: []
      responses:
        "200":
          description: Serving.

  /v1/envelopes:
    post:
      summary: Create and send an envelope
      description: |
        Creates and sends an envelope from ONE of two sources — pass exactly one of:
          - `template_id`: one of your templates, filling each role with a real recipient, and
            placing the fields for this send in the request itself;
          - `document_id`: a document you uploaded with `POST /v1/documents` (status `ready`),
            with the recipients and field placements in the request itself. Uploaded documents
            are **single-use**: one document sends one envelope, ever; a concurrent second send
            of the same document loses with `422` code `document_already_used`.

        Both modes place fields in the request. A template supplies the document and the ROLES;
        it does NOT carry field positions, because those are placed per use — the web wizard places
        them at send time and an API caller does the same thing here. **Every signer must have at
        least one field in both modes**, or the request is refused `422 signer_without_fields`
        before anything is created or charged. A send that produced a signer with nothing to sign
        would spend a document credit and mail that person a document they cannot complete.

        In document mode: every recipient carries `type` (`signer` | `approver` | `cc`);
        `sequence` is optional but all-or-none across signers/approvers (cc recipients are
        outside the signing order); fields belong to signers only, every signer needs at least
        one, and coordinates are `x`/`y` percent of the page (0-100) with `width`/`height` in
        pixels against the page dimensions `GET /v1/documents/{id}` reports. The envelope's
        subject is the uploaded filename. Unusable documents refuse with an actionable code:
        `document_not_found`, `document_not_ready` (poll and retry), `document_failed`,
        `document_expired`, `document_already_used`. **Asynchronous**: the response is `201` with the envelope in `draft`;
        within a few seconds Accordsign prepares the document, emails the first recipient (or
        every signer at once when the template has a single role), moves the envelope to
        `in_progress` and emits `envelope.sent`. One document is consumed from the key owner's
        allocation per send; a send that fails before dispatch is refunded automatically.

        **Idempotency.** Send an `Idempotency-Key` header (any string up to 200 characters,
        unique per logical send) and retries are safe: a replay returns the ORIGINAL envelope
        with `201` and never sends twice. A replay while the original is still being processed
        returns `409` code `request_in_progress`. A failed original replays as one of THREE
        distinct outcomes — read the code before acting:
          - `request_failed`: it failed BEFORE anything was dispatched and its charge is fully
            settled (refunded, or never taken). Safe to send again with a NEW key.
          - `request_settling`: it failed before dispatch but its charge is still being settled
            automatically. Do **not** use a new key yet — retry this request shortly; once
            settlement completes the replay becomes `request_failed`.
          - `request_failed_after_dispatch`: the envelope WAS created, dispatched and charged —
            only the invite mail failed (or its state could not be verified). Do **not** send
            again; that would create and charge a second envelope. The message names the
            envelope id — inspect it with `GET /v1/envelopes/{id}`.
        Without the header every call is a new send.

        **Validation** failures return `422` with `code`, `message` and a `fields` array whose
        `path` entries point into your payload (`recipients[1].email`). Every template role must
        be filled exactly once, by the role `name` GET /v1/templates publishes (case-insensitive;
        a template with two roles of the same name fills them in request order).

        **Quota.** An exhausted document allocation returns `429` with code `quota_exceeded`
        (no `Retry-After` — allocations do not replenish on a clock). An expired subscription
        returns `403`.
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 200 }
          description: Unique per logical send. Strongly recommended — it is what makes retries safe.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateEnvelopeRequest" }
      responses:
        "201":
          description: |
            Created — the envelope, in `draft` while dispatch completes. `Location` points at
            `GET /v1/envelopes/{id}`. A replayed `Idempotency-Key` also lands here, with the
            original envelope's CURRENT representation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "400":
          description: |
            The body could not be read — malformed JSON or an unknown member. Same envelope as
            every other error, with `fields` naming what failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            An earlier request holds this `Idempotency-Key`: `request_in_progress` (retry
            shortly for its envelope), `request_failed` (nothing was dispatched and the charge
            is settled — send again with a new key), `request_settling` (the charge is still
            being settled — retry; do NOT use a new key yet), or `request_failed_after_dispatch`
            (the envelope is LIVE and charged, or its state could not be verified — do NOT
            resend; the message names its id).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: The request was understood but cannot be processed. See `error.fields`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "429":
          description: |
            Rate limit exceeded (`rate_limited`, with `Retry-After`), or the document allocation
            is exhausted (`quota_exceeded`, without one).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "500":
          description: Nothing was sent and no document was consumed unless the body says otherwise.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

    get:
      summary: List envelopes
      description: |
        Newest first, cursor-paginated. Pass the `next_cursor` from the previous response as
        `cursor` to continue; `next_cursor` is null on the final page.

        Cursors are opaque — do not parse or construct them. An invalid cursor returns `400`
        rather than restarting from the beginning, so a paging loop cannot silently repeat.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, in_progress, signed, declined, void, cancelled, expired]
          description: An unrecognised value returns 400 rather than being ignored.
        - name: from_date
          in: query
          schema: { type: string, format: date }
        - name: to_date
          in: query
          schema: { type: string, format: date }
          description: Inclusive of the whole day named.
        - name: cursor
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: A page of envelopes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EnvelopeSummary" }
                  next_cursor: { type: string, nullable: true }
                  has_more: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/envelopes/{id}:
    get:
      summary: Retrieve an envelope
      description: |
        Full status including per-recipient state.

        An envelope belonging to another account returns `404`, not `403` — the two are
        deliberately indistinguishable so that ids cannot be enumerated.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/envelopes/{id}/void:
    post:
      summary: Revoke an envelope
      description: |
        Withdraws an envelope that is still `in_progress` — either nobody has signed, or some
        parties have and others have not. The signing links stop working immediately, every
        recipient who still owed an action is emailed, and any signatures already collected are
        retained as a record rather than discarded.

        ⚠️ **This does not refund the document.** A revoked envelope still counts towards your
        allowance: the document was spent when the envelope was sent, not when it was withdrawn.

        Terminal — there is no un-revoke. Send a new envelope instead, which spends a new document.

        Only an envelope that is `in_progress` and belongs to your account can be revoked.
        Everything else — already completed, declined, expired, already revoked, another account's,
        or simply unknown — answers `404`, deliberately indistinguishable so the endpoint cannot be
        used to discover envelope ids.
      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
                  maxLength: 500
                  description: Optional. Shown to the recipients you are withdrawing from, and recorded on the audit trail.
      responses:
        "200":
          description: The revoked envelope. Its `status` is `void`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/envelopes/{id}/certificate:
    get:
      summary: Retrieve the completion certificate
      description: |
        The ordered audit trail evidencing who acted, when, and from which address.

        Available only once the envelope is complete; before that it returns `409` with code
        `not_complete`, because a partial certificate would evidence nothing.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The certificate.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Certificate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The envelope is not complete yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/envelopes/{id}/document:
    get:
      summary: Download the signed document
      description: |
        The executed PDF for a completed envelope, as `application/pdf`, with the document's
        original filename in `Content-Disposition`.

        Available only once the envelope is complete; before that it returns `409` with code
        `not_complete`, the same rule the certificate follows.

        **A just-completed envelope is briefly signed but not yet stored.** The final document is
        generated, emailed, and only then written to storage, so for a short window this returns
        `202` with a `Retry-After` header. That is a retry, not a not-found.

        **Honour `Retry-After` even when reacting to the `envelope.completed` webhook.** That event
        fires as soon as the final document has been generated and sealed, which is earlier than the
        moment it becomes fetchable, so a fast subscriber can arrive inside this window.

        What you receive is the final sealed document. Envelopes signed with Aadhaar eSign are
        sealed by the eSign provider rather than by Accordsign — the provider's signature is
        preserved rather than signed over — so those carry the provider's seal instead.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The signed PDF.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "202":
          description: |
            The envelope is complete but its document has not reached storage yet. Retry after
            the number of seconds in `Retry-After`.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds to wait before retrying.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DocumentPreparing" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The envelope is not complete yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "501":
          description: |
            Signed-document retrieval is not enabled in this environment. Terminal — retrying
            will not help.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/documents:
    post:
      summary: Upload a document for an ad-hoc send
      description: |
        Uploads one PDF or DOCX (multipart/form-data, one file part, **20 MB max**) for a
        templateless send. **Asynchronous**: the response is `201` with status `processing`
        while Accordsign renders the pages; poll `GET /v1/documents/{id}` until `ready`, then
        send it with `POST /v1/envelopes` + `document_id`.

        Documents are **single-use** (one document, one envelope) and unsent documents expire
        after **7 days**, after which their assets are deleted — upload again. Only `.pdf` and
        `.docx` are accepted; anything else is refused before any state is created.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "201":
          description: Accepted for processing. `Location` points at `GET /v1/documents/{id}`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiDocument" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413":
          description: The request was refused by the transport before processing (over ~20 MB).
        "422":
          description: |
            No file part, an unsupported type (`Only PDF and DOCX…`), an empty file, or a file
            that exceeded 20 MB mid-stream.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500":
          description: Nothing was stored; upload again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/documents/{id}:
    get:
      summary: Retrieve a document's processing state
      description: |
        The status plus — once `ready` — the page metadata field placement needs: `page_count`,
        and `page_width`/`page_height` in pixels, the reference for every field's
        `width`/`height`. A document belonging to another account answers `404`,
        indistinguishable from one that never existed.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The document.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiDocument" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/templates:
    get:
      summary: List templates
      description: |
        The templates this key can send from, with the roles each one expects — the role `name`
        and `sequence` are what `POST /v1/envelopes` takes. Alphabetical by name,
        cursor-paginated like the envelopes list.
      parameters:
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque; from the previous response's `next_cursor`.
        - name: limit
          in: query
          schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: The templates.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
                  next_cursor: { type: string, nullable: true }
                  has_more: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks:
    get:
      summary: Retrieve the registered endpoint
      description: |
        The endpoint, its health, and the last delivery error.

        The signing secret is **not** included and there is no endpoint that returns it. That is
        what makes this response safe to log: it cannot hand anyone the ability to forge a
        signature.
      responses:
        "200":
          description: The endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NoWebhook" }
        "429": { $ref: "#/components/responses/RateLimited" }

    post:
      summary: Register or replace the endpoint
      description: |
        One endpoint per account, so this replaces any existing registration rather than adding
        a second. Replacing also clears the failure state and re-enables a disabled endpoint —
        pointing at a new URL says the old failures describe nothing.

        The URL must be absolute and **https**; payloads carry envelope details and a signature
        header, neither of which may travel in clear text. Maximum 500 characters — an
        over-length URL is rejected rather than truncated, because a `200` from the wrong host
        looks exactly like success.

        ⚠️ The response carries the signing secret. Store it now; it is shown once.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterWebhookRequest" }
      responses:
        "200":
          description: Registered. The body carries the signing secret, once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSecret" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

    delete:
      summary: Stop receiving webhooks
      description: |
        Removes the endpoint. Deliveries already queued are left in place and become
        unclaimable, so they stop without destroying the record of what was pending.
      responses:
        "204":
          description: Deleted. No body.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NoWebhook" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks/rotate-secret:
    post:
      summary: Mint a new signing secret
      description: |
        ⚠️ **Instant cutover.** Deliveries are signed with the new secret the moment this
        returns, so a receiver still verifying with the old one will reject them. Treat rotation
        as a coordinated deploy, not background hygiene.

        Rotating does not re-enable a disabled endpoint — that would hide the disablement.
      responses:
        "200":
          description: Rotated. The body carries the new secret, once.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookSecret" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NoWebhook" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks/enable:
    post:
      summary: Re-enable a disabled endpoint
      description: |
        Clears the failure counter and resumes delivery.

        ⚠️ **Missed events are not replayed.** Deliveries abandoned while the endpoint was
        disabled stay abandoned — replaying a day of events into a freshly repaired endpoint is
        how a recovery becomes a second outage. Reconcile with `GET /v1/envelopes` instead.
      responses:
        "200":
          description: Enabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook: { $ref: "#/components/schemas/Webhook" }
                  message:
                    type: string
                    description: States plainly that missed events were not replayed.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NoWebhook" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Your API key, e.g. `as_live_...`.

  responses:
    BadRequest:
      description: The request could not be understood.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: |
        The key is missing, unknown or revoked. The response does not distinguish between those
        cases on purpose.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The key is valid but the account's plan does not include API access.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such envelope, or it belongs to another account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NoWebhook:
      description: No webhook endpoint is registered for this account.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: The per-minute limit for this key was exceeded.
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      description: Every error uses this envelope. Branch on `code`, show `message`.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [unauthorized, forbidden, rate_limited, quota_exceeded, not_found, not_complete, invalid_request, server_error, request_in_progress, request_failed, request_failed_after_dispatch, request_settling]
            message: { type: string }

    ValidationError:
      type: object
      description: |
        The `422` envelope: the standard error plus per-field detail. `path` points into the
        request (`recipients[1].email`, `expires_at`, `Idempotency-Key`).
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [validation_failed, template_not_found, aadhaar_not_available, aadhaar_requires_all_signers, document_not_found, document_not_ready, document_failed, document_expired, document_already_used]
            message: { type: string }
            fields:
              type: array
              items:
                type: object
                properties:
                  path: { type: string }
                  code:
                    type: string
                    enum: [invalid_request, invalid_email, role_missing, role_unknown, role_duplicate, expiry_in_past, template_not_found, aadhaar_not_available, aadhaar_requires_all_signers, document_not_found, document_not_ready, document_failed, document_expired, document_already_used, recipient_type_unknown, sequence_invalid, no_signer, signer_without_fields, field_type_unknown, aadhaar_field_required, aadhaar_field_not_applicable, field_page_out_of_range, invalid_coordinates, invalid_dimensions, field_recipient_invalid]
                  message: { type: string }

    CreateEnvelopeRequest:
      type: object
      required: [recipients]
      additionalProperties: false
      description: |
        Exactly ONE of `template_id` and `document_id` is required. Unknown members are REJECTED
        with `400`, never silently ignored — a typo'd or unsupported field (e.g. `merge_fields`)
        must announce itself rather than produce an envelope missing what you thought you sent.
        Mode-mismatched members are refused the same way: `fields`, `type` and `sequence` belong
        to document mode; `role` belongs to template mode.
      properties:
        template_id:
          type: string
          format: uuid
          description: Template mode — a template id from `GET /v1/templates`.
        document_id:
          type: string
          format: uuid
          description: Document mode — a `ready` document from `POST /v1/documents`. Single-use.
        recipients:
          type: array
          description: |
            Template mode: one entry per template role, each with `role`/`name`/`email`.
            Document mode: the recipients themselves, each with `type`/`name`/`email` and an
            optional `sequence`.
          items:
            type: object
            additionalProperties: false
            properties:
              role:
                type: string
                description: Template mode only — the role's `name`, matched case-insensitively.
                example: Buyer
              name: { type: string, example: Priya Sharma }
              email: { type: string, format: email, example: priya@example.com }
              type:
                type: string
                enum: [signer, approver, cc]
                description: Document mode only.
              sequence:
                type: integer
                minimum: 1
                description: |
                  Document mode only, optional signing order. All-or-none across signers and
                  approvers, unique when present; never on cc recipients.
        fields:
          type: array
          description: >
            The fields to place, in BOTH modes. At least one per signer, or the request is
            refused with `signer_without_fields`. `recipient` is the zero-based index into this
            request's `recipients` array; in template mode that recipient's ROLE decides which
            signer the field belongs to. Fields belong to signers only — approvers and cc
            recipients hold none.
          items:
            type: object
            required: [type, page, x, y, width, height, recipient]
            additionalProperties: false
            properties:
              type:
                type: string
                enum: [signature, initial, name, email, company, title, signed_date, stamp, textbox, aadhaar_signature]
              page:
                type: integer
                minimum: 1
                description: 1-based, within the document's `page_count`.
              x: { type: number, minimum: 0, maximum: 100, description: Percent of the page width. }
              y: { type: number, minimum: 0, maximum: 100, description: Percent of the page height. }
              width: { type: integer, minimum: 1, description: Pixels against the reported page width. }
              height: { type: integer, minimum: 1, description: Pixels against the reported page height. }
              recipient:
                type: integer
                minimum: 0
                description: Zero-based index into `recipients`. Signers only.
        expires_at:
          type: string
          format: date-time
          description: |
            Optional envelope expiry. Must be in the future. Absent = no expiry.

            **Send an explicit offset** — `2026-09-01T18:30:00Z` or `2026-09-01T18:30:00+05:30`.
            A value with no offset is read in the Accordsign server's own time zone, not UTC.

    ApiDocument:
      type: object
      description: An uploaded document's processing state (POST /v1/documents and GET).
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, ready, failed, expired]
          description: |
            `processing` covers the whole pipeline (staging + rasterization). `ready` documents
            carry page metadata and can be sent. `failed` and `expired` documents cannot; upload
            again. Unused `ready` documents expire after 7 days and their assets are deleted.
        filename: { type: string }
        page_count: { type: integer, nullable: true, description: Present at `ready`. }
        page_width: { type: integer, nullable: true, description: Pixels; the field-coordinate reference. Present at `ready`. }
        page_height: { type: integer, nullable: true, description: Pixels. Present at `ready`. }
        envelope_id:
          type: string
          format: uuid
          nullable: true
          description: The envelope this document sent, once used (documents are single-use).
        failure_reason: { type: string, nullable: true, description: Present at `failed`. }
        created_at: { type: string, format: date-time }

    EnvelopeSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [draft, in_progress, signed, declined, void, cancelled, expired, unknown]
        subject: { type: string, nullable: true }
        created_at: { type: string, format: date-time, nullable: true }
        expires_at:
          type: string
          format: date-time
          nullable: true
          description: |
            **Returned exactly as stored, with no zone offset — do not assume UTC.** This field is
            shared with the Accordsign web app: envelopes created through this API hold the expiry
            in UTC, while envelopes created in the app hold the sender's local wall-clock time as
            they picked it. Both come back without a designator, so the zone depends on where the
            envelope was created. Contrast `created_at`, which is UTC at source and always carries
            a `Z`.
        signer_sequence_enabled: { type: boolean }
        source_template_id: { type: string, format: uuid, nullable: true }

    Envelope:
      allOf:
        - $ref: "#/components/schemas/EnvelopeSummary"
        - type: object
          properties:
            message: { type: string, nullable: true }
            totals:
              type: object
              properties:
                signers: { type: integer }
                signed: { type: integer }
            recipients:
              type: array
              items: { $ref: "#/components/schemas/Recipient" }

    Recipient:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        email: { type: string, nullable: true }
        role:
          type: string
          enum: [signer, approver, cc, unknown]
        status:
          type: string
          enum: [pending, sent, viewed, signed, declined, approved, cancelled, unknown]
          description: |
            Aadhaar verification steps report as `sent` — the recipient holds the envelope and has
            not finished with it.
        sequence: { type: integer }
        decline_reason:
          type: string
          nullable: true
          description: Present only when this recipient declined.
        last_event_at:
          type: string
          format: date-time
          nullable: true
          description: Null until something has happened to this recipient.

    DocumentPreparing:
      type: object
      description: |
        Returned with 202 when the envelope is complete but its signed document has not reached
        storage yet. Not an error envelope — nothing has gone wrong.
      properties:
        status: { type: string, example: preparing }
        retry_after_seconds: { type: integer, example: 5 }

    Certificate:
      type: object
      properties:
        envelope_id: { type: string, format: uuid }
        status: { type: string }
        events:
          type: array
          items:
            type: object
            properties:
              recipient_id: { type: string, format: uuid }
              status: { type: string }
              occurred_at: { type: string, format: date-time }
              detail: { type: string, nullable: true }
              ip_address: { type: string, nullable: true }

    Template:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        description: { type: string, nullable: true }
        shared: { type: boolean }
        roles:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string, nullable: true }
              role:
                type: string
                enum: [signer, approver, cc, unknown]
              sequence: { type: integer }

    RegisterWebhookRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 500
          description: Absolute, https only.
          example: https://example.com/hooks/accordsign

    Webhook:
      type: object
      description: The endpoint as you see it. Deliberately secret-free.
      properties:
        url: { type: string, format: uri }
        status:
          type: string
          enum: [active, disabled]
          description: |
            `disabled` means the retry budget was exhausted and nothing is being delivered.
            Re-enable with `POST /v1/webhooks/enable`.
        consecutive_failures:
          type: integer
          description: |
            Your early warning. It climbs towards the limit while your endpoint is failing, so
            you can act before the disablement email rather than after it.
        last_success_at:
          type: string
          format: date-time
          nullable: true
          description: |
            UTC, always with a trailing `Z` — as are `last_failure_at`, `disabled_at` and
            `created_at` on this resource. Unlike the envelope resource's `expires_at`, which is
            deliberately zone-less, every timestamp here is recorded by Accordsign itself and is
            therefore unambiguously UTC.
        last_failure_at: { type: string, format: date-time, nullable: true }
        last_error:
          type: string
          nullable: true
          description: The last delivery error, verbatim, so you can debug without a support ticket.
        disabled_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    WebhookSecret:
      type: object
      description: |
        Returned only by registration and rotation. The `secret` here is the only copy that will
        ever leave Accordsign.
      properties:
        url: { type: string, format: uri }
        status:
          type: string
          enum: [active, disabled]
        secret:
          type: string
          description: Shown once. Begins `whsec_`.
          example: whsec_8Kd2mXqR7vNpL0aYcZ3wJfB6tH1sE4gU9iO5rT8yPmQ
        message:
          type: string
          description: Says in the body what the docs say, for whoever is reading a terminal rather than this page.

    WebhookEvent:
      type: object
      description: |
        The body POSTed to your endpoint. Not returned by any endpoint here — it is published so
        you can generate types against it.
      properties:
        id:
          type: string
          description: |
            `evt_` + 40 hex characters, stable for a given lifecycle transition. De-duplicate on
            it: delivery is at-least-once.
          example: evt_9f2c1a7b3e5d8c0142ab76fe93cd10b8e4f7a2c6
        type:
          type: string
          enum: [envelope.sent, envelope.viewed, envelope.signed, envelope.completed, envelope.declined, envelope.expired, envelope.voided]
          description: "`envelope.sent` is emitted for envelopes created with `POST /v1/envelopes`; app-sent envelopes do not emit it yet."
        created: { type: string, format: date-time }
        data:
          type: object
          properties:
            envelope_id: { type: string, format: uuid }
            envelope_number:
              type: string
              pattern: "^ENV-[0-9A-F]{8}$"
              example: ENV-6F1C9A52
              description: |
                The human-facing reference, `ENV-` followed by 8 uppercase hex characters. It is
                DERIVED from `envelope_id` — the first block of that uuid, uppercased — so it is
                stable, needs no lookup, and is the same string the signer sees on the signing page
                and quotes to support. It is **not** an identifier: address envelopes by
                `envelope_id`.
            status:
              type: string
              enum: [draft, in_progress, signed, declined, void, cancelled, expired, unknown]
            recipient:
              description: |
                Present for `envelope.viewed`, `envelope.signed` and `envelope.declined`.
                **Absent entirely** — not null — on envelope-level events.
              type: object
              properties:
                id: { type: string, format: uuid }
                email: { type: string }
                type:
                  type: string
                  enum: [signer, approver, cc, unknown]
