# SAIG Sign — API Reference (v1)

AI document generation + e-signature as an ecosystem service, hosted on the SAIG Console.
Base URL: `https://console.saig.page`. UI: `/sign` · Public signing: `/s/<token>`.
Published: 2026-07 · Conforms to SAIG Integration Guide v2 conventions.

## Authentication

| Caller | How |
|---|---|
| Console UI (humans) | `Authorization: Bearer <SAIG session JWT>` |
| Ecosystem apps | `x-saig-service-token: <token>` + `X-SAIG-App: <appkey>` |
| External recipients | The magic-link token in the URL — no account, single envelope only |

App-created envelopes are visible/manageable only by the same `X-SAIG-App`. Recipient
tokens are stored hashed (SHA-256); a token grants access to exactly one recipient's
view of one envelope and is superseded on every re-issue (remind/embed).

## Endpoints

Pretty REST paths rewrite to the gateway; the `?op=` form is equivalent.

| Method + path | op | Purpose |
|---|---|---|
| `POST /api/sign/generate` | `generate` | AI-draft a document → `{ title, content_md }` (no envelope) |
| `POST /api/sign/envelopes` | `create` | Create envelope `{ title, content_md, recipients[], routing, brainId?, requireCountersign?, webhookUrl?, branding?, expiresInDays? }` → `{ envelopeId }` |
| `POST /api/sign/envelopes/:id/send` | `send` | Mark sent + email due invitations (routing-aware) |
| `GET /api/sign/envelopes` | `list` | Caller's envelopes (UI) |
| `GET /api/sign/envelopes/:id` | `get` | Envelope + recipients + events + document meta |
| `POST /api/sign/envelopes/:id/remind` | `remind` | Re-issue tokens + resend to pending invited recipients |
| `POST /api/sign/envelopes/:id/void` | `void` | Void with reason (audited) |
| `GET /api/sign/envelopes/:id/document` | `document` | Signed PDF + signatures page + completion certificate (also works with `?token=` for recipients) |
| `GET /api/sign/envelopes/:id/embed-url?recipient=email` | `embed` | Short-lived signing URL for iframe/webview embedding (app auth) |
| `GET /api/sign?op=view&token=` | `view` | Recipient's signing payload (public) |
| `POST /api/sign?op=signit&token=` | `signit` | Sign: `{ signedName, consent:true }` — captures name, UTC time, IP, UA, doc SHA-256 |
| `POST /api/sign?op=decline&token=` | `decline` | Decline with reason |
| `GET/POST /api/sign/templates` | `templates` / `template_create` | Reusable templates (UI) |

`recipients[]`: `{ name, email, role: "signer"|"countersigner"|"cc", order? }`.
`brainId` (optional, strongly recommended for multi-tenant apps): scopes the envelope
to the tenant's **business** brain; persisted and returned on `get`. Present from
2026-07-10 — envelopes created earlier have `brain_id = null`.

## Tokens (shape)

Invitation links and `embed-url` return `/s/<token>` — the token is the **last path
segment**, not a query parameter. For programmatic or embedded signing, extract it and
call `?op=view&token=` / `?op=signit&token=` (`signit` is fully supported
programmatically: `{ signedName, consent:true }`). Tokens are single-envelope,
SHA-256-hashed at rest, and superseded on every re-issue (remind / embed-url).
`routing`: `parallel` (all signers at once) or `sequential` (invited in `order`).
Countersigners are invited only after every signer has signed. When all non-cc parties
have signed, the envelope completes: fresh access tokens are issued and every party is
emailed a link to the executed PDF + certificate.

## Legal-grade layout: tables, parties, execution block (#2099, 30 Oct 2026)

- **Tables.** A GFM pipe table in `content_md` (`| a | b |` rows under a `|---|---|` separator) renders as a ruled table with a bold header row, identically on the signing page and in the executed PDF.
- **Parties.** Pass `parties` on `create` — `[{ name, capacity, company, companyNumber, address, label, email }]`, up to 10 — and the document opens with `BETWEEN: (1) <name>, <capacity> of <company>, Company No. …, <address> (the "<label>") AND (2) …`. Stored on the envelope at creation, like `issuedBy`. Without `parties`, the signing recipients are listed unless the document already begins its own `BETWEEN` block.
- **Execution block.** The PDF's execution page carries one block per signer: `SIGNED by` / `COUNTERSIGNED by`, the party name with capacity and company (matched from `parties` by `email`, else by position), a signature line (the typed signature once signed), a date line, then the evidence lines (method, IP, hashes). The signing page shows the same block under the document.

## Webhooks (signature)

`webhookUrl` on `create` receives `POST` JSON `{ type, envelopeId, data, ts }` for `envelope.sent`, `recipient.viewed`, `recipient.signed`, `recipient.declined`, `envelope.completed`, `envelope.declined`, `envelope.voided`. Headers: `x-saig-event` (the type), `x-saig-envelope`, and `x-saig-signature` = HMAC-SHA256 hex of the raw body keyed with **your app's own `SAIG_APP_TOKEN`** when the envelope was created with an app token (`x-saig-signature-key: app-token`); otherwise with the legacy Sign secret (`x-saig-signature-key: legacy`). `x-saig-signature-legacy` carries the legacy HMAC alongside until 5 Nov 2026. Verify with a constant-time compare and fetch `GET /api/sign/envelopes/:id` to act on state, never on the webhook body alone.

## Merge fields

`{{name}}`, `{{email}}`, `{{date}}` in `content_md` are personalised per recipient at
view/PDF time. The stored document (and its SHA-256) is the un-merged source.

## Signer-entered fields (#1909, 22 Oct 2026)
Ask a signer for information only they know, such as a residential address, an ID number or a phone number, **before they can sign**. Sign checks what they enter: the server is the authority, and the signing page runs the same checks as they type. Envelopes without fields behave exactly as before.

**create**: add `fields` entries that have a `key` and no `page` (or pass them as `signerFields`, or give a `templateId` whose template declares `signerFields`):
```json
{ "fields": [
  { "key": "address", "label": "Your residential address", "type": "address", "recipient": "seller@example.com", "validate": { "country": "AE", "verify": true } },
  { "key": "eid", "label": "Emirates ID number", "type": "id_number", "validate": { "kind": "emirates_id" }, "prefill": "784-1990-1234567-6" },
  { "key": "mobile", "label": "Mobile number", "type": "phone", "validate": { "country": "AE" } } ],
  "country": "AE" }
```
- **Types:** `address` (structured: line1, line2, city, region, postcode, country; rejects junk such as `asdf`, `n/a`, `test`, digits only, or a missing street/city; postcode is required where the country uses one), `text`, `textarea` (`minLength`, `maxLength`, `pattern`), `email`, `phone` (stored as E.164, with the country default from `validate.country`), `date` (`min`, `max`, `minAge`), `number` (`min`, `max`), `choice` / `select` (`options`), `checkbox` (must be ticked if required), `id_number` (`kind`: `emirates_id` (784-YYYY-NNNNNNN-C plus check digit), `uk_company`, `passport`, `uk_nino`), `company_number`, `tax_id` (UK VAT, UAE TRN, generic), `file` / `upload` (PDF/JPEG/PNG/HEIC/WebP, `maxMb`, default 5).
- `recipient`: an email or an index (default: the first recipient). `required` defaults to true. `help` shows under the field. `prefill` shows what you already know; the signer checks it and corrects it rather than retyping.
- `validate.verify: true` (addresses) adds a plausibility check through SAIG AI's free models. It **never blocks** on its own. If it is unsure or says no, the entry is accepted and **flagged** in the audit and on the certificate.

**Signing**: `op=view` returns `inputs` (this signer's fields). `POST op=fields_check {token, fields}` checks without saving, giving `200` or `422 {errors:{key:message}}`. `signit` takes `fields: {key: value}`, re-checks everything and returns `422 {errors}` on any problem. Values are stored with the signature event, fingerprinted as `inputs_sha256` (on the recipient, in the `signed` event and on the certificate), and files go to private storage with their SHA-256. The app-attested signing route needs the same `fields` (files must use the signing link).

**Document**: put `{{field:<key>}}` in `content_md`. It is filled with the signer's entry in the signing view and the executed PDF; before it is filled it reads "[label — to be completed by name]". The signatures page lists each signer's entries, and the completion certificate lists which fields were asked, the entered-values hash and any flagged entries. `get` returns `recipients[].fields` (value, display, flagged, verify, filled_at), and the `recipient.signed` webhook carries `fields`, `fieldsSha256` and `flagged` (files as name/mime/bytes/sha256 only).

**Templates**: `template_create` accepts `signerFields`, so a template (for example an ADEA Seller Instruction) always asks for the same information.

## Webhooks

If `webhookUrl` is set, events POST as
`{ type, envelopeId, data, ts }` with headers `x-saig-event` and
`x-saig-signature: hex(hmac_sha256(body, SIGN_WEBHOOK_SECRET || SAIG_SERVICE_TOKEN))`.
Types: `envelope.sent`, `recipient.viewed`, `recipient.signed`, `recipient.declined`,
`envelope.completed`, `envelope.voided`, `envelope.expired`. Verify the signature before trusting payloads.

## Envelope lifecycle

`draft → sent → completed` · terminal alternatives: `declined`, `voided`, `expired`
(default 30 days). A 15-minute cron now sweeps overdue envelopes to `expired` (firing
`envelope.expired`) and sends reminder nudges to invited-but-unsigned recipients on a
72-hour cadence, max 3 per recipient, each with a fresh link (prior links superseded).

## Legal / evidence model (ESIGN·UETA·eIDAS-style SES)

Explicit consent checkbox before signing · clear intent ("Sign document") · signature
bound to the exact content via SHA-256 · per-signature capture of typed legal name,
UTC timestamp, IP and user-agent · append-only `sign_events` audit trail · certificate
of completion embedded in the final PDF. This implements simple-electronic-signature
practice; it is not legal advice.

## Add signing to your SAIG app in 30 minutes

1. Get `SAIG_SERVICE_TOKEN` provisioned (CI/CD protocol) and pick your `X-SAIG-App` key.
2. `POST /api/sign/envelopes` with your document markdown + recipients, keep `envelopeId`.
3. `POST /api/sign/envelopes/:id/send` — recipients get branded magic-link emails
   (pass `branding: { name, color }` to brand as your app).
4. Receive webhooks; on `envelope.completed`,
   `GET /api/sign/envelopes/:id/document` and store/attach the executed PDF.
5. Optional in-app signing: `embed-url` → open in an iframe/webview.

## Environment (Console project)

`SUPABASE_SERVICE_ROLE_KEY` · `RESEND_API_KEY` · `ANTHROPIC_API_KEY` (AI drafts) ·
`SAIG_SERVICE_TOKEN` · optional `SIGN_WEBHOOK_SECRET`, `SIGN_AI_MODEL`, `SIGN_BASE_URL`.
Schema: run `SETUP-SIGN.sql` once in the Console Supabase.
