For: developers (or AI agents) integrating an application with the Talyana Sign e-signature platform at https://talyanasign.com.
1. Base URL, auth, and conventions
Base URL: https://talyanasign.com
Two authentication modes, both accepted on every org-scoped route:
| Header | Who | Notes |
|---|---|---|
X-API-Key: sk_… |
Server-to-server integrations | The org-level key. Keep it server-side ONLY — never in browser JS. Sees every envelope in the org. |
X-Auth-Token: <token> |
Console users (per-user sessions) | From POST /api/auth/login. 7-day expiry. v25: sees only the user's own envelopes plus ones shared with them (admins/viewAll see all) — see §4b. |
Connection test: GET /api/ping (either auth mode) → {"ok":true,"org":"…","plan":"…","auth":"apiKey|session"} — the right call for a "Test connection" button. A non-technical quick-connect kit (drop-in PHP class + step-by-step guide) ships at /connect/ on every install.
Conventions:
- All request/response bodies are JSON. The
Content-Typerequest header is not enforced (the body is parsed as JSON regardless), but sendapplication/jsonanyway. - Every error is
{"error": "human-readable message"}with a proper 4xx/5xx status. Uncaught server errors return{"error": "Internal error"}with status 500. - All timestamps are UTC ISO-8601 strings (
2026-07-31T14:00:00.000Z). - No trailing slashes:
GET /api/envelopes/is a 404; useGET /api/envelopes. - A request body larger than the server's
post_max_sizereturns a clear413. Keep base64-encoded PDFs comfortably under that (the decoded document limit is 15 MB). - Responses carry
Cache-Control: no-store— never cache envelope state.
CORS: browser pages may call the API only from origins in the server's corsOrigin allowlist (comma-separated, configured server-side). Server-to-server calls (PHP curl etc.) are unaffected by CORS. If you embed the signing page or call the API from browser JS on another subdomain, that subdomain must be added to corsOrigin in the server's config.php first.
2. Envelope lifecycle
draft ──send──▶ sent ──all signed──▶ completed (sealed PDF + certificate)
│──any decline──▶ declined
│──void (sender / void perm / API key)──▶ voided
└──expiry (default 30 days)──▶ expired
All transitions are enforced server-side with atomic guards; you can retry safely (a duplicate /send returns 409 and does NOT invalidate the first call's signing links).
3. Core endpoints
Create an envelope — POST /api/envelopes
{
"title": "Hardship Assistance Agreement", // required, ≤300 chars
"message": "Please review and sign.", // optional, ≤5000 chars
"documentBase64": "<base64 PDF | PNG | JPEG>", // required (unless documents[] below), ≤15MB decoded
"documents": [ // v72 ALTERNATIVE to documentBase64: multi-document envelope
{"name": "Consent Form", "documentBase64": "<base64 PDF|PNG|JPEG>"}, // 1–10 docs, ≤15MB combined,
{"name": "W-9 Request", "documentBase64": "<base64 PDF>", // names ≤190 chars (default "Document N")
"omitPages": [2, 5]} // v77 OPTIONAL: 0-based ORIGINAL page indices to remove at preparation
],
"markups": [ // v73 OPTIONAL sender markup — burned into the PDF at create
{"type": "text", "page": 0, "x": 40, "y": 100, "w": 220, "h": 24, "text": "Amended per counsel", "fontSize": 12},
{"type": "strike", "page": 0, "x": 40, "y": 160, "w": 150, "h": 16, "color": "red"},
{"type": "whiteout", "page": 0, "x": 40, "y": 200, "w": 160, "h": 40},
{"type": "check", "page": 1, "x": 300, "y": 100, "w": 20, "h": 20}
],
"senderEmail": "agent@yourfirm.com", // optional; gets decline/completion notices. v25: when it matches
// a user on the account, the envelope is ATTRIBUTED to that seat —
// it appears in that person's own console view (see §4b)
"routing": "parallel", // or "sequential" (signing order enforced)
"cc": [{"name": "Board Chair", "email": "chair@…"}], // optional, completion copies
"sendReceipt": true, // optional, default false (v78): at SEND time, email the sender a receipt listing who was emailed, and notify each CC they're copied. Completion emails unchanged.
"allowDelegation": false, // optional (v93): may a signer hand the envelope to someone else ("Assign to someone else" in the signing page's ⋯ menu)? Omitted → the account's default (Admin → Organization → Signing settings, v94; true unless an admin changed it; if the admin LOCKED it, a different explicit value is a 400 — v95). Set false when YOUR system vets who may sign (e.g. an authorized-signer roster): the menu item disappears and POST /api/sign/{token}/delegate answers 403 (audited as signer.delegate_refused). Echoed on every read as `allowDelegation`.
"webhookUrl": "https://your-app.example.com/hooks/talyanasign", // optional, public https URL
"metadata": {"applicationId": "HA-2026-0042"}, // optional, ≤60KB; echoed in webhooks
"signers": [ // required JSON LIST (not object), 1–10
{"firstName": "Dana", "lastName": "Donor", // v52: preferred — or legacy "name": "Dana Donor"
"email": "dana@example.com", "assuranceLevel": "email"},
{"name": "Wes Witness", "email": "wes@example.com",
"assuranceLevel": "access_code", "accessCode": "1234"}
],
"fields": [ // required list, 1–200
// v98: add "fieldOrigin": "bottom-left" at the top level if your y is PDF-native (measured up from the bottom); default "top-left"
{"signerIndex": 0, "type": "signature", "page": 0, "x": 50, "y": 600, "w": 180, "h": 50},
{"signerIndex": 0, "type": "date_signed", "page": 0, "x": 260, "y": 600, "w": 120, "h": 24},
{"signerIndex": 0, "type": "name", "page": 0, "x": 50, "y": 560, "w": 180, "h": 22},
{"signerIndex": 0, "type": "email", "page": 0, "x": 260, "y": 560, "w": 200, "h": 22},
{"signerIndex": 0, "type": "text", "page": 0, "x": 50, "y": 500, "w": 200, "h": 22, "required": true},
{"signerIndex": 0, "type": "company", "page": 0, "x": 50, "y": 470, "w": 170, "h": 22, "required": true},
{"signerIndex": 0, "type": "title", "page": 0, "x": 260, "y": 470, "w": 150, "h": 22, "required": false},
{"signerIndex": 0, "type": "checkbox", "page": 0, "x": 50, "y": 430, "w": 16, "h": 16, "required": false},
{"signerIndex": 1, "type": "initials", "page": 1, "x": 400, "y": 80, "w": 60, "h": 32},
{"signerIndex": 0, "type": "signature", "anchor": "/sig1/", // v86 AutoPlace: find this text in the
"anchorOffset": {"dx": 0, "dy": -6}}, // final PDF and place the field there
{"signerIndex": 0, "type": "initials", "anchor": "/init/", "anchorAll": true}, // one field per occurrence
{"signerIndex": 0, "type": "signature", "page": -1, "x": 54, "y": 150, "w": 180, "h": 50} // v88: -1 = LAST page
]
}
201→ full envelope summary includingid(env_…). Envelope starts as a draft; nothing is emailed yet. Plan quotas (v53, catalog-driven since v26): each plan's monthly envelope quota and seat limit live in the platform's plan catalog (defaults:starter5/mo · 1 seat,personal100/mo · 1 seat,businessunlimited · 4 seats,organizationunlimited · unlimited); a hit cap returns a clear403. Admin-provisioned and pre-v53 orgs areorganization(unlimited). The pre-v26 slugprofessionalis a permanent alias ofpersonal.- Multi-document envelopes (v72): pass
documents[]instead ofdocumentBase64and the sources are merged into ONE PDF at creation, in list order — the signer sees and seals a single continuous document (the "signer sees exactly what gets sealed" invariant is untouched). Fieldpageindexes the merged PDF. Per-source provenance (name,sha256,sourceFormat,pageStart,pageCount, all 0-based) is returned asdocumentsin the envelope summary and printed on the certificate under SOURCE DOCUMENTS. Images may be mixed in freely (each wraps to a page). LegacydocumentBase64behaves exactly as before (documents=nullin the summary). - AutoPlace anchors (v86): instead of
page/x/y, a field may giveanchor(≤120 chars) — the server locates that exact text in the FINAL document (post-merge, post-markup) and places the field there: the field's bottom edge sits on the anchor text's baseline (6pt into the descender space — the same rule as the console's Auto-place), the field's left edge at the anchor's first character.anchorOffset {dx,dy}then shifts it in top-left points (positivedy= down); anchored fields are placed top-left by construction and are never affected byfieldOrigin. (v98 fixed the server-side anchor placement, which until then stored the raw bottom-left text baseline as a top-lefty— API-anchored fields landed mirrored.)w/hfall back to sensible per-type defaults (signature 180×50, initials 60×32, date_signed 120×24, …);anchorAll: truerepeats the field at EVERY occurrence (initials-on-every-page). Put anchors in white or 1pt type in the source document so they don't print. An anchor not found in the document is a clear400naming it — so a caller can fall back to coordinates. Expansion is capped at 200 fields. - Negative pages (v88): field
pagemay be negative, counting from the END of the final document —-1= last page,-2= second-to-last. Resolved server-side after conversion/merge/omission, so "signature block on the last page" needs no page count. Out-of-range (e.g.-5on a 3-page document) is a clear400. - Field geometry (read this twice). PDF points;
pageis 0-based (negative counts from the end). The stored and returned coordinate system is top-left:yis measured DOWN from the top edge of the page to the TOP of the box (the console, the signer widget and the sealer all read it that way; the sealer draws atpdfY = pageHeight − y − h). v98: say which system you speak withfieldOriginonPOST /api/envelopesandPOST /api/envelopes/{id}/fields—"top-left"(the default, above) or"bottom-left"(PDF-native:ymeasured UP from the bottom edge to the BOTTOM of the box — what pdf.js text positions, PDF content streams and most PDF libraries give you). Withbottom-leftthe server converts every field (and every markup) once at ingest, per page, against the FINAL document's real page heights (after merge, page removal and negative-page resolution), so a mixed Letter/A4 packet converts correctly;GET ?includeLinks=1always returns the layout as stored, top-left, and says so ("fieldOrigin": "top-left"besidefields); theenvelope.createdaudit records the origin the caller used. Abottom-leftfield on a page the document does not have is a clear400naming the field. History: until 2026-09-15 this line said "origin bottom-left" — the engine never did that, and the two integrations built on the wording (the Prudentia client portal, the PRE portal's review screen) rendered every box mirrored about the page's midline. If your geometry is bottom-left, either sendfieldOrigin: "bottom-left"or convert yourself withyTop = pageHeight − yBottomLeft − h. The examples below are illustrative numbers, not a layout. - Page removal (v77): each
documents[]entry acceptsomitPages— 0-based page indices of the ORIGINAL upload to drop before merging (deduped server-side; out-of-range pages and removing every page across the envelope are clear400s). Fieldpageindices refer to the FINAL document (after removals + merge). Provenance is explicit: the summary'sdocuments[]entries carryomittedPages, and the certificate's SOURCE DOCUMENTS section notes "(N page(s) removed at preparation)" — the recorded SHA-256 remains that of the file as uploaded. A single document withomitPagesis valid (usedocuments[]with one entry). - Sender markup (v73):
markups[](≤100 ops) applies prep-time document edits — permanenttext(≤2000 chars,\nfor multi-line,fontSize6–48, default 11),strike(horizontal line through the box's middle),whiteout(opaque cover-up box),check(drawn checkmark);color∈black(default) |red|blue(whiteout is always white). Ops are burned into the PDF at create, before any signer can see it — the legal invariant holds because every signer sees, and the platform seals, the marked-up version. Page indexes the merged PDF when combined withdocuments[](merge happens first). Evidence: theenvelope.createdaudit event'sdocumentSha256is the post-burn hash and carriesmarkupCount, and a dedicatedenvelope.markup_appliedaudit event records the full op list on the tamper-evident chain. This is sender prep tooling (DocuSign-markup parity), never a post-send edit — after create the document is immutable as always. An op referencing a missing page is a clear400. - Attachment-request fields (v72):
type: "attachment"asks the signer to UPLOAD a file (PDF/PNG/JPEG, ≤10MB) during signing — DocuSign's "attachment" tab.required: trueblocks signing until uploaded. The file's pages are appended to the document before sealing (covered by the content hash and Ed25519 seal), a[Attached] filename (sha256 …)marker is stamped at the field position, the certificate lists every attachment (filename, SHA-256, uploader) under SIGNER ATTACHMENTS, and the audit trail recordssigner.attachment_uploaded. - Field types (reference, v110):
signatureandinitials(adopted by the signer);date_signed(auto-filled: the date, in the signer's local zone since v106);day_signed(v110, auto-filled: the day of the month alone — "7", not "7 October 2026" — for execution lines such as "signed this ___ day of October, 2026"; stamped in the signer's local zone, UTC when no zone was reported; default size 40×24; nothing for the signer to type);name,first_name,last_name,email(auto-filled from the signer record);text,company,title(typed by the signer;valuepre-fills);checkbox;radio(meta.group+meta.option);dropdown(meta.options, 1–50 choices; avaluemust be one of them);attachment(below).requireddefaults to true on fillable types. An unknown type is a clear400. - Labels and input rules (
meta, v50; first-class in the console since v110):meta.tooltip(≤300 chars) is the label the signer sees — it is printed inside the empty box as its placeholder and shown as the hover / long-press hint, so a free-text box reads "Day (1–31)" or "City where you sign" instead of being an anonymous blank. Set it on everytextbox; the console flags an unlabeled one as "Text · add a label".meta.validation∈number|date|emailis enforced at signing (the signer gets a plain message; a bad submit is a400) and picks the phone keyboard (number→ numeric).metais ≤4KB per field; unknown keys are stored and echoed, not interpreted. signerIndexrefers to the position in thesignerslist. Insequentialrouting, list order IS the signing order — only the first signer is emailed at send; each next signer is emailed automatically when their turn arrives.assuranceLevel— how the platform makes sure the person at the link is the person you addressed (v101):email— the signing link alone is the credential (industry default). Treat the link like a password: anyone who receives it — a CC, a forward — can open and sign as that signer. Never paste it into your own emails.email_code— the document stays sealed until the signer enters a 6-digit one-time code that Talyana emails to the signer's own address when they open the link (10-minute validity, one resend per minute, 5 wrong attempts lock the link). A forwarded or CC'd link is useless to anyone who cannot read that inbox, and the real signer is alerted the moment someone tries. Nothing for you to share. Recommended for legal instruments (verifications, consents, authorizations).access_code— the signer must enter a code you supply (accessCode, ≥4 chars); share it out-of-band (phone, in person), never in the same channel as the link.- Omit it and the account's default applies — set by an administrator in Admin → Organization → Signing settings → Identity check for new signers (
GET /api/organizations/self→signing.assuranceDefault;POST /api/organizations/self/signing {"assuranceDefault": "email" | "email_code"}). An integration that wants the emailed code on everything needs no code change — flip the account default. An unknown value is a400. - Validation is strict: wrong types, out-of-range values, and over-length strings are clear
400s. Nothing is partially created on failure.
Send it — POST /api/envelopes/{id}/send
No body. 200 → {"envelopeId", "status": "sent", "signingUrls": [{"signerId", "email", "url"}]}.
Emails each signer their unique signing link (first signer only, if sequential). The signingUrls are the same links — you may present them directly in your own UI instead of (or in addition to) the email. Second call → 409.
Read state — GET /api/envelopes/{id}
200 → summary: status, signers[] (each with status: pending → consented → signed, plus orderIndex, notifiedAt, and — v106 — timeZone: the IANA zone the signer's browser reported, e.g. "America/Los_Angeles", null until consent or when the device could not say), sentAt, expiresAt, routing, cc, sealHash, contentHash etc. Append ?includeLinks=1 to also get per-signer signingUrl and the full fields layout.
Everything else
| Endpoint | What |
|---|---|
GET /api/envelopes?q=dana&status=sent |
List/search (title, signer name/email; %/_ are literal). Max 100, newest first. Trashed envelopes are hidden; ?deleted=1 lists ONLY the trash (v74). |
POST /api/envelopes/{id}/delete |
v74. Drafts: hard delete (rows + files gone; the draft's author or the void permission — v105). Terminal envelopes (completed/declined/voided/expired): moved to the org's trash — hidden from lists, restorable, sealed files + audit untouched; signer links and public verify keep working (admin/API key only). Sent envelopes: 409 — void first. |
POST /api/envelopes/{id}/restore |
v74. Bring a trashed envelope back (409 if not trashed or already purged; admin/API key only). |
POST /api/envelopes/{id}/purge |
v75 "Delete forever". Trashed envelopes only (409 otherwise): destroys the document files immediately; the row, hashes, and audit chain remain so /verify keeps working (documentPurged: true). Irreversible; audited as envelope.purged (via: delete_forever). Admin/API key only. |
POST /api/envelopes/empty-trash |
v75. Purges every trashed, not-yet-purged envelope (same semantics as above, via: empty_trash) → {"purged": N}. Purged envelopes leave the ?deleted=1 list. Admin/API key only. |
GET /api/envelopes/{id}/document |
Current PDF: original → work-in-progress → sealed, best available. v107: once sealed, what you get follows the account's Certificate of completion setting (below): the full sealed record (document + certificate pages; SHA-256 = sealHash) or, with certificateDelivery: "separate", the signed document alone (SHA-256 = contentHash). Force one for a single call with ?variant=sealed or ?variant=content (anything else → 400). |
GET /api/envelopes/{id}/certificate |
v107. The certificate-of-completion pages by themselves, cut from the sealed record (not regenerated — byte-for-byte the pages the seal covers). Sealed envelopes only (409 before), 410 after a retention purge. |
POST /api/envelopes/{id}/fields |
v87.2 — DRAFTS ONLY (409 once sent). Body {"fields": […], "fieldOrigin"?: "top-left"|"bottom-left"} REPLACES the entire field layout — same shape and validation as create (anchors are NOT accepted here; pass resolved coordinates; negative page counts from the end; v98: fieldOrigin converts bottom-left geometry exactly as create does). Powers a sender "review & adjust placement" screen: create the draft with auto-placed fields, GET ?includeLinks=1 for the layout, let the sender nudge boxes, POST the adjusted list back, then /send. Audited as envelope.fields_updated. |
GET /api/envelopes/{id}/audit |
Full audit trail + chainValid (hash-chain verification). |
POST /api/envelopes/{id}/remind |
Manual reminder email to pending signers (optional body {"signerId": "sgn_…"}). |
POST /api/envelopes/{id}/void |
Cancel. Body {"reason": "…"} optional. v105: the console user who sent the envelope may void it at any point before completion (even after a partial signature); anyone else needs the void permission (admins have it); the API key always may. Completed envelopes can't be voided (409). Drafts: author or void permission. |
GET /api/envelopes/{id}/verify |
Public (no auth): seal present/intact, Ed25519 signature valid, audit chain valid, sealHash + (v107) contentHash. Printed on every certificate. |
POST /api/verify |
Public: {"envelopeId", "documentBase64"} → does this file match the sealed original? |
POST /api/convert |
Word → PDF (v51). {"filename": "consent.docx", "documentBase64": "…"} → {pdfBase64, pdfSha256, sourceSha256} via the org's Microsoft 365 tenant. Show the PDF to a human before creating an envelope from it — envelopes stay PDF-only by design. 501 when not configured (see SETUP-M365-CONVERSION.md); 502 when M365 rejects the file. |
POST /api/signup |
Public self-serve signup (v53): {"orgName","name","email","password"} → 201 {token, user, organizationName, plan}. Creates the org (plan starter) + first admin + a live session. Honeypot field website must be empty; throttled to 3/hour/IP. |
GET /api/plans |
Public plan catalog (v26): active tiers with name, price, priceCents, envelopeQuota, userLimit, whiteLabel, blurb, buyable — what the console plan cards render. Edited in the platform console; repricing needs no deploy. |
GET /api/ping |
Authenticated connection test → {ok, org, plan, auth} (v87). |
GET /health |
Liveness probe (no auth). |
Automatic reminders: hourly cron nudges unresponsive signers (first after 3 days, then every 3 days, max 3) and expires envelopes after 30 days (defaults; server-configurable).
Retention purge (v74): the cron also enforces each envelope's retentionDays (the policy printed on its certificate). Once that many days pass after completion, the document files are destroyed; the envelope row, hashes, and audit chain remain. After a purge: document endpoints return 410 with a retention message, the summary carries purgedAt, and GET /verify reports documentPurged: true alongside a still-valid sealSignatureValid/auditChainValid — verification can attest what was sealed and when even though the document itself is gone. retentionDays null or 0 = keep indefinitely. Purge applies regardless of trash state and is irreversible by design.
4. Templates
Store a reusable document + field layout (+ title, message, routing, CC):
POST /api/templates— same shape as envelope create, but signers are roles:{"name", "title", "message", "routing", "cc", "documentBase64", "signers": [{"role": "Donor"}], "fields": […]}→201withtpl_…id.GET /api/templates— list withfieldCount,roles,createdBy.GET /api/templates/{id}— full layout, routing, cc.GET /api/templates/{id}/document— the stored document.POST /api/templates/{id}/delete.
To use one: fetch detail + document, then POST /api/envelopes substituting real names/emails for the roles (field layout carries over verbatim; the console's "Use template" does exactly this).
Templates hold a single document — to reuse a multi-document combination, keep the sources and re-create with documents[] (v72; the console enforces the same rule).
4b. Multi-user accounts: seats, visibility, sharing, profiles (v25)
The DocuSign-parity account model. One organization, one API key, many user seats — and each console user's envelopes are private to them by default.
Visibility rules (enforced on lists, direct reads, documents, audit — invisible envelopes return 404, never 403, so existence doesn't leak):
- A user sees envelopes they created, plus every envelope owned by users who shared with them.
- Users with the
viewAllpermission (admins by default) see everything — including unattributed envelopes (API-key creations with no matchingsenderEmail). - The API key sees everything: integrations are the org acting as itself.
Attribution: console creations are attributed automatically. API-key creations are attributed to the user whose email matches senderEmail — this is how a portal sends "as" its signed-in person, and it's why integrations should always pass senderEmail.
Auto-provisioning seats (v87.1): an integration can create seats itself — POST /api/users with {"name", "email", "role": "staff", "invite": true} (API key, no password) runs the same invitation flow console admins use: the person gets an activation email and picks their own password. A duplicate email returns a clean 409 (treat as success). Attribution works the moment the seat row exists — activation is only needed for the person to open their own console. The quick-connect client wraps this as ensureSeat($name, $email).
Shared Access (user-level, DocuSign-style: sharing grants visibility into ALL of the owner's envelopes, past and future — the assistant-manages-the-sender arrangement):
| Endpoint | What |
|---|---|
GET /api/users/me/shares |
{iShareWith: […], sharedWithMe: […]} for the session user. |
POST /api/users/me/shares |
Body {"granteeUserId": "usr_…"} to grant; add "remove": true to revoke. Idempotent. |
GET/POST /api/users/{id}/shares |
Same, on another user's behalf — requires the manageUsers permission. |
GET /api/users/directory |
Active users (id, name, email) — any session user; powers sharing pickers without exposing roles. |
Permission profiles — named capability bundles that override a user's role default. The catalog: send, viewAll, templates, void, manageUsers, manageOrg. Built-ins: admin = everything, staff = send + templates (own envelopes only). void governs OTHER people's envelopes plus trash/purge/restore and "Mark signed on paper" — every user may void the envelopes they sent themselves (v105). POST /api/auth/login (and invite-accept / signup) return the effective perms map + profileName, same shape as GET /api/auth/me.
| Endpoint | What |
|---|---|
GET /api/profiles |
{catalog, builtins, profiles: [{id, name, perms, users}]}. |
POST /api/profiles |
{"name", "perms": {send: true, …}} → 201. Unlisted perms default false. |
POST /api/profiles/{id} |
{name?, perms?} to update; {"delete": true} removes it (assigned users fall back to their role default). |
POST /api/users/{id} |
{"profileId": "prf_…"} assigns; {"profileId": null} clears to role default. Guard: you cannot strip the last active admin of user management (409). |
Profile management requires manageUsers. GET /api/auth/me reports the session user's effective perms, profileName, and shares. All of this is also manageable in the console: Admin → Shared Access / Profiles, users table Profile column, and each user's own Account → My sharing screen.
Account-wide signing defaults (v94/v95, console admins): GET /api/organizations/self includes signing: {allowDelegationDefault, allowDelegationLocked}; POST /api/organizations/self/signing with either or both keys as booleans changes them (anything else → 400; API keys get 403 — admin settings). New console drafts start from the default and API envelopes created without allowDelegation inherit it; envelopes already created keep their own flag. Lock (v95): with allowDelegationLocked: true, the console shows the per-agreement option disabled and never sends it, and POST /api/envelopes refuses an explicit allowDelegation that differs from the default with a 400 naming the locked value — an integration is never left believing it sent one setting while the envelope carries another (omit the key, or match the locked value). Login, signup and /api/auth/me responses carry the same signing block so a console knows both without a second call.
Certificate of completion delivery (v107, console admins): the same signing block carries certificateDelivery: "attached" (default — every signed copy served by /document and the completion-email links carries the certificate pages, as always) or "separate" (signed copies are the signed document alone; the certificate stays with the record and is fetched with GET /api/envelopes/{id}/certificate or GET …/document?variant=sealed). POST /api/organizations/self/signing {"certificateDelivery": "attached" | "separate"} changes it (anything else → 400; API keys → 403). The sealed record, sealHash, the Ed25519 seal and the audit chain are identical in both modes — only delivery differs — and the public verify endpoint publishes contentHash so a content-only copy stays independently verifiable. Choose separate when signed documents are forwarded to third parties and the signer's IP address and device should not travel with them.
Account self-service (console sessions only, v90)
Two endpoints exist for the console's Account → My account screen; they require an X-Auth-Token user session and refuse API keys (400):
| Endpoint | Body | Effect |
|---|---|---|
POST /api/auth/change-password |
{currentPassword, newPassword} |
Verifies the current password (403 if wrong), requires ≥10 characters, keeps the calling session signed in, revokes every OTHER session for that user, and emails a "your password was changed" notice. |
POST /api/auth/profile |
{name?, phone?} |
Updates the user's own display name (2–190 chars) and phone. Email changes remain an administrator action. |
GET /api/auth/me now also returns user.phone.
5. Webhooks
If webhookUrl is set on an envelope, Talyana Sign POSTs JSON to it on terminal transitions:
{"type": "envelope.completed", "envelopeId": "env_…", "status": "completed",
"ts": "…", "sealHash": "…", "contentHash": "…", "metadata": {…your metadata…}}
Events: envelope.completed (includes sealHash and, v107, contentHash), envelope.declined, envelope.voided, envelope.expired. Exactly-once is NOT guaranteed (one retry on failure) — make your handler idempotent, keyed on envelopeId + type.
Verify authenticity: every delivery carries X-TalyanaSign-Signature: sha256=<hex> — HMAC-SHA256 of the raw request body with your org's webhookSecret (whsec_…, issued at org provisioning). Compare with a constant-time comparison. Also X-TalyanaSign-Event: <type>. (The same values are also sent as the legacy X-Signet-Signature / X-Signet-Event headers — integrations that verify those keep working forever.) Requirements: public https host (loopback/private IPs are rejected at envelope create), respond 2xx within 5s.
Webhooks are best-effort — poll GET /api/envelopes/{id} as the source of truth for anything critical.
6. Signer experience (what your users see)
The emailed link opens /sign?token=… — a guided, DocuSign-style flow: ESIGN/UETA consent → guided field navigation (Start/Next) → signature adoption (typed style, drawn, or uploaded) → done. Mobile-friendly (tested on WebKit/iPhone viewports). You normally do NOT need to build any signer UI. Fillable boxes carry the sender's label (meta.tooltip) as their placeholder and hint; auto-filled fields (name, first_name, last_name, email, date_signed, day_signed) are shown already filled and are stamped by the platform at signing — the signer types nothing into them. (Links issued before v54 pointed at /demo/sign.html?token=…; that page now redirects to /sign preserving the token, so old links keep working.)
If you want signing inside your own page instead, the widget is embeddable (/widget/talyanasign.js + a container div + the token — TalyanaSign.mount({...}); the legacy /widget/signet.js URL and Signet global are the same file and work forever), but the token comes from signingUrls — treat it as a bearer credential: anyone with the URL can sign as that person. Don't log it, don't put it in referrable markup.
Signers may reassign the envelope to another person from the ⋯ menu ("Assign to someone else", v62) unless the envelope was created with allowDelegation: false (v93) — then the item is not shown and the API refuses with 403. Per-signer API (the widget drives these; listed for completeness): GET /api/sign/{token}/session (carries allowDelegation; needsAccessCode + a verification {kind, emailMasked, codeSent, locked} block when an identity check is still owed), POST …/send-code (v101, email_code signers only: emails a one-time code to the signer's own address — 429 within 60s of the last one), POST …/verify-access ({accessCode} — the sender-set code or the emailed one), GET …/document, POST …/consent (must send {"accepted": true}; v106: the widget adds "timeZone": "<IANA zone>" from Intl.DateTimeFormat().resolvedOptions().timeZone — optional, validated server-side against PHP's zone database, ignored when unknown), POST …/attachment (v72: {"fieldId": "fld_…", "filename": "license.pdf", "dataBase64": "…"} → {ok, filename, sha256}; PDF/PNG/JPEG ≤10MB; requires prior consent; re-upload replaces; 404 for another signer's field), POST …/signature ({"typedName"} or {"signatureDataUrl": "data:image/png;base64,…"} + {"fieldValues": {"fld_…": "value"}}; optional {"initials": "J.Q.D"} — signer-chosen initials for initial fields, derived from the name when absent; optional {"timeZone": "America/Los_Angeles"} (v106) — the zone the stamp line, the date signed field and the certificate use for this signer's times; an unknown or missing zone keeps the UTC wording), POST …/decline. Consent is required before signing; sequential turn order is enforced server-side; a signer who signed cannot decline or re-consent; voided/declined/expired envelopes stop serving the document (410).
7. Completion artifacts
On completion Talyana Sign appends a certificate of completion (signer identities, consent + signature timestamps — v106: in each signer's own time zone with the exact UTC instant in parentheses and a Time zone row naming the zone the signer's device reported; UTC-only wording for a signer whose device reported none — IPs, geo summaries, document hashes, audit chain tip, verify URL — plus, v72: a SOURCE DOCUMENTS section for multi-document envelopes and a SIGNER ATTACHMENTS section listing every uploaded file with its SHA-256 and uploader) and seals the PDF with an Ed25519 platform signature. Signer-uploaded attachments are appended to the document before hashing, so the seal covers them. GET /api/envelopes/{id}/document then returns the sealed PDF (or, for an account whose certificateDelivery is separate, the signed document without the certificate pages — see §4b and ?variant=); sealHash in the summary is the sealed PDF's SHA-256 and contentHash the signed document's. The stamp line under every signature reads Signed by ⟨name⟩ · Oct 7, 2026, 7:21 PM PDT · Talyana Sign ID ⟨id⟩ in the signer's own zone (v106; … · 2026-10-08T02:21:36.000Z UTC · … when the zone is unknown). Completion emails go to all signers, the sender, and any CC addresses automatically — each lists every signer with their signed-at time and carries the public verification link (v90).
Public verification (no auth). The certificate prints GET /api/envelopes/{id}/verify and a QR code for it (v90). That URL is content-negotiated: a browser (Accept: text/html) gets the human verification page — verdict, four checks, seal record, and a "check your own copy" drop zone that hashes the visitor's PDF locally with SubtleCrypto (nothing is uploaded) — while API clients, curl, and fetch() keep getting JSON:
{"envelopeId":"env_…","sealPresent":true,"documentIntact":true,"sealSignatureValid":true,
"auditChainValid":true,"sealHash":"<sha256 hex>","contentHash":"<sha256 hex>","sealedAt":"2026-…Z","platformPublicKeyPem":"-----BEGIN PUBLIC KEY-----…"}
sealHash is the SHA-256 of the sealed record (signed document + certificate pages); contentHash (v107) is the SHA-256 of the signed document without the certificate pages — the file an account with certificateDelivery: "separate" hands to signers. The verification page recognises both: a dropped copy that hashes to contentHash is reported as the signed document, byte for byte, delivered without the certificate pages; one that hashes to sealHash as the sealed original. Force JSON regardless of Accept with ?format=json. The same page also answers at /verify, /verify?id=env_… and /verify/env_…; with no id it lets a person drop a sealed PDF and reads the Agreement ID out of the certificate text. documentPurged: true (plus purgedAt) appears after a retention purge — the seal and audit chain still verify, the file itself is gone by design.
8. Error handling cheat-sheet
| Status | Meaning | Your move |
|---|---|---|
| 400 | Validation — message says exactly what | Fix the payload |
| 401 | Bad/missing key or expired session | Check header |
| 403 | Role/turn restriction | Expected: not-your-turn, voiding someone else's envelope without the void permission |
| 404 | Wrong id, or other org's resource | Check id |
| 409 | State conflict (already sent, already signed, voided…) | Re-fetch state; usually means a retry raced |
| 410 | Document retracted (voided/declined/expired) | Terminal |
| 413 | Body exceeds server post limit | Shrink the PDF |
| 423 | Access-code lockout (5 failures) | Sender must re-send |
| 500 | Server error (logged server-side) | Retry once; then report |
Timeout-retry idempotency: create is safe to retry (worst case an extra draft — void it); send retry returns 409 with the original links intact; void/decline retries return 409 no-ops.
9. Known limitations (documented, accepted)
- Signer tokens are stored in plaintext in the database (reminder emails need to rebuild the links). DB compromise ⇒ live signing links leak. The DB is shared with the main site — treat any SQLi anywhere on the domain as critical.
- One org's user emails are globally unique across the install — a second org can't reuse an email that exists in the first (single-org deployment today, so moot).
- No rate limiting beyond the access-code lockout. Don't expose the API key to anything you don't trust.
- Signer text much longer than its drawn box can visually overflow on the stamped PDF (stored value is always correct/complete).
- ~~Envelope
metadatais returned in webhooks but not in the envelope summary.~~ Fixed in v87: the summary now echoesmetadata.
10. Quick-start (PHP, server-to-server)
The fast path (v87): every install serves a non-technical quick-connect kit at /connect/ — a step-by-step guide plus TalyanaSignClient.php, a dependency-free single-file connector covering ping, create/send (anchors or coordinates), status, sealed-PDF download, Word conversion, and webhook verification. Point a white-label subscriber's web developer at that page and they can wire their portal in an afternoon. The hand-rolled minimal version, if you'd rather not use the class:
function talyanasign(string $method, string $path, ?array $body = null): array {
$ch = curl_init("https://talyanasign.com$path");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: ' . TALYANASIGN_API_KEY],
] + ($body !== null ? [CURLOPT_POSTFIELDS => json_encode($body)] : []));
$res = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$data = json_decode((string)$res, true) ?? ['error' => 'invalid response'];
if ($status >= 400) throw new RuntimeException("Talyana Sign $status: " . ($data['error'] ?? '?'));
return $data;
}
$env = talyanasign('POST', '/api/envelopes', [ /* §3 payload */ ]);
$sent = talyanasign('POST', "/api/envelopes/{$env['id']}/send");
// Persist $env['id']; track completion via webhook or polling.
Keep TALYANASIGN_API_KEY (and the whsec_… webhook secret) in server-side config outside the web root and outside any synced folder.