Home › Help › Contracts and e-signatures
Contracts and e-signatures
ShootCal has a built-in way to write a photography contract, send it to a client, and have them sign it online. No DocuSign, no PDFs, no printing.
It works in three steps:
1. You set up one master template once. It is a normal rich-text document (headings, bold, bullet lists) written in a built-in editor. You drop in placeholders like {client}, {date}, {package}, and {price}, and ShootCal fills those in for each contract. If you never edit the template, ShootCal starts you with a sensible general-purpose one.
2. You generate a contract for a specific job. You fill in the client, date, location, and pick one of your saved packages, which auto-fills the package details, total, and retainer/deposit. You can add extra hours (priced from the package's hourly rate) and a private adjustment (a surcharge or discount that quietly changes the total but is never shown to the client). The placeholders get merged into real text.
3. You send a link and the client signs. ShootCal gives you a private web link like api.shootcal.com/contract/<token>. The client opens it, reads the agreement, types their full name, ticks the agree box, and taps Sign. That counts as their electronic signature. The contract is then locked, you get an email letting you know, and a clean copy is saved to your own Google Drive.
Each contract shows a status the whole way through: Draft, Sent, or Signed. One important note that ShootCal shows on every contract: ShootCal is not a law firm and the templates are a convenience, not legal advice. The terms are yours to review and adapt.
The end-to-end flow (who does what, in order)
The contract flow spans the ShootCal backend and the web app UI. The flow:
1. Template (one per user). The backend returns your saved master template body, or a built-in general-purpose template if you have never saved one. Saving upserts it. The body is rich HTML authored in a self-hosted rich-text editor.
2. Create / update a draft. On create, the server mints a unique, unguessable token and stores a new contract with status draft. The web app merges the {placeholders} into real text in the browser before saving the body.
3. Send. Sending flips a draft to sent, stamps the send time, and returns the signing link https://api.shootcal.com/contract/<token>. It only changes status on a first send (re-sending an already-sent or signed contract just re-returns the link). The photographer copies and shares that link themselves; ShootCal does not email the client the link.
4. Client opens the link. The public link (no login) renders a self-contained signing page, built fresh on the server.
5. Client signs. The client submits their name and ticks the agree box. The server validates a non-empty name and the agree flag, then records the audit trail and flips the status to signed.
6. After signing (detached). The server returns the response to the client first, then finishes the slower work in the background: it emails the photographer a "signed" notification and archives a copy to Google Drive.
A signed contract is locked: the server rejects edits with a "locked" error once the status is signed.
Where contracts are stored
Each contract is stored server-side in ShootCal's database, owned by your account. A single contract holds everything about that agreement:
- The signing-link token (unique to that contract) and its type (wedding, family, or custom).
- The client/date/package details: title, client name and email, the linked calendar event and date, and the location.
- The money fields: package, price, retainer/deposit, plus the extras (additional hours and the hourly rate, and the private adjustment).
- The rich terms body, your studio name, and your typed photographer signature.
- The status (
draft,sent, orsigned). - The e-signature audit trail: the signer's name, their IP, the browser they used, and the timestamps for when it was created, sent, and signed.
- Pointers to the Google Drive archive copy, once one has been made.
Money fields are stored as plain text, on purpose: there is no currency math on the server. Per-user isolation is enforced throughout, so every read and write is scoped to your account. Two companion records back the feature: your single master template body, and your reusable package presets (each with a name, price, deposit, hourly rate, and description).
The signing token and the public link
On contract creation the server generates a long, cryptographically random token (a CSPRNG-style value, base64url-encoded to roughly 24 URL-safe characters, about 144 bits of entropy), so it is not practically guessable. It is stored with the contract and must be unique.
The link is https://api.shootcal.com/contract/<token>. It is served directly by the API host.
The token is the only credential for the public page; there is no login. Before any lookup, the server first checks that the token has the expected shape, then finds the matching contract. Both the page route and the sign route are public (no login), so the client can open and sign without an account.
Building the template: the rich editor
The editor is a self-hosted rich-text editor, served from ShootCal's own site rather than a third-party CDN.
The toolbar is intentionally minimal: headings (levels 1/2/3), bold, italic, bullet and numbered lists, and "clear formatting." Note there is no link button in the toolbar, so you cannot author <a> links through it. The server sanitizer is a superset: it also keeps <a> links (with a checked, safe address), which can enter a body via paste or the older markdown conversion. So the toolbar is a subset of what the sanitizer permits, not an exact match.
An empty document is normalized to an empty string. A legacy plain-text or markdown-lite body is upconverted to HTML when you open it, so older templates load into the editor with formatting intact. Nothing is migrated in storage: it is a one-time, on-open conversion. The editor mirrors the server's HTML-vs-legacy detection so what you see matches what gets rendered.
Merge tokens are inserted as plain text at the cursor. The available tokens are surfaced as clickable chips: {client}, {email}, {date}, {location}, {packageName}, {package}, {price}, {deposit}, {additionalHours}, {restrictions}, {photographer}, {today}.
Generating a contract: package, total, retainer, and the internal adjustment
Packages are a reusable library, each with a name, price, deposit, hourly rate, and description. Picking one auto-fills the contract's package details, total, and retainer/deposit fields. The first time you open packages, ShootCal seeds one example ("Example: Portrait Session") and remembers that it did, so deleting it never makes it reappear.
The Total is computed entirely in the browser: total = base + additionalHours * hourlyRate + adjustment.
- Additional hours times the hourly rate adds extra coverage to the total, and the
{additionalHours}token renders as e.g. "2 hours at $150/hour." - The private adjustment is an internal figure (a surcharge like
+100or a discount like-50). It is folded into the Total but is never shown to the client and has no merge token. - Editing the Total by hand backs out the base price, so adding hours later stays consistent.
The {placeholders} are resolved in the browser before the body is saved: {client}, {package}, {price}, {deposit}, {additionalHours}, {restrictions}, {today}, and so on. So the stored body already contains merged real text, not raw tokens. Money is formatted for display only; the server stores money as plain text and does no arithmetic.
How the rich body is sanitized
Because the signing page is public and the body is text you authored, the body is run through a built-in allowlist sanitizer before it ever reaches the page or the Drive archive. It is small and dependency-free, built on PHP's built-in DOM parser.
- Allowed tags:
p, br, strong, em, b, i, h1, h2, h3, ul, ol, li, a- the rich tags the toolbar can produce, plus the inline tags those map to, plusafor links that arrive via paste. - Allowed attributes: only the link address on
<a>, and onlyhttp(s)ormailtoaddresses (control characters and whitespace are stripped to defeat tricks like a disguisedjavascript:, and any other scheme is rejected). Safe external links getrel="noopener nofollow"andtarget="_blank". - Dropped whole (contents and all):
script, style, iframe, object, embed, noscript, template, svg, math, link, meta, title, head, form, input, button, textarea, select, option, audio, video, source. - Unwrapped (wrapper removed, safe children kept): any other unknown-but-not-dangerous tag like
div,span,table,blockquote. Comments are stripped. Every attribute except a validated link address is removed (nostyle=, noclass=, noon*handlers). - Text is preserved and re-encoded safely, so raw
</&in text cannot break out into markup.
If the body contains block-level HTML it goes through the sanitizer; older plain-text or markdown-lite bodies fall through to a legacy renderer. This is a lazy migration: there is no backfill, and both formats keep rendering.
The client e-signature and the audit trail
The signing page is a fully self-contained page, built on the server (its styling and the small bit of script are inline, with no external assets). It shows the studio logo (if set), title, a details box (Client / Date / Location / Package / Total / Retainer-Deposit), the sanitized terms, the disclaimer, the photographer's typed signature, and the sign form: a name field, an "I have read this agreement and agree" checkbox, and a Sign & Agree button. The page explicitly tells the signer that typing their name is their electronic signature.
On submit, the server records:
- The signer's name (the typed name).
- The signer's IP - taken only from Cloudflare's
CF-Connecting-IPheader and then the real connecting address; a client-suppliedX-Forwarded-Foris deliberately NOT trusted (so a signer cannot spoof or later repudiate the recorded IP). - The browser they used (the User-Agent).
- The signing timestamp, taken from the server.
This is built in the style of an ESIGN/UETA audit trail. The signature write only lands if the contract is not already signed, which closes the double-sign race: only the first writer lands, and a loser gets back the winning signer's name and time rather than overwriting the recorded trail.
Security protections on the signing page: it cannot be embedded in a frame (no clickjacking a signature), it is marked noindex so a leaked token URL is not indexed, and it is never cached. The privacy line on the page states the IP is kept until three years after the session date, then deleted.
The Google Drive archive
After a successful sign, ShootCal saves a copy to the photographer's own Google Drive (best-effort, run in the background after the client's request returns). It:
1. Gets a fresh access token for the photographer; if there is none, it bails quietly. 2. Ensures a folder named "ShootCal Contracts" (searches for it, creates it if missing). If the folder cannot be ensured, it skips rather than dropping the doc in your Drive root, leaving the archive link empty so a future re-archive can retry. 3. Builds a clean print-style version (title, studio, the detail rows including Total and Retainer/Deposit, the sanitized terms, the disclaimer, and both signatures with the signed date) and uploads it, asking Drive to convert it into a native Google Doc. 4. On success, it stores the archive's file ID and view link back on the contract.
The Drive copy uses the drive.file scope and is explicitly a convenience archive, not a replacement: the copy stored server-side in ShootCal's database remains the durable, owned copy. Failures are logged and swallowed, so a Drive outage never blocks or unwinds a signature.
Status tracking and the disclaimer
Three statuses drive everything: draft (created, editable), sent (link shared, still editable), signed (locked). The web app renders them as colored badges: Draft (grey), Sent (amber), Signed (green). The same status gates server behavior: the server refuses to save a signed contract, and a sign attempt short-circuits if the contract is already signed.
The disclaimer is non-negotiable surface area: it is returned alongside the templates so the web UI can show it, rendered on every public signing page, and embedded in the Drive archive doc. It states plainly that ShootCal is not a law firm, the templates are a convenience and not legal advice, and the photographer is solely responsible for the terms.
FAQ
Where exactly is a contract stored, and is the Google Drive copy the source of truth?
The source of truth is the contract stored server-side in ShootCal's database (one per contract, owned by your account). The Google Drive copy is a best-effort archive created only after signing, written to a "ShootCal Contracts" folder as a converted Google Doc, with its file ID and view link stored back on the contract. The database copy is the durable, owned archive and Drive is not a replacement: if the Drive write fails, the signature still stands and the archive link is just left empty for a possible retry.
What is the signing-link token and how guessable is it?
It is a cryptographically random token (roughly 24 URL-safe characters, about 144 bits of entropy), stored uniquely with the contract, so it is not practically guessable. The public route also checks the token's shape before any lookup, and the page is marked noindex so a leaked URL will not be indexed.
Can the rich-text body inject script onto the public signing page?
No. Every body is passed through a built-in allowlist sanitizer before it reaches either the public sign page or the Drive archive. Only p, br, strong, em, b, i, h1-h3, ul, ol, li, a survive; script/style/iframe/form/input and similar are removed whole; all attributes except a checked, safe link address on <a> are stripped (so no style=, class=, or on* handlers); javascript: and data: URLs are rejected; and text is re-encoded so raw </& cannot break out. The toolbar is deliberately limited to a subset of that allowlist (it has no link button), and the sanitizer is the backstop for any markup, including links, that arrives via paste.
What is recorded as the e-signature, and how is the IP protected from spoofing?
On sign, the server stores the signer's typed name, their IP, the browser they used (User-Agent), and the signing time, built in the style of an ESIGN/UETA audit trail. The IP is taken only from Cloudflare's CF-Connecting-IP header (set at the edge) and then the real connecting address; a client-supplied X-Forwarded-For is intentionally ignored so a signer cannot spoof or later repudiate the recorded IP. The signing page tells the signer their name, the timestamp, and IP are recorded, and that the IP is kept until three years after the session date.
What stops a contract from being signed twice or edited after signing?
Two guards. The signature write only lands if the contract is not already signed, so under a concurrent double-tap only the first writer lands; the loser gets the winning signer's recorded name and time back instead of overwriting the audit trail. Editing is blocked server-side: the server returns a "locked" error once the status is signed, and the status flows draft -> sent -> signed with the badge reflecting it.
Does the server do any money math on the package, total, retainer, or additional hours?
No. All money fields (price, deposit, the additional-hour rate, the private adjustment, and package prices) are stored as plain text, and there is no currency math on the server. The Total is computed entirely in the browser as base + additionalHours * hourlyRate + adjustment. The private adjustment can be positive (surcharge) or negative (discount) and is folded into the Total but is never shown to the client and has no merge token.
Is the editor loaded from a CDN, and what happens to old plain-text contracts?
The editor is self-hosted, served from ShootCal's own site rather than a CDN. Old bodies authored before the rich editor were plain text or markdown-lite; they are handled by a lazy migration with no backfill. On the server, a body with block HTML is sanitized, otherwise it falls through to a legacy renderer. In the editor, a legacy body is upconverted to HTML when you open it so it edits cleanly, without rewriting stored data.
Does ShootCal email the signing link to the client automatically?
No. Sending marks the contract sent and returns the signing URL to the photographer, who shares it themselves (the web app surfaces it for copying). ShootCal does, however, email the photographer automatically after the client signs, a "signed" notification sent in the background after the response is returned, so it never blocks the client's Sign tap.
Is this a legally binding contract / legal advice?
ShootCal is explicit that it is not. A disclaimer is returned with the templates, shown on every public signing page, and embedded in the Drive archive: ShootCal is not a law firm, the built-in templates are a convenience and not legal advice, and the photographer is solely responsible for reviewing and adapting the terms. The typed-name and checkbox flow with the IP, browser, and timestamp audit trail is built in the ESIGN/UETA style, but enforceability of the terms themselves is the photographer's responsibility.