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
mintSignTokeninsigning.ts. - Plain JSON, no envelope. Success bodies are the object itself and errors are
errorpluscode. The comment inform-sign.routes.tsgives the reason: the coach web app proxies these answers to the signing page as they are. - Own body limit.
app.tsgives/v1/public/signa 4mbexpress.jsonlimit and thepublicSignBodyErrorshandler. - Own throttle. Requests the web proxy signs skip the global
/v1rate limiter and are counted byform-sign.throttle.tsinstead. - Every response sets
Cache-Control: no-store(orprivate, no-storefor PDFs) andX-Robots-Tag: noindex.
Optional service auth
The router’s first middleware looks atx-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.tsskips the global/v1limiter only for public sign paths that carryx-service-id. A request that claims to be the proxy but is not signed is refused by the router.clientIptrusts the forwardedx-client-ipheader only on a service-authenticated request. A direct caller is always counted and audited underreq.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. Returnsnullunless the template type isPDF_SIGNATURE.submitSigning(studioId, assignmentId, answers, extra): claims thePENDINGassignment and writes theFormResponsein one transaction (completeWithResponse), then fires the submit hooks.
Link status
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_FOUNDalways answers 404 with onlyerrorset to the fixedLINK_NOT_FOUNDmessage. Nocode.- For other
AppErrors,codeisdetails.reasonwhen the error has one, otherwise theErrorCode.detailsis included when present. - A
ZodErroranswers 400 withcode: "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 withcode: "PAYLOAD_TOO_LARGE", any other 4xx as 400 withcode: "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.
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.
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.
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.
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.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.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 fromform-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)