Changelog

Public API changes. Reverse-chronological. Breaking changes flagged.

This page tracks visible API changes — new endpoints, response shape tweaks, error-code additions, deprecations. Internal refactors and UI changes don't make it here.

2026-09-10 — Pin an API key to your own IP addresses

Added — a per-key IP allowlist

  • Every API key now carries a list of IPv4/IPv6 addresses and CIDR blocks (203.0.113.7, 203.0.113.0/24, 2001:db8::1, 2001:db8::/32), managed on the key's card in Settings → API, up to 50 entries. Entries are canonicalised on save, so what the card shows is what we match.
  • An empty list means unrestricted. Nothing changes for any existing key, and nothing changes for you until you deliberately add an entry. This is the opposite of the embed-origin list, where empty means "cannot embed" — worth reading twice, it is the half people get wrong.
  • The address we compare is the one our platform observed the call arriving from. X-Forwarded-For is not used for the decision: a caller can write that header itself, so trusting it would be decoration. For the same reason we do not and will not authorise REST calls by Origin — see Security.

Added — 403 ip_not_allowed

  • A key that is valid but called from an address its allowlist does not cover answers 403 { error, code: "ip_not_allowed", ip } instead of the generic 401 invalid_key — a different problem deserves a different code. ip is the address we saw (null when it could not be determined, in which case a restricted key fails closed). Documented on every key-authenticated operation in the OpenAPI spec.
  • The control gates API keys only: never the workspace UI, never the signing pages. A mistyped address locks out an integration, never a person — sign in from anywhere and empty the list.

Full contract: Restricting a key to your own IP addresses.

2026-09-10 — Embedded signing: sign inside your own page

Your signer can now sign in an <iframe> or a mobile webview on your domain, with your branding around it. Everything here is additive; no existing endpoint changed shape.

Added — POST /v1/embedded/sign-sessions

  • Exchanges an existing signing request for a short-lived, single-signer session: { signing_request_id, origin, ttl_seconds?, locale?, theme? }201 { id, embed_url, expires_at, signing_request_id }. ttl_seconds is 60…900 (default 300) and out-of-range is refused, not clamped. embed_url is on the workspace's own signing host, so the signer never sees a domain their firm did not put in front of them. Enterprise only (402 enterprise-required); needs a key with the embedded scope (a full key satisfies it); on the standard 60/minute per-key bucket.
  • GET /v1/embedded/sign-sessions/{id}{ id, status, expires_at, first_seen_at, last_seen_at, signing_request_id } with status one of active / expired / revoked. DELETE revokes, idempotently, and answers { "ok": true }. Neither is Enterprise-gated: revocation is a safety valve and never depends on billing.
  • New error codes: invalid_origin (400), origin_not_allowed (400), request_not_signable (409), embedding_unavailable (503, while a deployment has not applied the migration).

Added — ancestor origins are registered per API key

  • Embedding is a permission, not a default. The origins allowed to frame a session are listed on the key in Settings → API (up to 20, exact https://host[:port] match, no wildcards). A key with an empty list cannot mint a session.

Added — postMessage schema v1 and a documented CSP

  • The frame posts { v: 1, type, session_id, signing_request_id, at, detail? } to window.parent with targetOrigin set to the registered origin, never '*'. Types: wesign.ready / viewed / signed / declined / expired / error. They are a UX signal onlywebhooks remain the source of truth.
  • Hosts allow exactly one directive: frame-src https://<slug>.letssign.now https://<slug>.wesign.now. We answer embed paths with Content-Security-Policy: frame-ancestors <the session's one origin> and omit the app's global X-Frame-Options there, defaulting to frame-ancestors 'none' whenever the session cannot be read.

Full contract, a host-side listener and a copy-pasteable example page: Embedded signing.

Unchanged — the retired endpoint

  • POST /v1/embedded/sessions — no hyphen, the 2026 guest-placement flow — still answers 410 embedded_sessions_retired. The two share nothing but a word.

2026-09-10 — One-time hook secret, honest errors, expiry events

Changed — document.completed

  • audit_trail_url is now https://api.wesign.now/v1/documents/{document_id}/audit-trail — key-authenticated, same host, same Bearer key and same status rules as signed_pdf_url. Previously a token link on the web origin whose route no longer existed (it answered 404).
  • The event fires only when every signer row on the document is signed — the same rule GET /v1/documents/{id} (status: signed), /signed and /audit-trail already applied. A document with a declined, withdrawn or expired signer no longer receives it; before, it fired and both URLs answered 409 not_complete. See what "completed" means.

Added — POST /v1/hooks returns the secret once

  • The create response now carries secret (whsec_…), shown once. GET /v1/hooks never returns it; lose it and you delete + resubscribe. Additive — the response keeps every existing field.

Added — signing_request.expired is emitted

  • An hourly cron flips a pending / viewed request to expired once its expires_at has passed and emits the event exactly once per request: signing_request_id, document_id, signer { email, name }, expired_at. Queued sequential followers behind an expired signer do not emit. GET /v1/signing-requests/{id} and the signers[] on GET /v1/documents/{id} now report status: "expired" for such rows (previously the row stayed pending and expiry was only visible via expiresAt). remind on one answers 409 expired, withdraw 409 invalid_state — both as documented.

Changed — POST /v1/templates/{id}/instantiate error bodies

  • Every error now carries a stable code. New: invalid_key (401), invalid_request (400 body validation), not_found (404), version_not_found (404, meta.version + meta.current_version), missing_recipients (400, meta.slots), db_failed (500). Messages are unchanged; only the code field was added.

2026-09-09 — api.wesign.now is the canonical host

letssign.now now also runs as wesign.now, and the public API takes the new name first. Everything here is additive: every old spelling keeps working, nothing is removed.

Base URL

  • Canonical: https://api.wesign.now/v1. Every example on this site now uses it.
  • https://api.letssign.now/v1 is a permanent alias — identical routing and responses. Do not migrate a working client on our account.
  • Path-style URLs on the web hosts (https://letssign.now/api/v1/…) were never the contract; the apex only redirects. See Base URL.

API keys

  • New keys are minted as wsk_live_<32 hex>; the dashboard shows the first 12 characters (wsk_live_a1b…).
  • Existing lsk_live_ keys are accepted forever. No rotation needed.

Webhook headers

  • Every delivery now carries X-WeSign-Signature, X-WeSign-Event-Id and X-WeSign-Event and the X-LetsSign-* trio with byte-identical values. The scheme is unchanged: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<rawBody>")>. The User-Agent stays letssign.now-webhooks/1.0.
  • The docs now spell out the exact message format, both header families, a 300 s replay tolerance, event-id idempotency, and give Node and PHP verifiers — Verifying a signature.

Changed — document.completed.signed_pdf_url

  • Now https://api.wesign.now/v1/documents/{document_id}/signed: a key-authenticated API URL on the canonical host (Bearer key of the owning workspace; 409 not_complete until every signer signed; 404 not_found from another workspace).
  • Previously it was an unauthenticated token link on the web origin. A receiver that fetched it without a key must now send the bearer; one that pins the origin should pin api.wesign.now. No integrator was live on the old shape. audit_trail_url moved the same way one day later — see the entry above.

Docs

  • Second factor by SMS (phone_e164 + require_sms_verification) is documented.
  • Embedded signing states what exists today and what the iframe phase will be.
  • The events table lists only events that are actually emitted.

2026-09-06 — Review before sending

  • "review": true on POST /v1/templates/{id}/instantiate and /generate stages the instance instead of sending it; new POST /v1/documents/{id}/confirm and /discard, new GET /v1/documents/{id}/pdf for a finalized file-only instance, "validate_only": true dry runs, and the template_instance.staged / .confirmed / .discarded webhook events — see Templates.

2026-07-31 — Retirements (breaking, zero production usage)

  • placement="manual" on POST /v1/signing-requests400 placement_retired.
  • POST /v1/documents (ingest-by-URL for manual placement) → 410 placement_retired. file_url on POST /v1/signing-requests covers ingest-by-URL.
  • POST /v1/embedded/sessions410 embedded_sessions_retired. GET/DELETE /v1/embedded/sessions/{id} still answer for existing rows.

All three answer a stable, descriptive 410 rather than a 404, so an old integration fails loud.

2026-04-30 — v1 launch

The v1 public API launches with the full reference live.

New endpoints

MethodPathPurpose
POST/v1/signing-requestsCreate — multipart upload, four placement modes
GET/v1/signing-requests/{id}Read one signer + last 50 audit events
POST/v1/signing-requests/{id}/remindRe-send invite
POST/v1/signing-requests/{id}/withdrawCancel in-flight
GET/v1/documents/{id}Holistic doc + signers + rollup status
GET/v1/documents/{id}/signedFinal PAdES-sealed PDF
GET/v1/documents/{id}/audit-trailGenerated audit-trail PDF

Concepts

  • Idempotency-Key header with a Stripe-style pending lock for concurrent retries — see Idempotency.
  • 60 req/min/key rate limiter with Retry-After — see Rate limits.
  • Workspace-scoped Bearer keys; rotation via mint-then-revoke.
  • Multipart placement modes: anchors / auto_append / explicit / manual — see Placement modes. Manual mode collapses the previous "magic-link" mode into a single URL that works with or without a workspace login.
  • Doc-scoped callback webhooks via callback_url on POST. HMAC-SHA256 signed events, 7-attempt exponential-backoff retries.

Subdomain branding

Branded + Teams tier workspaces get <slug>.letssign.now/sign/... for signer-facing pages. Wildcard cert provisions automatically; no DNS work per-customer.

Migration from /docs/api

If you bookmarked the old monolithic https://letssign.now/docs/api page: it now permanently redirects to /docs. The content has been split into the reference pages listed in the sidebar.

If you used a deep section anchor like /docs/api#errors, it redirects to /docs (apex). Direct links to specific topics are now e.g. /docs/errors, /docs/webhooks.

Versioning policy

Breaking changes ship under a new path version (/v2/*). The existing /v1/* surface stays operational for at least 12 months after /v2 GA. Non-breaking additions (new optional fields, new events, additive enum values) ship to /v1 with a changelog entry.