# Waiverly REST API & Webhooks (v2.0)

Waiverly's API is **read-mostly, deliberately.** It answers the questions an integration legitimately
has — what templates exist, who has signed, is this person covered, is this record intact — and it
does not let anything sign on a person's behalf.

## Why signing is not an endpoint

A waiver's legal weight comes from a specific person, in front of a specific document *version*,
drawing a signature and ticking the acknowledgments themselves. Waiverly captures exactly that: the
signer's identity, the template version, the acknowledgments, the signature image, and a
tamper-evident `record_hash` over all of it.

An endpoint that let a server assert *"consider this person to have signed"* would manufacture
records nobody actually signed — and would hollow out every waiver in the table, including the real
ones. So **there is no `POST /api/waivers`**, and signing stays at `/w/{token}` (share link) and
`/k/{token}` (kiosk), where a human does it. A test asserts the endpoint does not exist and that `Waiver::create()` is never reachable
from the API layer.

## Authentication

    Authorization: Bearer apk_...

## Endpoints

| Method & path | What it does |
|---|---|
| `GET /api/ping` | Verify a key |
| `GET /api/templates` | Templates and their public signing URLs |
| `GET /api/waivers` | `?signer=` (name or email substring) · `?status=valid\|expired` |
| `GET /api/waivers/{code}` | One waiver **plus an `integrity_ok` verdict** |
| `GET` | `/api/waivers/check?email=…[&template_id=…]` | One person's waiver status for another system (a front desk): `valid`, `expired`, `superseded` or `none`, with `expires_on`, `signed_on`, `template_id`, `is_minor`. A valid record wins over a later expired one; a tampered record never counts. No signature, answers or code |
| `GET /api/openapi.json` | OpenAPI 3.0 |

### Check someone is covered before they turn up

    curl -H "Authorization: Bearer apk_..." \
      "https://waivers.example.com/api/waivers?signer=ada@example.com&status=valid"

### Verify a record has not been altered

    curl -H "Authorization: Bearer apk_..." https://waivers.example.com/api/waivers/ABC123

`integrity_ok` re-runs the product's own `Waiver::integrityOk()` against the stored template version —
the same check the verify page uses. `false` means the record or its template version no longer
hashes to `record_hash`.

## What the API will not return

- **The signature image.** Neither `signature` nor `guardian_signature` is ever serialized. It is the
  artifact at the center of a legal record and an integration has no reason to hold a copy.
- **The signer's IP.** Captured for the record; never handed out.

Both are asserted by tests (`no_signature_image`, `no_signer_ip`).

## MCP — the agent surface (new in 3.0)

Waiverly speaks **MCP** at `POST /mcp`, authenticated with the API key you already have:

    Authorization: Bearer <your api key>

`GET /mcp` answers **405** with `Allow: POST`, so a misconfigured client is told what to do rather
than concluding you have no MCP support.

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://waivers.example.com/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http waiverly https://waivers.example.com/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "waiverly",
  "server_url": "https://waivers.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://waivers.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Waiverly implements the protocol, not an integration with a particular vendor.

### Tools — all read-only, on purpose

| Tool | What it does |
|---|---|
| `list_templates` | Templates with their current version and expiry policy |
| `list_waivers` | Signed waivers; filter by template, status or signing date |
| `waiver_detail` | One waiver **with the template text exactly as it was signed** |
| `signing_link` | The public signing URL for a template (the existing public link) |
| `stats` | Counts by status and template, plus what expires soon |

**Nothing here writes a waiver, and that is deliberate.** A signed waiver is evidence of what a
person agreed to on a given day. There is no tool to create, edit or void one, because an agent
should not be able to manufacture a signature. `signing_link` is as close as it gets: it returns
the URL a *human* signs at.

### Status is computed, never stored

`valid` · `expired` (past its expiry date) · `superseded` (the template moved on and that template
requires re-signing). The API calls the product's own status rule; it never decides separately
whether a waiver still stands.

### The snapshot rule

`waiver_detail` returns `signed_text` from the stored **template version**, not the live template.
Edit your template afterward and the waiver still shows what was actually agreed. The PDF obeys
the same rule.

**Signature images are never returned** by any tool, and never enter the audit trail.

    curl -s -X POST http://your-install/mcp \
      -H "Authorization: Bearer <key>" -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

## Calendar feed (new in 3.0)

`GET /calendar.ics?t=<token>` is a read-only iCal feed of upcoming expiries — subscribe in Google
Calendar, Outlook or Apple Calendar and re-signing lands in the diary. Mint the token in
**Settings → Expiry calendar**; it is shown once, and minting a new one invalidates the old.

## Erasure, honestly (new in 3.0)

Subject export is always available. **Anonymizing is refused while a waiver is still valid** —
erasing the signer from a waiver you are still relying on destroys the only thing it is for. Once
expired or superseded, anonymizing blanks the signer's details and the signature image while
keeping the evidentiary skeleton: the reference code, the dates, and which template version was
signed.


## Webhooks

| Event | Fires when |
|---|---|
| `waiver.signed` | Someone completes a signature (link **or** kiosk) |

Payload carries identity, dates and `record_hash` — never the signature image, never the IP.

    X-Waiverly-Event: waiver.signed
    X-Waiverly-Signature: sha256=<hmac_sha256(raw_body, your_webhook_secret)>

## Import — N/A, and why

There is no CSV import. Importing waivers would mean **inserting signed legal records for signatures
nobody witnessed**: the `record_hash` chain would be meaningless, and a row that looks like a signed
waiver but was typed in from a spreadsheet is worse than no row at all. Templates are authored in the
app because each edit creates a new immutable version that signatures bind to.


## Connect it to Zapier, Make or n8n

Waiverly talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Waiverly, open **Settings → API keys**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://waiverly.yourcompany.com`.

### When something happens in Waiverly: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Waiverly under **Settings → API keys**, and choose the event or events below.
3. Make the event happen once in Waiverly, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `waiver.signed` | Someone completes a signature (link or kiosk) |

Every delivery carries `X-Waiverly-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Waiverly

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

### Example recipes

- **waiver.signed → a row in Google Sheets:** webhook trigger on `waiver.signed` (Someone completes a signature (link or kiosk)), then the Google Sheets "Create row" action with the fields you want.
- **waiver.signed → a message in Slack or Teams:** webhook trigger on `waiver.signed` (Someone completes a signature (link or kiosk)), then the Slack or Microsoft Teams "Send message" action.

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Waiverly's own automated tests check its API.
