A PDF_SIGNATURE form is a PDF the coach uploads and places fields on. The trainee never sees it in the app. They open a link, fill their fields, sign, and get a signed copy with an audit page. This page covers the public lane that serves that link. Source: backend/apps/core-api/src/modules/form-sign, with the shared rules in backend/apps/core-api/src/modules/forms/signing.ts.

How this lane differs from the rest of the API

  • No user auth. The token in the path is the only credential. It is 16 random bytes encoded as base64url, minted by mintSignToken in signing.ts.
  • Plain JSON, no envelope. Success bodies are the object itself and errors are error plus code. The comment in form-sign.routes.ts gives the reason: the coach web app proxies these answers to the signing page as they are.
  • Own body limit. app.ts gives /v1/public/sign a 4mb express.json limit and the publicSignBodyErrors handler.
  • Own throttle. Requests the web proxy signs skip the global /v1 rate limiter and are counted by form-sign.throttle.ts instead.
  • Every response sets Cache-Control: no-store (or private, no-store for PDFs) and X-Robots-Tag: noindex.

Optional service auth

The router’s first middleware looks at x-service-id. When the header is present the request must pass HMAC service auth (requireServiceAuth from @perform/security). When it is absent the request continues as a direct caller. This decides two things:
  • app.ts skips the global /v1 limiter only for public sign paths that carry x-service-id. A request that claims to be the proxy but is not signed is refused by the router.
  • clientIp trusts the forwarded x-client-ip header only on a service-authenticated request. A direct caller is always counted and audited under req.ip.

Throttle

createRedisSignThrottle counts per action in 600 second windows, both per link and per visitor IP. A request over either limit gets RATE_LIMITED. Redis keys look like perform:form-sign-throttle:<action>:ip:<ip> and perform:form-sign-throttle:<action>:token:<digest>. The token is stored as a truncated SHA-256 digest so key listings never show a live link. A malformed token counts against the IP only. If Redis is unavailable the throttle logs a warning and lets the request through.

How forms/signing.ts feeds this lane

The forms service creates everything the link needs when a coach assigns a PDF_SIGNATURE template (assignForm, documented on the Forms page): The link signs the schema and coach values as they were when the form was sent. Editing the template later does not change an open link. This lane calls back into the forms service through two methods:
  • findSigningAssignment(token): loads the assignment with its template, client, studio and latest response. Returns null unless the template type is PDF_SIGNATURE.
  • submitSigning(studioId, assignmentId, answers, extra): claims the PENDING assignment and writes the FormResponse in one transaction (completeWithResponse), then fires the submit hooks.
statusOf in form-sign.service.ts derives one of three states: A token that does not match ^[A-Za-z0-9_-]{16,64}$, an unknown token, a deleted studio and a deleted client all produce the same 404, so a guessed token cannot be told apart from a withdrawn link. Cancelling an assignment from the coach API deletes the row, so that link also becomes a 404.

Error shape

signErrors at the end of the router converts everything to:
  • NOT_FOUND always answers 404 with only error set to the fixed LINK_NOT_FOUND message. No code.
  • For other AppErrors, code is details.reason when the error has one, otherwise the ErrorCode. details is included when present.
  • A ZodError answers 400 with code: "VALIDATION". This differs from the main API, which answers 422.
  • Anything else answers 500 with code: "INTERNAL".
  • Body parser failures are handled earlier by publicSignBodyErrors: 413 with code: "PAYLOAD_TOO_LARGE", any other 4xx as 400 with code: "VALIDATION".

Endpoints

GET /v1/public/sign/:token

Returns everything the signing page needs to render. Auth: none. The token is the credential. Throttle action view.
string
required
The signing token from the link.
Response: a SignView object, not wrapped in an envelope.
string
pending, signed or canceled.
string
settings.displayName of the frozen schema, or the template name.
object
name, logoUrl, primaryColor, onPrimaryColor. Taken from Studio.branding through a strict allow-list. Colors are returned only when they are valid hex values, otherwise null.
object
firstName: Client.firstName, or the first word of Client.name.
object
pageCount and pages (each with width and height in PDF points). The PDF itself comes from the document endpoint.
object[]
Only fields that have a placement. Each has key, type (text or signature), label, required, filledBy (coach or trainee) and placement. Coach fields with a stored value also carry value.
object | null
richText from settings.thankYou when it has any text, else the legacy settings.successMessage as a single span, else null.
string | null
The stored FormResponse.documentUrl when the status is signed, otherwise null.
placement is page (0-based PDF page) plus x, y, w, h as fractions of the page as pdf.js displays it, measured from the top-left corner.
Errors:

GET /v1/public/sign/:token/document

Streams the source PDF for pdf.js. The page cannot read the storage CDN cross-origin, so it loads the file through the API. Auth: none. Throttle action document.
string
required
The signing token.
The document URL in the frozen schema must resolve through documentKeyOf to forms/<studioId>/<name> in the media bucket, with no subfolders. The object is read up to 15 MB (MAX_SOURCE_PDF_BYTES) and must start with %PDF. Response: raw PDF bytes with Content-Type: application/pdf, Cache-Control: private, no-store, X-Robots-Tag: noindex. A signed link still returns the source document here. Errors:

GET /v1/public/sign/:token/signed

Streams the signed copy once the link is signed. Auth: none. Throttle action document.
string
required
The signing token.
The latest response’s documentUrl must resolve to forms/<studioId>/signed/<24 hex chars>.pdf. The object is read up to 30 MB and must be a PDF. Response: raw PDF bytes with the same headers as the source document. Errors:

POST /v1/public/sign/:token

Submits the trainee’s values and signatures, generates the signed PDF and completes the assignment. Auth: none. Throttle action submit.
string
required
The signing token.
object
Map of field key to text. Defaults to an empty object. Keys up to 100 characters, values up to 5000 characters, at most 200 keys. Only trainee text field keys are accepted.
object
Map of signature field key to a data:image/png;base64,... URL. Defaults to an empty object. Keys up to 100 characters, values up to 600,000 characters, at most 200 keys.
What sign does, in order:
1

Resolve and gate

Applies the throttle, loads the assignment and refuses a signed or canceled link.
2

Check keys

Every key in values must be a trainee text field and every key in signatures must be a trainee signature field of the frozen schema.
3

Validate each signature image

decodeSignaturePng (signature-image.ts) requires a PNG data URL of at most 400 KB, at most 4000 pixels per side and 4,000,000 pixels in total, with a valid colour type and bit depth, no interlacing and none of the chunk types CgBI, zTXt, iTXt, acTL, fcTL, fdAT or a second IHDR. The image data is inflated with a hard cap equal to the expected scanline size, so a small file cannot expand into a huge bitmap.
4

Validate answers

prepareSigningAnswers merges the trainee values with the stored coach prefill, strips answers hidden by conditions and runs validateAnswers. On a document form each text value is limited to 500 characters (SIGNING_TEXT_MAX_LENGTH). Nothing is generated or stored until this passes.
5

Generate the signed PDF

generateSignedPdfInWorker runs generateSignedPdf (signed-pdf.ts) on a worker thread. It flattens the source form, draws each text value and signature at its placement and appends one A4 audit page. The audit page lists the document name, signer name, signer phone, signing time in Asia/Jerusalem, IP address, browser user agent (clipped to 200 characters), the SHA-256 of the source document and the assignment id.
6

Upload

Signature PNGs go to forms/<studioId>/signatures/<random>.png and the signed PDF to forms/<studioId>/signed/<random>.pdf in the R2 media bucket.
7

Complete the assignment

submitSigning claims the PENDING assignment and writes the FormResponse in one transaction. answers holds the text values plus the stored signature URLs, schemaSnapshot is the frozen schema, documentUrl is the signed PDF and signingAudit holds signedAt, ip, userAgent (clipped to 512 characters), phone, documentSha256 and signedDocumentSha256.
8

Fire the submit hooks

The same formSubmittedHandler as an app submission runs: FORM_FILLED webhooks, FORM_FILLED flows or the legacy task, and the rating-below flows or task. The response is written first, so the webhook payload can already reference the signed document. See Automation hooks and Task automations.
If anything fails before the commit, or the forms service refuses with an AppError, the uploaded objects are deleted on a best-effort basis. Unlike an app submission, a signing does not check Client.planFrozenOn, does not call the subscription starter and does not copy answers to the client profile. Response:
thankYou is null when the form has no thank-you text. Errors:

PDF work limits

pdf-lib holds a parsed document fully in memory, and the cost follows the object count, not the file size. pdf-work.ts therefore runs every parse and every generation on its own worker thread (pdf-worker.ts), at most two at a time (MAX_CONCURRENT), and terminates a worker that passes its limits. The save-time check (createDocumentCheck in source-document.ts, wired into the coach forms router) opens the PDF the same way signing does and signs a throwaway copy with a sample value and signature on every page (rehearseSigning). A document that would fail at signing is refused when the coach saves the form, before any link goes out. The reasons it returns are listed on the Forms page.

Storage

signingStorageFromEnv (storage.ts) builds the R2 client from R2_ENDPOINT, R2_REGION, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_BASE_URL and R2_PATH_PREFIX. Stored URLs are mapped back to keys by comparing them with R2_PUBLIC_BASE_URL and R2_PATH_PREFIX, and only keys under the calling studio’s forms/<studioId>/ scope are ever read.

Coach-side document routes

Two handlers exported from form-sign.routes.ts are mounted on the coach forms router, not on the public lane:
  • GET /v1/web/forms/templates/:id/document (coachDocumentHandler)
  • GET /v1/web/forms/responses/:id/document (coachSignedDocumentHandler)
Both are documented on the Forms page.