Skip to content

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.

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.

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.

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.

Every failure here is quiet — the page renders and the form does not.

  • The request is already signed. status is completed and the URL loads a read-only receipt rather than a form. Check status before 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_at is in the past. The URL still resolves and the form refuses to submit. Check the parent row.
  • The request was archived. esign_submission.status is archived. Same shape: the URL resolves and nothing can be done with it.
  • signing_url is null. The provider did not return one for this party. signing_slug is 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.

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_id and 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 — by pgRowId or by externalId — is refused 422 APPEND_ONLY before 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) through POST /v1/data/esignSubmitter, addressed by pgRowId or externalId. 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 refused 422 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 the record_write_state row’s message always says what to change. Its fields array names the column when DocuSeal named one, and is empty when it did not — so render your correct-and-save prompt from the message and treat fields as the extra that focuses it, never as the test for whether to show it. A template_external_id your DocuSeal account no longer has names the column, and carries the id you sent in requested; 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 the message instead. requested is 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. The message is the generic hold and fields is 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 to synced by 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 ends blocked with reason_code saying 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.