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.

Updated

On this page

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 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 Three buckets: 120/min general, 10/min creates and mutations, 50/min unauthenticated. Failed creates still charge the create bucket.
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_ids 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:

{
  "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”:

{
  "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 }:

{ "errors": { "files": "At least one file should be present." } }
{ "errors": { "recipients": { "duplicated_emails": "These emails are duplicated: [email protected]." } } }
{ "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 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 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:

{
  "event": { "hash": "…", "time": 1789050255, "type": "document_completed",
             "related_signer": { "email": "[email protected]", "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 has both schemes in full, and 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.

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.