Docs

Docs / Guides / Your own signing page

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: body as text or Markdown (body_format), or a PDF’s pages (Signing a PDF).
  • Showing consent_text before consent and intent_text above 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}.

  1. Read the link. GET …. When ok is false, show reason, a sentence written for the person, and stop. It answers 200 for a token that matches nothing too. The first read puts viewed on the trail.
  2. Where they are. GET …/location. When mode is ask or require and there is no position, show disclosure.text, ask the device, then POST …/location with { "shared": true, "lat", "lon", "accuracy_m" }, or { "shared": false } when they said no or it could not tell. offered is for counter tablets.
  3. The code, when verification_required is email or sms. POST …/code sends six digits, good for ten minutes, to where the link went, and answers channel, sent_to (masked) and resend_after. POST …/verify with { "code" } answers ok; a wrong code is ok: false with reason and attempts_left, not an error, and expired: true means send a new one.
  4. Consent. Show consent_text, then POST …/consent with {}, unless consented_at is set.
  5. The ID photo. The view does not carry it: GET …/id-evidence. When mode is not off and id is null, show id_disclosure.text and POST …/id-evidence with kind: "id", content_type and bytes_base64. Under id_selfie you may offer kind: "selfie" too.
  6. The questions. Draw the fields whose visible is not false, in order: a block with step: true gets a page of its own, and the rest come after the steps. Answer with POST …/fields (key, value, about for a field about one person), a photo with POST …/fields/{key}/photo. An empty value passes an optional question (passed); a required one cannot be passed. Never ask for a field with at_signing or from_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. When document_after_questions is true, ask these before showing the document, except a box placed on a PDF’s page.
  7. The party, when party.may_add is true and party.still_to_add is above nought. Ask the lead about exactly that many people, then POST …/party with { "people": [ … ] }. Each adult is a full_name with an email or a phone, or with neither. A child is minor: true (or has a date_of_birth when party.ages is dob) with a guardian: me, p1 for the first adult in the list, or other with guardian_name and an address. Everybody with an address is sent their own link and comes back with sent: true. Anybody we could not send it to comes back with a url, shown once, for the lead to hand over. An adult with no address is a place kept open: show the lead party_link to share with the party, and each person opens it, chooses their name and gives an address. POST …/party/share makes a new party link, and POST …/party/link with a signer_id makes a new link for one person.
  8. Sign. Show the document whole, intent_text above the signature, then POST … with method (typed, drawn, clicked or uploaded) and value: the name typed, or a drawing as a data URI. 201 with event_id means it is recorded. A box with at_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_on names 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

StepSkipped or refused
CodeConsent 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, requireOnly 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 photoThe signature is refused without the photo of the ID. The selfie is never required.
QuestionsThe signature is refused while a required, visible question is unanswered, photographs included.
PartyThe 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.

AnswerWhenDo
403 forbiddenno such signing link, this signing link expired on …, or a token since replacedStop; the view says why.
422 invalida 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 invalidan earlier signer has not signed yetNot their turn.
422 invalida 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 todayWait; resend_after says when.
404 not_foundthis document does not ask for …Check the field’s key.
413 too_largeA body over a megabyteShrink the photo.
429 rate_limitedA budget below is spentWait Retry-After seconds.
429 busyA photo arrived while others were being readNothing was kept: send it again after Retry-After.
BudgetLimit
Calls on one link: the view, consent, the code and its check, a location, each photo, the signature, the decline20 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 signers300 an hour
Photos read for one linkOne 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 becomes email_otp or sms_otp.
  • location_shared, to six decimal places with the accuracy, or location_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 becomes sha256:<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 }.

MethodCalls
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