Embedding a signing page
A customer who has been sent a document to sign should be able to sign it in the app they already have open, rather than in a separate tab. Your instance mirrors every signature request and every party on it, so the signing page is something you mount from an ordinary row read.
There is no Turbobase embed endpoint and no client helper for this. The signing URL is a column on a table you already read, and the widget is the e-signature provider’s own. That is deliberate: a wrapper would be one more thing between you and a page that already works, and one more thing to go stale when the provider changes it.
The three tables
Section titled “The three tables”Connecting DocuSeal mirrors three tables into your instance:
| Table | One row is | Direction |
|---|---|---|
esign_template |
the reusable document a request is built from | read-only |
esign_submission |
one signature request | read + create, never an edit |
esign_submitter |
one party asked to sign — this is the one you embed from | read + edit, never a create |
They are named for the record CLASS rather than for DocuSeal, so an app written against them keeps working if your instance later mirrors a different e-signature provider.
The Direction column is what your app may do, which is not the same as what DocuSeal allows. A submission is create-only because a request that has gone out cannot be changed — see Sending a request from your app for what a write that addresses an existing one answers, and what to do instead. A submitter is the mirror image: a party comes into being as part of the request that asks them to sign, so there is no create — but an existing one can be edited, which is how you correct a request that has already gone out.
Reading the signing URL
Section titled “Reading the signing URL”esign_submitter.signing_url is a typed column, not a key inside raw.
That matters: a raw key is refreshed only by the periodic poll, while a
promoted column is kept current by the webhook leg too. Read it like any other
column.
import { turbobase } from '$lib/turbobase';
const { data, error } = await turbobase .from('esign_submitter') .select('external_id, name, email, role, status, signing_url') .eq('submission_external_id', submissionId) .order('role');Reading these rows needs the read:esign_submitter capability, which the
built-in Standard role carries. A user without it gets an empty array rather
than an error — see Reading the mirror for why that shape is
worth checking for explicitly.
Mounting the widget
Section titled “Mounting the widget”Load DocuSeal’s embed script and point it at the URL:
<script lang="ts"> import { onMount } from 'svelte'; export let signingUrl: string;
onMount(async () => { await import('https://cdn.docuseal.com/js/form.js'); });</script>
<docuseal-form data-src={signingUrl} data-expand="false"></docuseal-form>Nothing about this call goes through your instance. The browser talks to DocuSeal directly; your instance’s only part is having told you the URL.
What breaks, and what it looks like
Section titled “What breaks, and what it looks like”Every failure here is quiet — the page renders and the form does not.
- The request is already signed.
statusiscompletedand the URL loads a read-only receipt rather than a form. Checkstatusbefore mounting and show the completed state yourself; a signed document presented as a form reads as a bug in your app. - The request has expired.
esign_submission.expires_atis in the past. The URL still resolves and the form refuses to submit. Check the parent row. - The request was archived.
esign_submission.statusisarchived. Same shape: the URL resolves and nothing can be done with it. signing_urlis null. The provider did not return one for this party.signing_slugis still set, and the next poll usually repairs the URL — do not build a URL from the slug yourself. A hand-built URL that is wrong renders a blank frame with nothing in the console, which is strictly worse than showing nothing.- The row is stale. A submitter row is refreshed by the webhook when somebody views, starts, completes or declines. If your instance’s DocuSeal webhook has no shared secret configured, those deliveries are refused and the rows only move on the periodic poll. Your instance’s Health tab for DocuSeal says so in as many words.
Sending a request from your app
Section titled “Sending a request from your app”Creating an esign_submission row sends a real signature request, by email,
under your company’s name, to whoever you name. That write is held shut by a
per-instance switch that ships off — ask your operator to enable it before
building against it, and expect the write to be refused with
BIDIRECTIONAL_DISABLED until they do.
Two shapes are worth knowing before you build:
- A submission carries
template_external_idand the parties, and both are required on a create — a request with neither a document nor anybody to sign it is refused on the call, before anything is saved or queued. It cannot be edited once created: a write that addresses an existing one — bypgRowIdor byexternalId— is refused422 APPEND_ONLYbefore anything is written, so your copy never stops matching the document being signed. - To correct a request that has already gone out, change the party. A
submitter can be edited (
name,email,phone,role) throughPOST /v1/data/esignSubmitter, addressed bypgRowIdorexternalId. It cannot be created on its own — a party comes into being as part of the request that asks them to sign — so a create there is refused422 EDIT_ONLY. - When the document itself is wrong, send a new request. That is an ordinary create and is unchanged. Archive the old one in DocuSeal.
The create goes to the write boundary, POST /v1/data/esignSubmission, like
every other record write:
await fetch('/v1/data/esignSubmission', { // same-origin, relative path method: 'POST', credentials: 'same-origin', // the turbobase_session cookie rides automatically headers: { 'content-type': 'application/json' }, body: JSON.stringify({ idempotencyKey: crypto.randomUUID(), parent: { template_external_id: '7001', // required — which document to send template_name: 'Mutual NDA', // optional, for your own lists raw: { submitters: [ // the people who have to sign { email: 'first@customer.example', name: 'First Party', role: 'First Party' }, { email: 'second@customer.example', name: 'Second Party', role: 'Second Party' } ] } } })});The signers go under raw, not at the top level. esign_submission has no
submitters column — the parties are rows on esign_submitter, which DocuSeal
creates for you — so a top-level submitters key is refused with
422 UNKNOWN_COLUMN naming the field. Each party takes email, name,
phone, role and source_external_ref (your own id for them, which comes
back on the mirrored submitter row); anything else is dropped.
An incomplete create is refused on the call, not afterwards. Omit the
template or leave the party list empty and you get 422 with
error.code = 'MISSING_REQUIRED_FIELD' and error.field naming
template_external_id or raw.submitters; no row is written and nothing is
queued, so there is nothing to clean up. The same two paths are published on
required_inputs for this record type — read them rather than hard-coding the
list, and you cannot be refused for something nobody told you about.
You get back 200 { ok, row: { pg_row_id, external_id } } with external_id
null — DocuSeal mints the request’s id, and your instance sends it in the
background. Watch the row you were handed: write_status moves pending →
synced and external_id fills in when the request has gone out, and the
per-party signing_url values appear on esign_submitter shortly after. Any
write_status other than pending or synced means the request has not gone
out, and the three say different things about what happens next:
needs_input— yes. The refusal is released by the record itself, so therecord_write_staterow’smessagealways says what to change. Itsfieldsarray names the column when DocuSeal named one, and is empty when it did not — so render your correct-and-save prompt from themessageand treatfieldsas the extra that focuses it, never as the test for whether to show it. Atemplate_external_idyour DocuSeal account no longer has names the column, and carries the id you sent inrequested; a request DocuSeal turned down over its own content — a party it will not accept, a value its rules forbid — names no column and carries DocuSeal’s own reason in themessageinstead.requestedis optional either way: it is absent when the column was simply left empty.blocked— no. Either the refusal is not released by anything on the record — a credential DocuSeal rejected, a permission the connected account lacks — or DocuSeal could not be reached at all. Themessageis the generic hold andfieldsis empty. Show it and stop; your team is already notified.awaiting_approval— nothing is wrong, and nobody has to do anything on this screen. A signature request leaves the company’s account and cannot be unsent, so an instance can be configured to hold one for an operator to approve before it goes. The request is held, not refused: once it is approved the row carries on tosyncedby itself, and the person who saved it needs no second save. Render it as waiting rather than as a failure, and do not offer a correct-and-save prompt — there is nothing on the row to correct. If nobody answers inside the window the row endsblockedwithreason_codesaying the hold expired, and sending it again is a fresh request.
Sending the request needs the write:esign_submission capability, which is
not in the built-in Standard role: a signature request leaves your
company’s account under your name and cannot be unsent, so an admin grants it
per user. Reading these rows needs only read:esign_submission, which every
standard user has.