# Compatibility reference

> Measured wire behaviour Signclad reproduces on purpose: status casing, transient create statuses, the "t" checkbox literal, the 0.75 pt coordinate unit, and the error envelopes — with the quirks it deliberately does not reproduce.

Source: https://signclad.com/docs/compatibility/

Signclad's `/api/v1` is wire-compatible with an existing third-party eSignature API, here called
**the compatibility target**. Every operation that is not marked `x-signclad-extension: true` in
[the OpenAPI document](https://signclad.com/openapi/signclad.v1.yaml) has the same path, method, request fields,
response fields, status strings, error envelopes, and webhook event names. A working client against
that API works against Signclad after changing two things: the base URL and the API key.

This page is the behavioural half of that promise — the part OpenAPI cannot express. It was
measured, not inferred: recorded against the compatibility target's live API in test mode, then
reproduced and pinned by Signclad's own test suite. If you are porting an integration, read this
before you read anything else; most of what breaks in a migration is on this page.

## Conventions

| Item | Signclad |
|---|---|
| Base URL | `https://api.signclad.com/api/v1` |
| Auth | `X-Api-Key: <token>` — sandbox keys are prefixed `sk_test_`, live keys `sk_live_` |
| Trailing slashes | Every path routes with and without one. `/documents/{id}` and `/documents/{id}/` return byte-identical bodies. |
| Unknown request keys | Silently dropped. Never rejected, never echoed back. Every request body is `additionalProperties: true`. |
| Success codes | `200` on GET/PUT/PATCH, `201` on create/send/remind, `204` on DELETE |
| Rate limits | **120/min** general, **10/min** creates and mutations, **50/min** unauthenticated. Failed creates still charge the create bucket. Two details are easy to trip on: `DELETE` is charged to the **general** bucket, not the tight one — the fixtures recorded `x-ratelimit-limit: 120` on every delete — and `POST /files` has its own **120/min** upload bucket, so a batch of files cannot exhaust the create budget before you send anything. |
| `x-ratelimit-reset` | An **ISO 8601 timestamp**, not a seconds count. The additive `RateLimit-Reset` is in seconds. |
| Pagination | The compatibility surface paginates only bulk-send documents: `limit` 1–50 (default 10), `page` ≥ 1, with `current_page`, `next_page`, `previous_page`, `total_count`, `total_pages`. Extension list endpoints are cursor-paginated instead. |
| IDs | UUIDs for server objects. Recipients, placeholders, and field `api_id`s are **caller-supplied strings**. |
| `metadata` | Up to 50 pairs. Keys under 40 characters, values under 500, values must be strings. |

## Statuses

Document and template statuses are **capitalised**. Recipient statuses are **lowercase**. They are
not the same vocabulary, and no amount of wishing makes them one.

- Documents: `Draft, Created, Sending, Sent, Pending, Viewed, Completed, Manually completed, Declined, Canceled, Bounced, Blocked, Error, Expired`.
- Templates: `Created, Draft, Available`. Only an `Available` template can spawn a document.
- Recipients: `created, draft, sent, viewed, completed, declined, bounced`. A finished recipient is
  **`completed`**, never `signed`.

### Create and send answer with a transient status

`POST /documents` and `POST /document_templates` answer `"Created"` regardless of what you passed
for `draft`. `POST /documents/{id}/send` and `POST /document_templates/documents` answer `"Draft"`.
The settled status appears only on the next `GET`.

This catches every integration once. If your code reads `status` off a create response and decides
anything from it, it is reading a placeholder. Read the record back, or wait for the webhook.

## Field serialisation on the wire

These are the details that make a naive port fail its first test:

- **`width` and `height` come back as strings** (`"200.0"`, `"50.0"`). `x`, `y`, and `page` are
  numbers.
- **A checked checkbox serialises as `"t"`**, the Postgres boolean literal — not `true`.
- **`files[].pages_number` is `0` in the create response** and carries the real count on the next
  `GET`. Page counting is asynchronous. The extension field `files[].status` is `processing` until
  ingest finishes.
- **`GET` reorders fields by type.** The create response echoes your request order; a later `GET`
  regroups the same fields by field type. Never key off array index — use `api_id`.
- **There is no document-level `embedded_signing_url`**, not even on a document with exactly one
  embedded signer. Only `recipients[].embedded_signing_url` exists.
- **`signing_url` is regenerated on send.** A recipient URL captured from a draft response is dead
  once the document is sent. Mint a fresh one rather than caching it.
- **`completed_pdf_url` is not returned on the document object**, even when the document is
  `Completed`. Fetch `GET /documents/{id}/completed_pdf` for the bytes.

## Coordinates

The origin is the **top-left of the visible page, with y increasing downward**, and
**1 API unit = 0.75 PDF points** — CSS pixels at 96 DPI, so a US Letter page is 816 × 1056 units.
The signing viewer lays the page out at exactly that size and positions each field at its API
`x`/`y` 1:1.

Size limits: signature and initials fields up to 200 units tall, every other field up to 74.

> The compatibility target's own completed-PDF stamper measures 0.7528125 pt per unit — 0.37%
> larger, up to about 2.3 pt of drift across a letter page. That is a rendering wart, not the
> intended model. Signclad implements 0.75 and does not reproduce the drift, so a field lands in the
> same place in the signing view and in the sealed PDF.

## Error envelopes

`401` — a JSON body, not a plain string:

```json
{
  "message": "Missing or invalid authorization key",
  "meta": {
    "error": "api_key_unauthorized_error",
    "message": "Not valid authorization token",
    "messages": ["Not valid authorization token"]
  }
}
```

`404` — and `GET /documents/{id}/completed_pdf` before completion returns exactly this, byte for
byte. There is no marker distinguishing "not finished yet" from "no such document", so a retry loop
must treat `404` on that endpoint as "not ready", not as "gone":

```json
{
  "message": "Not found",
  "meta": {
    "error": "record_not_found",
    "message": "Couldn't find the document requested",
    "messages": ["Couldn't find the document requested"]
  }
}
```

`422` — `{"errors": {…}}`, where each value is either a **string** or a **nested object keyed by an
error code** (or by a per-item key, then a code). **No arrays appear anywhere.** Model it as
`string | { [code_or_item]: string | object }`:

```json
{ "errors": { "files": "At least one file should be present." } }
```

```json
{ "errors": { "recipients": { "duplicated_emails": "These emails are duplicated: dup@example.com." } } }
```

```json
{ "errors": { "template_fields": { "tdate1": { "invalid_date_format": "DateField value must be in Iso8601 format." } } } }
```

The last one is keyed by the field's `api_id`, then by the error code, and `Iso8601` is capitalised
exactly so. `template_fields` dates need a full ISO 8601 value (`2026-07-02T00:00:00Z`); a bare
`Y-m-d` is rejected. The accepted value comes back rendered through the field's `date_format`.

See [Errors and rate limits](https://signclad.com/docs/errors-and-rate-limits/) for the full catalogue, including the
Signclad-only fields added alongside these.

## Test mode does not suppress email

Test mode excludes a document from billing and watermarks it as not legally binding. It does **not**
stop mail going out. A send to an address that does not exist really bounces: the *document* moves
to `Bounced` while the recipients stay `sent` with `bounced: true`. `send_email: false` on a
non-embedded document is echoed back as `false` and the mail still goes — the flag is not an
indicator of delivery.

The only genuinely mail-free send is `embedded_signing: true` together with `send_email: false`.
Use addresses you control for everything else. [Sandbox and test mode](https://signclad.com/docs/sandbox-and-test-mode/)
covers the rest.

## Webhook events

The preserved event names are `document_created`, `document_sent`, `document_viewed` (every view,
not just the first), `document_in_progress` (once), `document_recipients_updated`,
`document_signed` (per recipient), `document_completed`, `document_expired`, `document_canceled`,
`document_declined`, `document_bounced`, `document_error`, `template_created`, and
`template_error`.

The compatibility payload is:

```json
{
  "event": { "hash": "…", "time": 1789050255, "type": "document_completed",
             "related_signer": { "email": "jane@example.com", "name": "Jane Doe" } },
  "data": { "object": { "…": "the full document or template" }, "account_id": "…" }
}
```

`hash` is `hex(HMAC-SHA256(key = webhook id, data = "<type>@<time>"))`. It authenticates nothing
about the body: there is no body signature and no timestamp tolerance in that scheme. Signclad
accepts it for compatibility and adds a real one — `X-Signclad-Signature`, an HMAC over the raw
body with a timestamp — which is what you should verify against. Signclad's `data` also carries
`workspace_id` beside `account_id`, and its events carry a stable `event.id` to dedupe on.

[Webhooks](https://signclad.com/docs/webhooks/) has both schemes in full, and
[Webhook events](https://signclad.com/docs/webhook-events/) is the complete catalogue, generated from the
specification, with every additive event marked as such.

## Text tags

Signclad's preferred syntax is `[type|modifier|modifier]`, for example
`[sig|signer1|req|id:borrower_signature]`.

For migration, the compatibility target's positional syntax —
`{{type:signer:required:label:prefill:api_id:width:height}}`, with blanks keeping defaults
(`{{signature::y}}`) — is also accepted, including its long and short type names, its text and date
trailing options, and its variables (`{{set=a1:text:1:y:Age}}` followed by `{{a1}}`). Both formats
may appear in the same file. Enable either with `text_tags: true` on the document or the template.
See [Text tags](https://signclad.com/docs/text-tags/).

## What Signclad deliberately does not reproduce

Compatibility is not bug-for-bug. These are the intentional divergences:

- **The coordinate drift.** 0.75 pt per unit everywhere, in the viewer and in the sealed PDF.
- **Excluding a role that has fields.** `exclude_placeholders` naming a role with assigned fields is
  a `422` rather than a silent drop of the recipient and its fields.
- **A single bad prefill failing the whole request.** Send `skip_invalid_fields: true` and invalid
  `template_fields` entries are reported in `warnings[]` instead.
- **Unsigned webhooks.** The compatibility hash is still emitted; a real body signature is emitted
  alongside it.
- **Silent webhook failure.** Endpoints carry health states, a delivery log, and replay.

## Related

- [Migrating to Signclad](https://signclad.com/docs/migration/) — the staged plan, and per-vendor mapping.
- [Creating a document](https://signclad.com/docs/creating-a-document/) — every option on the create call.
- [API reference](https://signclad.com/docs/api/) — the generated reference, one page per endpoint group.
