# BotuSign agent API

For Muse, Dots, Duet, Grok Bot, Claude, Cursor, and any other MCP client or script. One key, two doors: MCP (JSON-RPC over HTTP) and REST. Both call the same operations, so a tool and its REST route always agree.

An agent can run the whole loop without opening the web app: send, check status, edit, resend, remind, and download the signed PDF and the certificate.

- Base URL (today): `https://botusign.com`
- MCP endpoint: `POST /api/mcp`
- REST base: `/api/v1`
- Plans: **Starter** ($9 per sender per month) and **Company** ($99 per month for the organization). Both plans can use the API and MCP, and both get webhooks, so an agent on Starter hears about sent, viewed, signed, declined, and completed without polling. Starter does not see the template and bulk tools; Company does.
- **Org** ($1,100 per month for the organization, 25,000 agent envelopes included) has everything Company has, plus bulk calls of 500 rows and [running the organization from the API](#run-the-organization) (members, API keys, audit log). A new organization can try it free for 3 days with the promo code `AUSTEN`, no card.
- The plan is read on every request. A key can always reach the billing and usage calls; every other call stops working the moment the organization has no active plan (`403 plan_required`, with an `upgrade` field that says how to start one).
- Signers are never billed. Pricing rules are in [Billing and rounds](#billing-and-rounds). Buying a plan over the API is in [Billing over the API](#billing-over-the-api).

## Sign up as an agent

You do not need an account or a key to begin. Start the sign-up yourself, show one link to a person, and poll. A person signs in with Google once and presses Approve; nothing else needs a human. The reply to your last poll carries your API key.

```bash
export BOTUSIGN=https://botusign.com
```

**1. Start.** No key. `agent_name` is 1 to 80 characters and is what the person sees. `org_name` (2 to 120) and `promo_code` are optional.

```bash
curl -s $BOTUSIGN/api/v1/agent/signup -H "Content-Type: application/json" \
  -d '{"agent_name":"Austen'\''s connector","org_name":"Gauntlet AI","promo_code":"AUSTEN"}'
```

```json
{
  "signup_id": "uuid",
  "device_code": "…43 characters, secret",
  "user_code": "BSGN-7KQ2",
  "verification_url": "https://botusign.com/agent/approve?code=BSGN-7KQ2",
  "expires_in": 900,
  "interval": 3,
  "instructions": "A person has to approve this once. Show them …",
  "promo": { "code": "AUSTEN", "valid": true, "plan": "org", "trial_days": 3 }
}
```

`promo` appears only when you sent a `promo_code`. It says whether the code is good; it is not spent until the person approves.

**2. Get it approved.** Show the person `verification_url` (the `user_code` is for reading aloud). Or, if you drive a browser that is already signed in to Google, open the address yourself and press Approve. The page says "The agent <agent_name> wants to sign up BotuSign" shows the promo code you brought (if any), lets the person choose an organization (a new one, or one where they are an owner or admin), and has Approve and Deny. If they are signed out they are sent through Google first and brought back. Keep `device_code` to yourself: it is the only thing that collects the key.

**3. Poll** every `interval` seconds (3) until `status` is not `pending`.

```bash
curl -s $BOTUSIGN/api/v1/agent/signup/poll -H "Content-Type: application/json" \
  -d '{"device_code":"<device_code>"}'
```

| `status` | Meaning |
| --- | --- |
| `pending` | Nobody has decided yet. Keep polling. |
| `approved` | Approved. This one reply carries `api_key`. Store it now. |
| `consumed` | The key was already handed over. It is never shown again. |
| `denied` | The person said no. Stop. |
| `expired` | Nobody approved within 15 minutes (or the key was not collected within 15 minutes of approval). Start again. |

An unknown `device_code` is `404 invalid_device_code`. Two polls at the same moment cannot both get a key: one gets `approved`, the other `consumed`.

```json
{
  "status": "approved",
  "api_key": "bsk_live_…",
  "key_id": "uuid",
  "org": { "id": "uuid", "name": "Gauntlet AI" },
  "plan": {
    "plan": "org",
    "status": "trialing",
    "trial_ends_at": "2026-11-06T20:00:00.000Z",
    "can_apply_promo": false,
    "promo_code": "AUSTEN",
    "promo_applied": true,
    "promo_error": null,
    "…": "the rest of GET /api/v1/billing"
  },
  "next_steps": ["Store api_key now. …"]
}
```

The key is created in the name of the person who approved. It is named after `agent_name`, is shown once, and only its SHA-256 is stored. If you lose it, start again, or ask a person to make another under Settings, API and MCP.

**4. Start the trial or pay.** A new organization has no plan, and until it has one only the billing and usage calls work (everything else is `403 plan_required`). If you sent `promo_code` in step 1, it was applied at approval: read `plan.promo_applied`. If it failed, `plan.promo_error` is `{ "code", "message" }` (`invalid_promo`, `promo_used`, `already_subscribed`) and the key still works. To apply a code now:

```bash
curl -s -X POST $BOTUSIGN/api/v1/billing/promo \
  -H "Authorization: Bearer $BOTUSIGN_KEY" -H "Content-Type: application/json" \
  -d '{"code":"AUSTEN"}'
```

`AUSTEN` is 3 free days on the Org plan, no card, once per organization and once per person. The trial lives inside BotuSign, so it starts instantly and needs nothing from Stripe. Before it ends, a person pays through `POST /api/v1/billing/checkout` with `{"plan":"org"}`: the rest of the trial carries over, so the first charge lands on the day it would have ended (at once if under 2 days remain). With no card the plan simply stops on the end date, `GET /api/v1/billing` reports `status: "trial_ended"`, and nothing is charged.

**5. Send.** The rest of this page applies. Add an `Idempotency-Key` header so a retry never sends twice.

```bash
curl -s -X POST $BOTUSIGN/api/v1/envelopes \
  -H "Authorization: Bearer $BOTUSIGN_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: enrollment-2291" \
  -d '{"title":"Fellowship Agreement","pdf_base64":"JVBERi0x…","recipients":[{"name":"Ada Okafor","email":"ada@example.com","role":"signer","order":1}],"external_id":"enrollment-2291"}'
```

Limits on this flow: `POST /api/v1/agent/signup` allows 10 starts per 10 minutes per IP address. The poll allows 120 per minute per IP address and 60 per minute per `device_code`. Both answer `429 rate_limited` with `Retry-After`.

## Connect (Austen's setup, about a minute)

1. In the app, open Settings, API and MCP, name a key (for example "Austen's connector"), and copy it. It is shown once. Only its SHA-256 is stored. Owners and admins can create keys. Revoking takes effect on the next request.
2. Add the connector to the MCP client:

```json
{
  "mcpServers": {
    "botusign": {
      "url": "https://botusign.com/api/mcp",
      "headers": { "Authorization": "Bearer bsk_live_..." }
    }
  }
}
```

3. Ask the client to list tools. Starter sees 19, Company and Org see 23. An organization with no plan sees only 5: `get_billing`, `apply_promo_code`, `create_checkout_session`, `create_portal_session` and `get_usage`.
4. Optional, so the agent never has to poll: call `create_webhook` with a public HTTPS URL and keep the `signing_secret` it returns. See [Webhooks](#webhooks).

Streamable HTTP, stateless JSON responses, protocol versions 2025-06-18, 2025-03-26, 2024-11-05. `tools/call` results carry the JSON in `structuredContent` and in `content[0].text`. Failures set `isError: true` and the text is `{"error":{"code","message"}}`.

Raw call:

```bash
curl -s https://botusign.com/api/mcp \
  -H "Authorization: Bearer $BOTUSIGN_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_envelopes","arguments":{"awaiting_signature":true}}}'
```

## Authentication

```
Authorization: Bearer bsk_live_<43 characters>
```

The session cookie is never read by the API, so a browser tab cannot call it on a user's behalf. Rate limits: 300 requests per minute per IP and 600 per minute per key (`429 rate_limited`, with `Retry-After`).

Signing links are intentionally not returned by any API call. BotuSign emails each signer their own single-use link. The one exception is a bulk call over REST while email delivery is not connected: see [Bulk](#bulk-send-to-a-list). An agent that needs the person to act uses `remind` or `resend_envelope`.

### Who the signer hears from

An envelope sent with a key is sent in the name of the person who created the key (or approved the agent's sign-up). The signer's email says "<that person's name> sent you "<title>" to sign", and their reply goes to that person's email address (`Reply-To`). The `From` address is always BotuSign's own sending address. If the person who made the key is no longer a member of the organization, the sender is the organization's name and there is no `Reply-To`. Reminders, resends and revisions sent with the same key keep the same sender.

## The loop, as tools

| Step | Tool | REST | Plans |
| --- | --- | --- | --- |
| Send a PDF | `create_envelope` | `POST /api/v1/envelopes` with `pdf_base64` | both |
| Send a draft | `send_envelope` | `POST /api/v1/envelopes/{id}/send` | both |
| Check status | `get_envelope` | `GET /api/v1/envelopes/{id}` | both |
| Find who has not signed | `list_envelopes` | `GET /api/v1/envelopes?awaiting_signature=true` | both |
| Edit and send again | `revise_envelope` | `PATCH /api/v1/envelopes/{id}` | both |
| Send again unchanged | `resend_envelope` | `POST /api/v1/envelopes/{id}/resend` | both |
| Nudge the signer | `remind` | `POST /api/v1/envelopes/{id}/remind` | both |
| Cancel | `void_envelope` | `POST /api/v1/envelopes/{id}/void` | both |
| Signed PDF and certificate links | `get_documents` | `GET /api/v1/envelopes/{id}/documents` | both |
| The PDF bytes themselves | (REST only) | `GET /api/v1/envelopes/{id}/documents/{signed\|certificate\|original}` | both |
| Proof | `get_evidence` | `GET /api/v1/envelopes/{id}/evidence` | both |
| Allowance and cost so far | `get_usage` | `GET /api/v1/usage` | any, also with no plan |
| Plan, trial, allowance, what to do next | `get_billing` | `GET /api/v1/billing` | any, also with no plan |
| Apply a promo code | `apply_promo_code` | `POST /api/v1/billing/promo` | no plan needed |
| Get a link for a person to pay | `create_checkout_session` | `POST /api/v1/billing/checkout` | no plan needed |
| Get a link to update the card, see invoices, cancel | `create_portal_session` | `POST /api/v1/billing/portal` | after a paid plan exists (a promo trial has no billing account yet: use checkout) |
| Hear about changes (no polling) | `create_webhook` | `POST /api/v1/webhooks` | both |
| List endpoints | `list_webhooks` | `GET /api/v1/webhooks` | both |
| Send a test event | `test_webhook` | `POST /api/v1/webhooks/{id}/test` | both |
| Remove an endpoint | `delete_webhook` | `DELETE /api/v1/webhooks/{id}` | both |
| Templates | `list_templates`, `get_template` | `GET /api/v1/templates`, `GET /api/v1/templates/{id}` | Company |
| Fill a template | `create_envelope_from_template` | `POST /api/v1/envelopes` with `template_id` | Company |
| Many at once | `bulk_send_from_template` | `POST /api/v1/templates/{id}/bulk` | Company |
| See where fields would go, before sending | `analyze_document` | `POST /api/v1/documents/analyze` | both |
| Make a template from a PDF | `create_template` | `POST /api/v1/templates` | Company |
| Change a template | `update_template` | `PATCH /api/v1/templates/{id}` | Company |
| Retire a template | `archive_template` | `DELETE /api/v1/templates/{id}` | Company |
| Make a template from an envelope | `create_template_from_envelope` | `POST /api/v1/templates/from-envelope` | Company |
| One PDF to a list of people | `bulk_create_envelopes` | `POST /api/v1/envelopes/bulk` | Company |
| Progress of a bulk call | `get_batch` | `GET /api/v1/batches/{id}` | Company |
| Nudge everyone still pending | `remind_batch` | `POST /api/v1/batches/{id}/remind` | Company |
| Run the organization | `list_members`, `invite_member`, `set_member_role`, `remove_member`, `list_api_keys`, `create_api_key`, `revoke_api_key`, `get_audit_log` | `/api/v1/org/members`, `/api/v1/org/api-keys`, `/api/v1/org/audit` | Org |

Every id is a UUID. Every tool returns JSON. Field names are snake_case. A Starter key that calls a Company tool, or a Company key that calls an Org tool, gets `403 plan_required`, and so does a key whose organization has no plan (see [Billing over the API](#billing-over-the-api)). The five tools that create or change an envelope (`create_envelope`, `create_envelope_from_template`, `send_envelope`, `revise_envelope`, `resend_envelope`) accept an idempotency key: see [Safe retries](#safe-retries-idempotency).

Every envelope response includes `available_actions`, the tools that make sense right now (for example `["get_envelope","remind","resend_envelope","revise_envelope","void_envelope"]`). An agent can follow it instead of memorizing the state machine.

### create_envelope

```json
{
  "title": "Fellowship Agreement",
  "message": "Cohort 12 terms",
  "pdf_base64": "JVBERi0x...",
  "recipients": [
    { "name": "Ada Okafor", "email": "ada@example.com", "role": "signer", "order": 1 },
    { "name": "Hannah Lee", "email": "hannah@gauntlet.ai", "role": "signer", "order": 2 },
    { "name": "Legal", "email": "legal@gauntlet.ai", "role": "cc", "order": 3 }
  ],
  "fields": [
    { "recipient_index": 0, "type": "signature", "page": 2, "x": 0.1, "y": 0.78, "w": 0.3, "h": 0.07 }
  ],
  "expires_in_days": 30,
  "send": true,
  "external_id": "enrollment-2291",
  "metadata": { "cohort": "12", "source": "airtable" },
  "idempotency_key": "enrollment-2291"
}
```

- `external_id` (up to 200 characters) and `metadata` (up to 20 keys; keys up to 40 characters; values are strings up to 500) tie the envelope to your own records. Both come back on every envelope response, on `list_envelopes`, and inside `data.envelope` of every webhook, so a receiver never has to look the envelope up. `list_envelopes` can filter by `external_id`. An `external_id` is not required to be unique. Both are optional and `null` when not set. `revise_envelope` can replace them (`null` clears them; leave them out to keep them), and `create_envelope_from_template` takes them too.
- `idempotency_key` (or the `Idempotency-Key` header on REST) makes a retry safe: see [Safe retries](#safe-retries-idempotency).
- PDF up to 10 MB and 100 pages. The file must be a readable, unencrypted PDF (`422 invalid_pdf` otherwise). BotuSign rebuilds it page by page, which drops embedded scripts, document actions, and annotations, and the hash of the rebuilt file is what gets signed.
- Up to 20 recipients and 200 fields. `role` is one of `signer, applicant, vendor, customer, partner, employee, cc`. `cc` receives the finished copy and never signs.
- Field `type` is `signature, initials, date, text, checkbox`. Positions are fractions from 0 to 1 measured from the top left. Omit `fields` and BotuSign places them for you, in this order: text tags in the PDF (`{{sign}}`, `{{sign:2}}`), your `placements` (fields found by text in the PDF), the signature lines the document already has, then a signature and a date on the last page. The response's `placement` block says which and where. See [Placing signature fields without coordinates](#placing-signature-fields-without-coordinates).
- Equal `order` values sign in parallel. Different values sign one after another; the next signer is emailed when the previous one finishes.
- `send: false` makes a draft (not counted, not emailed). Send it later with `send_envelope`.

Returns (REST status 201):

```json
{
  "envelope": {
    "id": "uuid",
    "title": "Fellowship Agreement",
    "status": "sent",
    "round": 1,
    "rounds_remaining": 20,
    "supersedes": null,
    "superseded_by": null,
    "available_actions": ["get_envelope", "remind", "resend_envelope", "revise_envelope", "void_envelope"],
    "created_at": "2026-11-02T20:00:00.000Z",
    "first_sent_at": "2026-11-02T20:00:01.000Z",
    "sent_at": "2026-11-02T20:00:01.000Z",
    "completed_at": null,
    "expires_at": "2026-12-02T20:00:01.000Z",
    "page_count": 2,
    "external_id": "enrollment-2291",
    "metadata": { "cohort": "12", "source": "airtable" },
    "awaiting": ["ada@example.com"],
    "recipients": [
      {
        "id": "uuid", "name": "Ada Okafor", "email": "ada@example.com", "role": "signer", "order": 1,
        "status": "sent", "notified_at": "…", "viewed_at": null, "consented_at": null, "signed_at": null,
        "declined_at": null, "reminder_count": 0
      }
    ]
  },
  "billing": {
    "counted_as": "included",
    "new_envelope_charged_usd": 0,
    "agent_envelopes_used": 1,
    "agent_envelopes_included": 100,
    "note": "Counted once at first send, inside the monthly allowance. Revisions, resends and reminders of this envelope are free."
  }
}
```

`billing.counted_as` is `none` (a draft), `included`, `overage`, or `unmetered_app_send` (sent from the web app).

`viewed_at` on each recipient is the first time that person opened their link. It is `null` until then, it does not move on later opens, and a revision (`revise_envelope`) resets it to `null` because everyone signs again. To learn it without polling, subscribe to `recipient.viewed` (see [Webhooks](#webhooks)).

### send_envelope

`{ "envelope_id": "uuid", "expires_in_days": 30, "idempotency_key": "…" }`. Sends a draft. Returns `{ "envelope", "billing" }`. This is the moment a new envelope is counted. Calling it on anything that is not a draft returns `409 invalid_state`, so a retry can never double count. If you would rather have the retry return the first response, send an idempotency key (see [Safe retries](#safe-retries-idempotency)).

### get_envelope

`{ "envelope_id": "uuid" }`. Returns the `envelope` object above plus `evidence`:

```json
{
  "evidence": {
    "document_sha256": "…",
    "completed_sha256": null,
    "audit_chain_valid": true,
    "audit_chain_head": "…",
    "events": 4,
    "email_deliveries": [{ "type": "recipient.sent", "at": "…", "to": "ada@example.com", "message_id": "provider-message-id" }]
  }
}
```

`email_deliveries` is the proof the email left BotuSign: provider message ids, per signer, for the first send, every resend, and every reminder.

### list_envelopes

`{ "status": "sent", "awaiting_signature": true, "external_id": "enrollment-2291", "limit": 50, "cursor": "…" }` (all optional, limit 1 to 100, default 50). REST takes the same names as query parameters. Returns `{ "envelopes": [ { id, title, status, round, rounds_remaining, sent_at, completed_at, expires_at, external_id, metadata, signers: [ { name, email, status, viewed_at } ] } ], "next_cursor": "…" | null }`.

Newest first. When `next_cursor` is not `null`, there is more: send it back as `cursor` with the same filters to get the next page. A cursor never repeats or skips an envelope, even if new ones arrive while you page. `next_cursor` is `null` on the last page. A cursor you did not get from this call is `400 invalid_cursor`. Example, every envelope for one of your records:

```bash
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" "$BOTUSIGN/api/v1/envelopes?external_id=enrollment-2291&limit=100"
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" "$BOTUSIGN/api/v1/envelopes?external_id=enrollment-2291&limit=100&cursor=$NEXT_CURSOR"
```

### revise_envelope

Edit a sent, viewed, expired, or declined envelope and send it again as the next round of the same envelope. The id does not change.

`PATCH /api/v1/envelopes/{id}` or tool `revise_envelope`. Send at least one of:

```json
{
  "envelope_id": "uuid",
  "title": "Fellowship Agreement v2",
  "message": "Start date moved to Feb 2",
  "pdf_base64": "JVBERi0x...",
  "recipients": [
    { "name": "Ada Okafor", "email": "ada@example.com", "role": "signer", "order": 1 },
    { "name": "Hannah Lee", "email": "hannah@gauntlet.ai", "role": "signer", "order": 2 }
  ],
  "fields": [ { "recipient_index": 0, "type": "signature", "page": 2, "x": 0.1, "y": 0.78, "w": 0.3, "h": 0.07 } ],
  "expires_in_days": 30
}
```

- `recipients` is the complete new list. People are matched by email: someone who stays keeps their identity, someone new is added, someone missing is removed. Omit it to keep the current people.
- `fields` is the complete new field list. Omit it to keep the current fields (values go back to their prefill). If you replace the PDF or recipients and omit `fields`, missing signature fields are placed automatically.
- Everyone signs the new version again. Earlier signatures and consents are cleared, and every earlier link stops working: the signer sees "This agreement was updated" and is pointed to the new email. To keep signatures already collected, use `resend_envelope` instead.
- No arguments besides `envelope_id` returns `400 nothing_to_revise`. The same goes for sending only `external_id` or `metadata`: a revision sends the envelope to everyone again, so it needs a change to the title, message, PDF, recipients or fields. You can send `external_id` and `metadata` along with a real change: each replaces the stored value, `null` clears it, and leaving it out keeps it.
- `idempotency_key` (or the `Idempotency-Key` header) makes a retry apply once: see [Safe retries](#safe-retries-idempotency).
- The old and new documents, and the revision itself, are recorded in the audit log (`envelope.revised`).

Returns:

```json
{
  "new_envelope": false,
  "supersedes": null,
  "envelope": { "id": "same uuid", "round": 2, "rounds_remaining": 19, "status": "sent", "…": "…" },
  "billing": { "counted_as": "none", "new_envelope_charged_usd": 0, "round": 2, "rounds_remaining": 19, "note": "Revision 1 of 20 on the same envelope. Free. After 20, the next revise or resend creates a new envelope that counts as a new send." }
}
```

### resend_envelope

Send the same envelope again with fresh links and a new expiry, keeping signatures already collected. Works on sent, viewed, and expired envelopes (an expired envelope is revived).

`POST /api/v1/envelopes/{id}/resend` with `{ "expires_in_days": 30, "idempotency_key": "…" }` (both optional), or tool `resend_envelope`. Earlier links stop working. Only the people who have not signed are emailed, and only the group whose turn it is. Returns the same shape as `revise_envelope`.

`remind` is different: it re-emails the current signer without issuing new links or changing the round, and is rate limited to one per signer per hour.

### remind

`{ "envelope_id": "uuid", "email": "ada@example.com" }`. `email` is optional. Returns `{ "reminded": ["ada@example.com"], "skipped": [] }`. A signer reminded within the last hour appears in `skipped` with the reason. A signer who is not up yet is never emailed. `409 nothing_to_remind` when nobody is waiting. BotuSign also sends automatic reminders: the first after 48 hours, then every 48 hours, at most three. Reminders are free and do not change the round.

### void_envelope

`{ "envelope_id": "uuid", "reason": "Wrong cohort" }`. Signers who open their link afterward see that it is no longer active. A voided envelope is final.

### get_documents

`{ "envelope_id": "uuid" }`. Available once `status` is `completed`.

```json
{
  "status": "completed",
  "original": { "url": "https://…", "sha256": "…" },
  "signed_pdf": { "url": "https://…", "sha256": "…" },
  "certificate_pdf": { "url": "https://…" },
  "expires_in_seconds": 900
}
```

Links are signed, private, and expire in 15 minutes. Before completion, `signed_pdf` and `certificate_pdf` are `null`. Fetch the links right away; ask again for fresh ones later.

Clients that cannot follow a URL can fetch the bytes directly with the same API key:

```bash
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" -o signed.pdf       $BOTUSIGN/api/v1/envelopes/$ID/documents/signed
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" -o certificate.pdf  $BOTUSIGN/api/v1/envelopes/$ID/documents/certificate
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" -o original.pdf     $BOTUSIGN/api/v1/envelopes/$ID/documents/original
```

`signed` and `certificate` return `409 invalid_state` until the envelope is completed. Any other value returns `404`. The response is `application/pdf` with `Content-Disposition: attachment`.

### get_evidence

Full hash-chained audit log: `document` hashes, `audit_chain { valid, head, length }`, `recipients`, and `events [ { seq, at, type, recipient, ip, user_agent, meta, hash } ]`. Each event includes the previous event's hash, and the database refuses updates and deletes on the events table. Event types include `envelope.created`, `envelope.sent`, `envelope.revised`, `envelope.resent`, `recipient.sent`, `recipient.reminded`, `recipient.viewed`, `recipient.consented`, `recipient.signed`, `envelope.completed`.

### get_usage

`GET /api/v1/usage` or tool `get_usage`. No input.

```json
{
  "plan": "starter",
  "senders": 1,
  "period": { "start": "2026-11-01T00:00:00.000Z", "end": "2026-12-01T00:00:00.000Z" },
  "agent_envelopes": {
    "used": 1, "included": 100, "remaining": 99,
    "over_allowance": 0, "overage_usd_so_far": 0, "overage_usd_per_envelope": 0.1
  },
  "app_envelopes_sent": 0,
  "rules": { "counted": "…", "free": "…" }
}
```

Check it before a large batch. `period` is the Stripe billing period when one is active, otherwise the UTC calendar month.

### Template tools (Company)

`list_templates` and `get_template` take `{}` and `{ "template_id": "uuid" }` and return roles and prefill keys:

```json
{
  "id": "uuid",
  "name": "Fellow participation packet",
  "title": "Fellow Participation Packet",
  "pages": 2,
  "roles": [
    { "key": "fellow", "name": "Fellow", "role": "applicant", "order": 1 },
    { "key": "countersigner", "name": "Gauntlet countersigner", "role": "signer", "order": 2 }
  ],
  "prefill_keys": ["fellow_name", "cohort", "start_date"]
}
```

`create_envelope_from_template`:

```json
{
  "template_id": "uuid",
  "recipients": {
    "fellow": { "name": "Ada Okafor", "email": "ada@example.com" },
    "countersigner": { "name": "Hannah Lee", "email": "hannah@gauntlet.ai" }
  },
  "prefill": { "fellow_name": "Ada Okafor", "cohort": "Cohort 12", "start_date": "2026-11-16" },
  "title": "Fellow packet: Ada Okafor",
  "message": "Welcome. This takes a minute.",
  "expires_in_days": 30,
  "send": true
}
```

`recipients` needs every role key the template defines. `prefill` keys must exist on the template (`unknown_prefill` otherwise). It also takes `external_id`, `metadata` and `idempotency_key`, with the same meaning as on `create_envelope`. Returns `{ "envelope", "billing" }` (REST status 201). `bulk_send_from_template` takes `{ "template_id", "rows": [ { recipients, prefill?, title? } ] }` with up to 100 independent rows (500 on Org); a failed row does not stop the rest, and each successful row counts as a new envelope. Its full response, row keys and retry behaviour are in [Bulk](#bulk-send-to-a-list). To create, change or retire templates yourself, see [Templates](#templates).

## Placing signature fields without coordinates

You never have to measure a page. Leave out `fields` and BotuSign finds where each person signs. It uses the first of these that applies:

1. `fields` you give, exactly as sent (`placement.method` is `explicit`).
2. Text tags written in the PDF (`tags`).
3. `placements`: you name text that is in the PDF and what goes next to it (`anchors`).
4. The signature lines the document already has: a "Signature" label, a line of underscores, a rule, "Employee signature", "Date:" beside it (`detected`).
5. A signature and a date in free space on the last page (`default`).

Tags and `placements` can be mixed: a tag for the signatures and a placement for one extra initials box. Send `fields` or `placements`, not both.

### Text tags

Type a tag where the field should go. BotuSign puts the field there and covers the tag text with a white box in the copy people sign, so the tag never shows. The original upload is kept as sent.

| Tag | Field |
| --- | --- |
| `{{sign}}` or `{{sign:1}}` | signature for the first signer |
| `{{sign:2}}` | signature for the second signer (signers are counted in the order of `recipients`, ignoring `cc`) |
| `{{initial:2}}` | initials |
| `{{date:2}}` | date signed |
| `{{text:1:Start date}}` | text box labeled "Start date", filled by that signer |
| `{{text:1:Cohort\|key=cohort}}` | text box with a prefill key, for templates |
| `{{check:1:I agree}}` | checkbox |
| `{{sign:1\|required=false}}` | optional field |

`[[sign:2]]` works the same as `{{sign:2}}`. The short forms `/sn1/` (signature), `/in1/` (initials), `/ds1/` and `/dt1/` (date), `/tx1/` (text), `/ck1/` (checkbox) are also read, for documents that come from other tools. Tag type words are not case sensitive. A tag for signer 3 in an envelope with 2 signers is `422 invalid_tag`, and so is a tag that names a role (`{{sign:fellow}}`) on a one-off envelope: role names are for [templates](#templates). Tags on rotated pages are ignored.

### Anchors: `placements`

```json
{
  "title": "Offer letter",
  "pdf_base64": "JVBERi0x...",
  "recipients": [
    { "name": "Ada Okafor", "email": "ada@example.com" },
    { "name": "Hannah Lee", "email": "hannah@gauntlet.ai" }
  ],
  "placements": [
    { "recipient_index": 0, "type": "signature", "anchor": "Employee signature" },
    { "recipient_index": 0, "type": "date", "anchor": "Employee signature", "position": "right" },
    { "recipient_index": 1, "type": "signature", "anchor": "Employer signature", "occurrence": "last" }
  ]
}
```

- `anchor` is text that is in the PDF, matched ignoring case and extra spaces. If it is not found the call fails with `422 anchor_not_found` and `closest_matches` lists the nearest text with its page. Nothing is created.
- `occurrence`: `first` (default), `last`, or a number counted in reading order. `page` limits the search to one page.
- `position`: `right`, `below`, `above` or `on`. Leave it out and BotuSign picks the free side: to the right of a blank, above a line, otherwise the first free side. A field never lands on printed words.
- `offset_x` and `offset_y` nudge the field (fractions of the page; positive is right and down). `width` and `height` resize it. `required`, `label` and `key` work as on `fields`.
- `revise_envelope` takes `placements` too (the complete new field list). A revision with a new PDF and no `fields` places again from that PDF's tags or lines.

### See before you send: `analyze_document`

`POST /api/v1/documents/analyze` or tool `analyze_document` reads a PDF and changes nothing. It is free and does not count as an envelope. Send `pdf_base64` or an `envelope_id`, and optionally `recipients` (`[{ "name", "role" }]`) so "Employee" and "Employer" lines go to the right person.

```json
{
  "page_count": 2,
  "text_found": true,
  "readable": true,
  "method_if_sent_now": "tags",
  "suggested_for_recipients": 2,
  "notes": [],
  "pages": [
    {
      "page": 2,
      "width_pt": 612, "height_pt": 792, "rotation": 0,
      "text_blocks": [ { "text": "Employee signature", "x": 0.117, "y": 0.62, "w": 0.18, "h": 0.014 } ],
      "tags_found": [ { "raw": "{{sign:1}}", "type": "signature", "signer": 1, "role": null, "label": null, "key": null, "x": 0.12, "y": 0.6, "w": 0.09, "h": 0.014 } ],
      "signature_lines": [ { "kind": "rule", "purpose": "signature", "label": "Employee signature", "confidence": 0.9, "x": 0.12, "y": 0.6, "w": 0.3 } ],
      "suggested_fields": [ { "recipient_index": 0, "type": "signature", "x": 0.12, "y": 0.57, "w": 0.3, "h": 0.05, "source": "tag", "matched": "{{sign:1}}" } ]
    }
  ]
}
```

All coordinates are fractions of the page from the top left, the same as `fields`. Text blocks are capped (150 per page, 600 per call); `notes` says when they were. Use the text blocks to choose anchors.

### The `placement` block

`create_envelope` returns a `placement` block next to `envelope` (and `billing`), and so does `revise_envelope` when it placed fields, so you can check what was done:

```json
{
  "placement": {
    "method": "tags",
    "fields": [
      { "recipient_index": 0, "type": "signature", "page": 2, "x": 0.12, "y": 0.57, "w": 0.3, "h": 0.05, "required": true, "label": null, "key": null, "source": "tag", "matched": "{{sign:1}}" }
    ],
    "notes": []
  }
}
```

`method` is `explicit`, `tags`, `anchors`, `detected` or `default`; each field's `source` is `explicit`, `tag`, `anchor`, `detected` or `default`, with `matched` (the tag or text) and sometimes a `note`. If the method is `default`, BotuSign found nothing to go on and used the standard position; tell the person, or add tags and send a revision. In the web app, a PDF uploaded without fields opens in the editor with the same placement already suggested.

## Templates

A template is a PDF, named roles ("Fellow", "Countersigner"), and fields per role. Company and Org can create and manage them from the API. Starter gets `403 plan_required`.

`create_template` (`POST /api/v1/templates`, 201):

```json
{
  "name": "Fellow participation packet",
  "title": "Fellow Participation Packet",
  "message": "Welcome. This takes a minute.",
  "pdf_base64": "JVBERi0x...",
  "roles": [
    { "key": "fellow", "name": "Fellow", "role": "applicant", "order": 1 },
    { "key": "countersigner", "name": "Gauntlet countersigner", "role": "signer", "order": 2 }
  ]
}
```

- `roles`: 1 to 20, each with a `key` (lowercase letters, digits, underscores; unique), a `name`, a `role` label (`signer` by default) and an `order` (same number signs together). At least one role must sign; `cc` roles only get a copy.
- Fields come from, in order: `fields` (each has `role` plus the usual `type`, `page`, `x`, `y`, `w`, `h`), `placements` (like envelope placements but with `role` instead of `recipient_index`), tags written with role keys in the PDF, the signature lines the document has, then free space on the last page. Role tags look like `{{sign:fellow}}`, `{{date:fellow}}`, `{{text:fellow:Cohort|key=cohort}}`, `{{text:countersigner:Notes}}`. Numeric tags (`{{sign:2}}`) mean the role at that position. Tag text is covered in the stored copy.
- A text field with a `key` becomes a prefill key. `get_template` lists them as `prefill_keys`.
- Returns `{ "template": { id, name, title, message, pages, roles, prefill_keys, archived_at, created_at }, "placement": { method, fields, notes } }`.

`update_template` (`PATCH /api/v1/templates/{id}`) takes `template_id` and any of `name`, `title`, `message`, `pdf_base64`, `roles` (the complete new list, matched by `key`), `fields` or `placements`. A new PDF with tags re-places the fields; without tags the current fields stay. If you drop a role, send fields for what replaces it (`invalid_template` says which role is orphaned). Envelopes already sent keep the version they were sent with.

`archive_template` (`DELETE /api/v1/templates/{id}`) retires a template: it leaves `list_templates` (add `?include_archived=true` on REST to see it), `create_envelope_from_template` and bulk calls return `409 template_archived`, and envelopes already made from it are untouched. Repeating it changes nothing. The web app has the same Archive button.

`create_template_from_envelope` (`POST /api/v1/templates/from-envelope`, 201) turns a prepared envelope (draft or sent) into a template: `{ "envelope_id", "name", "roles": [ { "email", "key", "name?" } ] }`. Each recipient becomes a role; without `roles` the roles are named after the people.

The web app has "New template" under Templates for the same thing: upload a PDF, name the roles, and rename them later.

## Bulk: send to a list

Two tools, one result shape. `bulk_send_from_template` sends a template to many people. `bulk_create_envelopes` sends one PDF to a list, with no template. Both need Company; a call takes up to 100 rows on Company and 500 on Org (`422 limit_reached` over that).

```json
{
  "template_id": "uuid",
  "idempotency_key": "cohort-12-welcome",
  "rows": [
    {
      "recipients": { "fellow": { "name": "Ada Okafor", "email": "ada@example.com" }, "countersigner": { "name": "Hannah Lee", "email": "hannah@gauntlet.ai" } },
      "prefill": { "cohort": "Cohort 12" },
      "external_id": "enrollment-2291",
      "metadata": { "cohort": "12" }
    }
  ]
}
```

`bulk_create_envelopes` takes `title`, `message`, `pdf_base64`, `fields` or `placements` (applied to every row; omit both to place from tags or lines), `send` (default true; `false` makes drafts), `expires_in_days`, and either `rows` (`[{ "recipients": [ { "name", "email", "role?", "order?" } ], "external_id?", "metadata?" }]`) or `recipients_csv`. The PDF is read once; every row gets its own envelope.

### From a CSV

`recipients_csv` is CSV text with a header row. Required columns: `name` and `email` (for more people on one row use `name_1,email_1,name_2,email_2` and so on). Optional: `role`, `order` (`role_2`, `order_2` for the second person), `external_id`, and `meta_<key>` for metadata.

```
name,email,external_id,meta_cohort
Ada Okafor,ada@example.com,enrollment-2291,12
Grace Hopper,grace@example.com,enrollment-2292,12
```

A row that cannot be read (no email, a bad address) comes back as a failed row with the reason; the rest go out. A missing header or a missing `name` or `email` column is `400 invalid_csv` before anything is sent.

### The result

```json
{
  "batch_id": "uuid",
  "total": 3,
  "sent": 2,
  "failed": 1,
  "duplicate": 0,
  "rows": [
    { "row": 1, "status": "sent", "envelope_id": "uuid", "external_id": "enrollment-2291", "delivery": "email" },
    { "row": 2, "status": "sent", "envelope_id": "uuid", "external_id": "enrollment-2292", "delivery": "email" },
    { "row": 3, "status": "failed", "external_id": "enrollment-2293", "error": { "code": "invalid_recipient", "message": "Enter a valid email address." } }
  ]
}
```

- Rows go out one at a time, in order, and are independent: a failed row never stops the rest. The call returns `200` even when some rows failed, so read `rows`.
- `status` is `sent`, `draft` (when `send` is false), `failed`, or `duplicate`: a row whose `external_id` already belongs to a draft, an envelope out for signature, or a completed envelope in this organization is not sent again, and `envelope_id` points at the existing one. Voided, declined and expired envelopes do not count, so the same person can be sent again after one of those.
- Each sent row counts as a new envelope at first send, like any other. Check `get_usage` before a big batch.
- `delivery` is `email` when the signing email went out. Email being unavailable never fails a row: the envelope is created and sent, and the row says `delivery: "link"`. Over REST, that call's own response then carries `signing_links` on those rows (`[{ name, email, url }]`) with a `note`: pass them on yourself, because they are shown once, are never stored, and anyone holding one can sign as that person. Over MCP and in any replay, links are never returned; connect email and use `remind_batch` instead. Signers who were emailed never have a link in a response.
- `batch_id` names everything this call created. Keep it.

### Progress and reminders

`get_batch` (`GET /api/v1/batches/{batch_id}`) returns the live state:

```json
{
  "batch_id": "uuid",
  "total": 2,
  "by_status": { "sent": 1, "completed": 1 },
  "awaiting": 1,
  "completed": 1,
  "rows": [
    { "row": 1, "status": "sent", "envelope_id": "uuid", "external_id": "enrollment-2291", "envelope_status": "completed", "title": "…", "sent_at": "…", "completed_at": "…", "metadata": {}, "signers": [ { "name": "Ada Okafor", "email": "ada@example.com", "status": "signed", "viewed_at": "…", "signed_at": "…" } ] }
  ],
  "note": "…"
}
```

`rows` are the envelopes that were created, oldest first; a row that failed never created one, so only the bulk call's own response lists it (and `row` here counts created envelopes, not your original list positions). Match on `external_id`.

`remind_batch` (`POST /api/v1/batches/{batch_id}/remind`) nudges everyone who is expected to sign now: `{ "batch_id", "envelopes", "reminded", "skipped", "already_done", "rows": [ { "envelope_id", "external_id", "reminded": [emails], "skipped": [ { "email", "reason" } ] } ] }`. Finished envelopes are skipped, a person is reminded at most once an hour (the rest are `skipped` with `reminded in the last hour`), and reminders are free. The web app has the same list at Batches, with a "Remind everyone pending" button.

### Retrying a bulk call

Send `idempotency_key` on the call (REST: the `Idempotency-Key` header or the body field). Then:

- The same key and the same body returns the first response. Nothing is sent twice (`Idempotent-Replay: true` on REST, `idempotent_replay: true` on MCP).
- Every row also has a key. Give a row its own `idempotency_key`, or it uses `<call key>:<row number>` (`cohort-12-welcome:1`). A row that already went out under its key is returned as `sent` with `idempotent_replay: true` and is never sent again, even if the call itself crashed halfway and you repeat it with the same key. A row key reused with different content is a failed row (`idempotency_key_reuse`).
- A retry after a crash joins the same `batch_id`.
- Without a key, a retry sends everything again, except rows whose `external_id` already belongs to a live envelope, which are skipped as `duplicate`. Send a call key, and give every row an `external_id`.

## Run the organization

On the Org plan an agent can do what an owner does in Settings: manage people and keys and read the audit trail. These tools need the Org plan (Starter and Company get `403 plan_required`) and a key made by an owner or admin; a key made by a member gets `403 forbidden`, and so does a key whose creator has left the team. The Team and Billing pages in the web app work the same way on Org.

| Tool | REST | Does |
| --- | --- | --- |
| `list_members` | `GET /api/v1/org/members` | Members (`id`, `name`, `email`, `role`) and `pending_invitations`. |
| `invite_member` | `POST /api/v1/org/members` `{ "email", "role": "admin" \| "member" }` | Invites by email (201). They join when they sign in to botusign.com with that address. `email_sent` says whether the invitation email went out. |
| `set_member_role` | `PATCH /api/v1/org/members/{id}` `{ "role" }` | Makes someone an admin or a member. The owner's role cannot change. |
| `remove_member` | `DELETE /api/v1/org/members/{id}` | Removes a member, or withdraws a pending invitation. The owner cannot be removed, and a key cannot remove the person it belongs to. |
| `list_api_keys` | `GET /api/v1/org/api-keys` | `id`, `name`, `prefix`, `created_at`, `last_used_at`, `revoked_at`. Never a secret. |
| `create_api_key` | `POST /api/v1/org/api-keys` `{ "name" }` | Returns the key's `secret` once (201). Store it right away; it cannot be shown again. The new key belongs to the same person as the key that made it. |
| `revoke_api_key` | `DELETE /api/v1/org/api-keys/{id}` | Stops a key working at once. Repeating it is safe (`already_revoked: true`). |
| `get_audit_log` | `GET /api/v1/org/audit?envelope_id=&since=&limit=&cursor=` | Every audit event across the organization, oldest first, 100 per page (up to 500). Each event has `envelope_id`, `title`, `seq`, `at`, `type`, `recipient`, `ip`, `user_agent`, `meta`, `prev_hash`, `hash`. Continue with `next_cursor` until it is `null`. |

On `set_member_role` and `remove_member`, the id in the URL is the member `id` from `list_members`, or the person's URL-encoded email. Over MCP send `member_id` or `email`. Treat `create_api_key` like a password: put the secret in a secret store, never in a message or log.

## Safe retries (idempotency)

A call that times out may or may not have worked. Send an idempotency key and retry with the same request: you get the first response back and nothing is created, emailed or counted a second time.

```bash
curl -s -X POST $BOTUSIGN/api/v1/envelopes \
  -H "Authorization: Bearer $BOTUSIGN_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: enrollment-2291" \
  -d @envelope.json
```

- REST: the `Idempotency-Key` header (a body field `idempotency_key` also works, and the header wins if both are sent). MCP: the `idempotency_key` argument. Up to 255 characters; longer is `400 invalid_idempotency_key`. Use something that belongs to the thing you are doing, such as your own record id, not a fresh random value on each retry.
- It works on `create_envelope`, `create_envelope_from_template`, `send_envelope`, `revise_envelope` and `resend_envelope` (REST: `POST /api/v1/envelopes`, `POST /api/v1/envelopes/{id}/send`, `PATCH /api/v1/envelopes/{id}`, `POST /api/v1/envelopes/{id}/resend`), and on `bulk_send_from_template` and `bulk_create_envelopes`, where every row has a key too (see [Bulk](#bulk-send-to-a-list)). Other calls are safe to repeat or have no side effects.
- The same key with the same request returns the stored response, status and body, exactly as the first time. REST adds the header `Idempotent-Replay: true`. MCP adds `"idempotent_replay": true` to the result. A retry never changes the allowance: a create-and-send with one key is one envelope and one count.
- The same key with a different request (any different field, or a different operation) is `422 idempotency_key_reuse`. Send a new key for a new request.
- If the first request is still running when the retry arrives, the retry gets `409 idempotency_in_progress` with `Retry-After: 2`. Wait and send the same request again.
- A request that failed (for example `422 invalid_pdf`, a validation error, or a server error) stores nothing and frees the key, so you can fix the request and use the same key.
- Keys belong to the organization and are kept for 48 hours.

## Billing over the API

These calls take the same API key and work even when the organization has no plan, which is the state of a new organization after [sign-up](#sign-up-as-an-agent). A person is only needed for the two things an agent cannot do alone: pay with a card, and manage one.

| Call | Tool | What it does |
| --- | --- | --- |
| `GET /api/v1/billing` | `get_billing` | The plan, status, trial end, seats, allowance used, the plans on offer, whether a promo code can still be applied, and `next_steps`. |
| `POST /api/v1/billing/promo` `{ "code": "AUSTEN" }` | `apply_promo_code` | Starts a free trial with no card. Returns the trial and the new `billing`. |
| `POST /api/v1/billing/checkout` `{ "plan": "starter" \| "company" \| "org", "seats": 1 }` | `create_checkout_session` | Returns `checkout_url`. Give it to a person. They pay once and the plan turns on within seconds. `seats` (1 to 500) applies to Starter only. |
| `POST /api/v1/billing/portal` | `create_portal_session` | Returns `portal_url`: update the card, see invoices, cancel. Needs a paid plan. During a promo trial there is no billing account yet, so it answers `409 no_billing_account`; use `POST /api/v1/billing/checkout` to add a card. |

`GET /api/v1/usage` (`get_usage`) also works with no plan.

```json
{
  "plan": "none",
  "status": "none",
  "trial_ends_at": null,
  "current_period_end": null,
  "seats": 0,
  "cancel_at_period_end": false,
  "allowance": {
    "period": { "start": "2026-11-01T00:00:00.000Z", "end": "2026-12-01T00:00:00.000Z" },
    "agent_envelopes": { "included": 0, "used": 0, "remaining": 0, "over_allowance": 0, "overage_usd_so_far": 0, "overage_usd_per_envelope": 0.1 },
    "app_envelopes_sent": 0
  },
  "can_apply_promo": true,
  "checkout_available": true,
  "plans": [
    { "id": "starter", "name": "Starter", "price_usd": 9, "per": "sender", "included_agent_envelopes": 100 },
    { "id": "company", "name": "Company", "price_usd": 99, "per": "organization", "included_agent_envelopes": 1000 },
    { "id": "org", "name": "Org", "price_usd": 1100, "per": "organization", "included_agent_envelopes": 25000 }
  ],
  "next_steps": ["If you were given a promo code, POST {\"code\":\"…\"} to /api/v1/billing/promo. …"]
}
```

`plan` is the plan in force right now: `none`, `starter`, `company` or `org`. During a trial `status` is `trialing` and `plan` is the trial's plan (`org` for `AUSTEN`). `allowance.agent_envelopes.included` is 0 until a plan exists.

**Promo code.** `AUSTEN` is 3 free days on the Org plan, with no card. Once per organization and once per person (by email): a repeat call for the same organization returns the same trial and never extends it. It only works before the organization has any plan (`409 already_subscribed` otherwise). An unknown code is `404 invalid_promo`; one already used by the organization or the person is `409 promo_used`. The code is credited to the person who created the key.

**Checkout.** `create_checkout_session` returns a Stripe Checkout link for the plan you name. Asking for the plan the organization already has, or a lower one, is `409 already_subscribed` (use the portal to change what you pay); moving up is allowed. If checkout is not connected on this deployment the answer is `503 billing_not_configured`. `GET /api/v1/billing` shows `checkout_available`. Promo codes still work then.

**Portal.** `409 no_billing_account` until the organization has paid or has a trial that created a billing account. `503 billing_not_configured` when Stripe is not connected.

**What a refused call looks like.** With no plan, or with a plan that is too small, every other endpoint answers `403 plan_required`. The body always says how to fix it:

```json
{
  "error": { "code": "plan_required", "message": "…", "current_plan": "none", "required_plan": "starter" },
  "upgrade": {
    "message": "This organization has no active plan yet. GET /api/v1/billing shows the plans. If you were given a promo code, POST {\"code\":\"...\"} to /api/v1/billing/promo: the trial starts at once and needs no card. Otherwise POST {\"plan\":\"starter\"} to /api/v1/billing/checkout and give the checkout_url to a person, who pays once.",
    "required_plan": "starter",
    "billing_endpoint": "/api/v1/billing",
    "promo_endpoint": "/api/v1/billing/promo",
    "checkout_endpoint": "/api/v1/billing/checkout"
  }
}
```

A Starter key that calls a Company feature gets `required_plan: "company"`. Over MCP the same JSON is the text of the error result, and a plan-less key sees only the five billing and usage tools in `tools/list`.

## Envelope states

`draft`, `sent`, `viewed`, `completed`, `declined`, `voided`, `expired`.

- `sent` becomes `viewed` the first time any signer opens it.
- `completed` happens when every signer has signed.
- `declined` happens when a signer declines. `voided` is the sender cancelling. `expired` happens when the deadline passes (default 30 days, up to 90).
- `completed` and `voided` are final. `sent`, `viewed`, `expired`, and `declined` can be revised; `sent`, `viewed`, and `expired` can be resent.

## Billing and rounds

| | Starter | Company |
| --- | --- | --- |
| Price | $9 per sender per month | $99 per month, unlimited senders |
| Sends from the web app | Unlimited | Unlimited |
| New agent envelopes included each month | 100 per sender | 1,000 for the organization |
| After the allowance | $0.10 per new envelope | $0.10 per new envelope |
| Signers | Never billed | Never billed |
| Reminders | Free | Free |
| Revise or resend the same envelope | 20 free, then a new envelope | 20 free, then a new envelope |
| Webhooks | Yes | Yes |
| Templates and bulk send | No | Yes |

- A new envelope is counted once, when it is first sent through the API or MCP (`create_envelope` with `send: true`, `send_envelope`, `create_envelope_from_template`, each bulk row). Drafts are never counted.
- `round` is the document version. Round 1 is the first send. Every `revise_envelope` or `resend_envelope` adds one round. A sender gets 20 free revisions after the original send, so one envelope runs through round 21 and `rounds_remaining` counts down from 20 to 0. Revisions and resends share the same 20.
- The 21st revision or resend (document version 22) creates a new envelope. When `rounds_remaining` is 0, the next revise or resend returns `new_envelope: true` with a new `envelope.id`, `supersedes` pointing at the old id, and a `billing` block that counts it as a new send. The old envelope is voided if it was still live, and its links stop working. The old envelope shows `superseded_by`.
- Allowances are pooled across the organization. Starter's pool is 100 times the number of paid sender seats.
- Overage is added to the organization's next Stripe invoice as one $0.10 line per envelope. `get_usage` shows the running total.
- Billing is decided on the server for every request. Nothing the client sends changes the plan or the allowance.

## Webhooks

Starter and Company both get webhooks, so an agent can hear about every step without polling. Register an endpoint with `create_webhook` (REST `POST /api/v1/webhooks`, body `{ "url": "https://…" }`) or under Settings, Webhooks. Endpoints must be public HTTPS URLs (private and internal addresses are refused, both when you save the URL and when BotuSign connects). Up to 5 endpoints per organization. `create_webhook` returns `signing_secret` once; store it. `list_webhooks` shows endpoints and the 20 most recent deliveries with status codes and errors, `test_webhook` sends a `webhook.test` event and reports the response status, and `delete_webhook` removes an endpoint.

Every event carries `data.envelope` with `id`, `title`, `status`, and your `external_id` and `metadata` (`null` when you set none), so a receiver can match the event to its own record without another call.

The six events an agent needs:

| Event | Fires when | Notable `data` |
| --- | --- | --- |
| `envelope.sent` | The envelope is first sent | `envelope`, `recipients` |
| `envelope.viewed` | The first time anyone opens it. Later opens do not fire again, until a revision sends the envelope back to `sent` | `envelope` |
| `recipient.viewed` | Each person opens their own link for the first time, one event per person. Later opens by the same person do not fire again, until a revision resets them | `envelope`, `recipient`, `viewedAt` |
| `recipient.signed` | Each signer finishes, one event per signer | `envelope`, `round`, `recipient`, `signed`, `signers`, `awaiting` (emails still to sign; empty on the last signature) |
| `envelope.declined` | A signer declines | `envelope`, `declinedBy` |
| `envelope.completed` | The last signer signs | `envelope`, `completedAt`, `recipients`, `documents` with 24 hour links to the signed PDF and certificate |

Also sent: `envelope.revised` and `envelope.resent` (with `round` and `recipients`), `envelope.voided` (with `reason`), `envelope.expired`, and `webhook.test`. For a two-person envelope an agent receives `envelope.sent`, `recipient.viewed` and `envelope.viewed` (the first person opening it), `recipient.signed` (`signed: 1`, `awaiting: ["second@…"]`), another `recipient.viewed` when the second person opens their link, `recipient.signed` (`signed: 2`, `awaiting: []`), then `envelope.completed`. Events are queued in that order, but each one is its own HTTP request, so a retry or a slow receiver can see them out of order. Use `signed` and `signers`, not arrival order, to know where the envelope stands.

`recipient.signed`:

```json
{
  "id": "event uuid",
  "type": "recipient.signed",
  "createdAt": "2026-11-02T20:03:00.000Z",
  "data": {
    "envelope": { "id": "uuid", "title": "Fellow packet: Ada Okafor", "status": "viewed", "external_id": "enrollment-2291", "metadata": { "cohort": "12" } },
    "round": 1,
    "recipient": { "id": "uuid", "name": "Ada Okafor", "email": "ada@example.com", "role": "applicant", "order": 1, "status": "signed" },
    "signed": 1,
    "signers": 2,
    "awaiting": ["hannah@gauntlet.ai"]
  }
}
```

`recipient.viewed` (the first time Ada opens her link; `viewedAt` is the same moment as `viewed_at` on the recipient):

```json
{
  "id": "event uuid",
  "type": "recipient.viewed",
  "createdAt": "2026-11-02T20:02:00.000Z",
  "data": {
    "envelope": { "id": "uuid", "title": "Fellow packet: Ada Okafor", "status": "viewed", "external_id": "enrollment-2291", "metadata": { "cohort": "12" } },
    "recipient": { "id": "uuid", "name": "Ada Okafor", "email": "ada@example.com", "role": "applicant", "order": 1, "status": "viewed" },
    "viewedAt": "2026-11-02T20:02:00.000Z"
  }
}
```

For an envelope with two parallel signers an agent receives `recipient.viewed` once for each, and `envelope.viewed` once, with the first. Both are queued in the same step, but they are separate requests: do not rely on arrival order.

Request headers:

```
POST <your url>
Content-Type: application/json
User-Agent: BotuSign-Webhooks/1
BotuSign-Event: envelope.completed
BotuSign-Delivery: <delivery uuid>
BotuSign-Signature: t=1762113600,v1=<hex>
```

`envelope.completed` body:

```json
{
  "id": "event uuid",
  "type": "envelope.completed",
  "createdAt": "2026-11-02T20:05:00.000Z",
  "data": {
    "envelope": { "id": "uuid", "title": "Fellow packet: Ada Okafor", "status": "completed" },
    "completedAt": "2026-11-02T20:05:00.000Z",
    "recipients": [{ "id": "uuid", "name": "Ada Okafor", "email": "ada@example.com", "role": "applicant", "order": 1, "status": "signed" }],
    "documents": {
      "signedPdf": { "url": "https://…", "sha256": "…", "expiresInSeconds": 86400 },
      "certificatePdf": { "url": "https://…", "expiresInSeconds": 86400 }
    }
  }
}
```

`envelope.completed` carries 24-hour download links so an agent can file both PDFs the moment the last person signs.

Verify the signature before trusting a request:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, header: string, rawBody: string, toleranceSeconds = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
  if (!parts.t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}
```

Reply with any 2xx within 8 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours (six attempts in total), then marked failed. Delivery history, with status codes and errors, is on the Webhooks settings page. Use `BotuSign-Delivery` or the event `id` to de-duplicate: a retry sends the same payload and ids. Redirects are not followed.

## Errors

Every error is `{ "error": { "code": "…", "message": "…" } }`.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | Body failed validation; `issues` lists each path. Also `invalid_json`, `unsafe_url`, `nothing_to_revise`, `invalid_cursor`, `invalid_idempotency_key`. |
| 400 | `invalid_template`, `unknown_role`, `invalid_csv` | A template needs a name, 1 to 20 roles (at least one that signs), unique role keys, and fields on pages that exist; a field or placement names a role the template does not have; `recipients_csv` has no header row, no `name` or `email` column, or a bad `meta_` column. The message says which. |
| 401 | `unauthorized` | Missing, wrong, or revoked key. |
| 403 | `plan_required` | No active plan, or the tool needs a bigger plan (templates and bulk send need Company; running the organization from the API needs Org). The body has an `upgrade` field: see [Billing over the API](#billing-over-the-api). |
| 403 | `forbidden` | Org tools only: the key was made by a member (not an owner or admin) or by someone who has left, or the action would remove or demote the owner or the key's own person. Make a key as an owner or admin. |
| 404 | `not_found`, `invalid_promo`, `invalid_device_code` | Unknown id, or it belongs to another organization. `invalid_promo`: the promo code does not exist. `invalid_device_code`: the sign-up poll does not know that `device_code`. |
| 409 | `invalid_state`, `nothing_to_remind`, `conflict` | The envelope is not in a state that allows this (for example resend on a completed envelope), or another change landed first. Read `available_actions` and retry. |
| 409 | `template_archived` | The template was archived and cannot start new envelopes. Use another template or create a new one. |
| 409 | `idempotency_in_progress` | A request with the same idempotency key is still running. Wait (`Retry-After`) and send the same request again. |
| 409 | `already_subscribed`, `promo_used`, `no_billing_account` | Billing: the organization already has a plan (a promo code starts a first trial only, and checkout cannot go to the same or a lower plan), the promo code was already used by this organization or person, or the portal has no billing account yet. |
| 413 | `payload_too_large` | Body over 16 MB or PDF over 10 MB. |
| 422 | `invalid_pdf` | The file is not an acceptable PDF. |
| 422 | `idempotency_key_reuse` | The idempotency key was already used for a different request. |
| 422 | `anchor_not_found`, `invalid_tag`, `invalid_placement` | Field placement could not be done as asked. `anchor_not_found` lists the closest text in the PDF (`closest_matches`); `invalid_tag` names the tag and page (a tag for signer 3 when there are 2 signers, a bad `key=`); `invalid_placement` says why (rotated page, a `cc` recipient with fields, a page that does not exist). Nothing was created. See [Placing signature fields without coordinates](#placing-signature-fields-without-coordinates). |
| 422 | `limit_reached` | More bulk rows than the plan allows in one call (100 Company, 500 Org). |
| 429 | `rate_limited` | Slow down; see `Retry-After`. |
| 503 | `email_not_configured` | Email delivery for botusign.com is not connected or the sending domain is not verified yet. Do not retry. Sends from the web app still hand the sender the signing links. |
| 503 | `billing_not_configured` | Checkout or the billing portal is not connected on this deployment. A promo code still works. A person can choose a plan in the web app. |

A signer who opens a link that a revision or resend replaced sees "This agreement was updated" (HTTP 410 from the signing API). That is expected; they use the newest email.

## Limits

PDF 10 MB, 100 pages. 20 recipients and 200 fields per envelope. Bulk send 100 rows per call (500 on Org); a recipients CSV up to 2 MB. Templates up to 20 roles. Title 160 characters, message 2,000. Signature image 200 KB. Expiry 1 to 90 days. 5 webhook endpoints. 20 free revisions or resends per envelope, then a new envelope. `external_id` 200 characters; `metadata` 20 keys, key 40 characters, value 500. Idempotency key 255 characters, kept 48 hours. `list_envelopes` returns up to 100 per page. Agent sign-up code valid 15 minutes.

## Smoke test

```bash
export BOTUSIGN=https://botusign.com
curl -s $BOTUSIGN/api/health
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" $BOTUSIGN/api/v1/usage
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" $BOTUSIGN/api/v1/billing
curl -s -H "Authorization: Bearer $BOTUSIGN_KEY" "$BOTUSIGN/api/v1/envelopes?awaiting_signature=true"
```

The agent skill that wraps these calls lives at `/cursor/stores/self/skills/botusign/SKILL.md`.
