Your own signing page
Every screen in your own app, on the routes a signing token opens. They call the same engine SignSealer’s own page does, so a page that skips a step is refused, not believed.
Build it, frame it, or send them to ours
Send the signer to SignSealer’s page (the url the send
answered) when you can. Frame it when the screen is on a site the business has
proven (Signing inside your product). Build your
own when neither fits: the signing routes take no key and answer a browser on
any site. What you take on:
- Drawing the document as the engine holds it:
bodyas text or Markdown (body_format), or a PDF’spages(Signing a PDF). - Showing
consent_textbefore consent andintent_textabove the signature, word for word. The engine records its own wording whatever your page showed. - Every step the business switched on, below.
- The party, when everybody in a booking’s party signs: the person who booked says who else is in it before they can sign (below).
One line of the certificate is written only by SignSealer’s page: How the document was shown. A signature taken through the API has none, because we did not draw the page.
The token
A signing token is one signer’s credential for one document, and the only
one these routes take; an API key sent with it is ignored. The send (or a
workflow start) answers it once, as token; only its hash is kept.
POST /v1/documents/{id}/signers/{signer}/embed mints a fresh link,
with its expires_at, and the one before it stops working: the token
is the part of its url after /s/, URL-encoded.
Whoever holds it can read the document, the signer’s name and email and the names of the others on it, and answer, consent, sign and decline as that signer. So:
- Never store or log it. Mint it on your server, with your key, when the person opens the screen, and hand only the token to their browser.
- Never sign with it for somebody. Each step records the address and browser of the request that made it. Called from your server, the trail records your server as the signer.
- Never give it to anyone but the signer. The certificate can say only that the link was used.
The order
The order SignSealer’s page takes. Each step’s state is on the view
or on the step’s own GET, so a page reloaded half-way carries
on. … is https://api.signsealer.com/v1/sign/{token}.
- Read the link.
GET …. Whenokis false, showreason, a sentence written for the person, and stop. It answers200for a token that matches nothing too. The first read putsviewedon the trail. - Where they are.
GET …/location. Whenmodeisaskorrequireand there is noposition, showdisclosure.text, ask the device, thenPOST …/locationwith{ "shared": true, "lat", "lon", "accuracy_m" }, or{ "shared": false }when they said no or it could not tell.offeredis for counter tablets. - The code, when
verification_requiredisemailorsms.POST …/codesends six digits, good for ten minutes, to where the link went, and answerschannel,sent_to(masked) andresend_after.POST …/verifywith{ "code" }answersok; a wrong code isok: falsewithreasonandattempts_left, not an error, andexpired: truemeans send a new one. - Consent. Show
consent_text, thenPOST …/consentwith{}, unlessconsented_atis set. - The ID photo. The view does not carry it:
GET …/id-evidence. Whenmodeis notoffandidis null, showid_disclosure.textandPOST …/id-evidencewithkind: "id",content_typeandbytes_base64. Underid_selfieyou may offerkind: "selfie"too. - The questions. Draw the
fieldswhosevisibleis not false, in order: ablockwithstep: truegets a page of its own, and the rest come after the steps. Answer withPOST …/fields(key,value,aboutfor a field about one person), aphotowithPOST …/fields/{key}/photo. An empty value passes an optional question (passed); a required one cannot be passed. Never ask for a field withat_signingorfrom_sender. Read the view after each page: an answer can show a question (show_if) or hide one, and a hidden one loses its answer. Whendocument_after_questionsis true, ask these before showing the document, except a box placed on a PDF’s page. - The party, when
party.may_addis true andparty.still_to_addis above nought. Ask the lead about exactly that many people, thenPOST …/partywith{ "people": [ … ] }. Each adult is afull_namewith anemailor aphone, or with neither. A child isminor: true(or has adate_of_birthwhenparty.agesisdob) with aguardian:me,p1for the first adult in the list, orotherwithguardian_nameand an address. Everybody with an address is sent their own link and comes back withsent: true. Anybody we could not send it to comes back with aurl, shown once, for the lead to hand over. An adult with no address is a place kept open: show the leadparty_linkto share with the party, and each person opens it, chooses their name and gives an address.POST …/party/sharemakes a new party link, andPOST …/party/linkwith asigner_idmakes a new link for one person. - Sign. Show the document whole,
intent_textabove the signature, thenPOST …withmethod(typed,drawn,clickedoruploaded) andvalue: the name typed, or a drawing as a data URI.201withevent_idmeans it is recorded. A box withat_signing: "signature"takes the words typed, or the signer’s name for any other method; one with"date", the day. Read the view again:waiting_onnames anybody still to sign.
Before the signature, POST …/decline with a reason
(up to 500 characters) closes the document for everybody on it, not only this
signer.
What the engine will not let through
| Step | Skipped or refused |
|---|---|
| Code | Consent is refused until a code is confirmed, so there is no signature either. |
Location, ask | { "shared": false } is recorded as a decline and they sign as usual. Send it: a decline is a fact on the record, and silence is not. |
Location, require | Only a shared position lets the signature through. A decline is recorded and the signature is still refused, so take them back to the step. |
| ID photo | The signature is refused without the photo of the ID. The selfie is never required. |
| Questions | The signature is refused while a required, visible question is unanswered, photographs included. |
| Party | The lead’s signature is refused until they have said who else is in it. |
The code, the location and the ID photo are asked of a
signer we delivered a link to by email or text. For a signer sent with
delivery: "embedded", verification_required is null and
both modes are off, until a reminder sends them a
link.
Errors and limits
Refusals have the usual shape (Errors); the message is usually the engine’s own sentence, worth showing the person.
| Answer | When | Do |
|---|---|---|
403 forbidden | no such signing link, this signing link expired on …, or a token since replaced | Stop; the view says why. |
422 invalid | a code sent to you must be confirmed before you consent, a code sent to you must be confirmed before you decline, consent to electronic records must be recorded before signing, sharing your location is required before you sign, a photo of your ID is required before you sign, still needed before signing: … | Back to that step. |
422 invalid | an earlier signer has not signed yet | Not their turn. |
422 invalid | a code was sent less than a minute ago, too many codes in the last hour, too many wrong codes today, too many codes have gone to that address in the last hour, too many codes have gone to that address today | Wait; resend_after says when. |
404 not_found | this document does not ask for … | Check the field’s key. |
413 too_large | A body over a megabyte | Shrink the photo. |
429 rate_limited | A budget below is spent | Wait Retry-After seconds. |
429 busy | A photo arrived while others were being read | Nothing was kept: send it again after Retry-After. |
| Budget | Limit |
|---|---|
| Calls on one link: the view, consent, the code and its check, a location, each photo, the signature, the decline | 20 a minute |
Answers on one link (POST …/fields) | 600 an hour |
Codes for one signer (refused with 422) | One a minute, five an hour, none after fifteen wrong guesses in a day |
Codes to one email address or number, across every signer and business (refused with 422) | Six an hour, twenty a day, and none after fifteen wrong guesses in a day at one form’s documents |
| Codes by text, across a business’s signers | 300 an hour |
| Photos read for one link | One at a time |
The twenty is per link, not per caller, so a page that reads the view on a timer
spends the signer’s budget; hear about other signers on your server from
webhooks. The location and ID-evidence
GETs and the page pictures (/s/{token}/page?n=1) do not
count. A photo is a JPEG or PNG of at most 52 megapixels, base64 in a body of at
most a megabyte. Limits and paging has the rest.
What is recorded
Each step goes on the trail with the address and browser of the request (Evidence and certificates):
viewed, on the first read.verified, with the channel and masked address; the assurance becomesemail_otporsms_otp.location_shared, to six decimal places with the accuracy, orlocation_declined.consented, with the consent wording, its reference and its SHA-256.id_captured, with the photo’s SHA-256; the image is kept encrypted for thirty days.photo_supplied; the field’s value becomessha256:<hex>and the image is kept as long as the document.signed, with the method, the value, the SHA-256 of the text signed, the statement above the signature and the assurance.declined, with the reason.
A signer holding a link from …/embed, or sent with
delivery: "embedded" and never sent a link, signs at assurance
link, possession of the link, unless they confirm a code.
With the Node client
SignSealerSigning in @signsealer/sdk takes
no key, runs in the browser and never retries: a second signature is not a retry.
An image is { contentType, bytes }.
| Method | Calls |
|---|---|
link(token) | GET … |
pageUrl(token, n) | Nothing: answers the address of page n |
location(token) · recordLocation(token, position) | GET · POST …/location |
requestCode(token) · confirmCode(token, code) | POST …/code · POST …/verify |
consent(token) | POST …/consent |
idEvidence(token) · captureIdEvidence(token, kind, image) | GET · POST …/id-evidence |
supply(token, key, value, about?) | POST …/fields |
supplyPhoto(token, key, image, { about }) | POST …/fields/{key}/photo |
sign(token, method, value) · decline(token, reason) | POST … · POST …/decline |
verify(code, sha256?) | GET /v1/verify/{code} |
The whole flow, with your own screens behind the functions it does not import:
import { SignSealerSigning, SignSealerError } from "@signsealer/sdk";
const signing = new SignSealerSigning(); // no key: the token is the credential
try {
let link = await signing.link(token);
if (!link.ok) return show(link.reason);
const where = await signing.location(token);
if (where.mode !== "off" && (!where.position || (where.mode === "require" && !where.position.shared))) {
await show(where.disclosure.text);
const fix = await askTheDevice(); // null when they said no
await signing.recordLocation(token, fix
? { shared: true, lat: fix.latitude, lon: fix.longitude, accuracyM: fix.accuracy }
: { shared: false });
}
if (link.verification_required) {
const { sent_to } = await signing.requestCode(token);
let checked = await signing.confirmCode(token, await ask("Code sent to " + sent_to));
while (!checked.ok && !checked.expired) {
checked = await signing.confirmCode(token, await ask(checked.reason));
}
}
if (!link.consented_at) {
await show(link.consent_text);
await signing.consent(token);
}
const id = await signing.idEvidence(token);
if (id.mode !== "off" && !id.id) {
await show(id.id_disclosure.text);
await signing.captureIdEvidence(token, "id", await takePhoto());
}
const owed = (l) => l.fields.filter((f) =>
f.visible !== false && !f.passed && !f.at_signing && !f.from_sender);
for (let open = owed(link); open.length; open = owed(link = await signing.link(token))) {
for (const f of open) {
if (f.kind === "photo") await signing.supplyPhoto(token, f.key, await takePhoto(), { about: f.about ?? undefined });
else await signing.supply(token, f.key, await ask(f.label), f.about ?? undefined);
}
}
if (link.party?.may_add && link.party.still_to_add > 0) {
const added = await signing.addParty(token, await askWhoElse(link.party.still_to_add));
await showToHandOver(added.people.filter((p) => p.url), added.party_link);
}
await show(link.body, link.intent_text);
await signing.sign(token, "typed", await ask("Type your name to sign"));
} catch (e) {
if (!(e instanceof SignSealerError)) throw e;
show(e.status === 429 ? "Try again in " + e.retryAfter + " seconds." : e.message);
}
Ready to build? An API key takes a minute in the portal, and the free plan covers the first 10 agreements a month.
Get an API key