Use Case
Generating signed contracts from a Tally webhook on Cloudflare Workers
A customer agrees to a 12-month deal in a sales call. You
send them a Tally form with the terms — name, company,
billing email, plan, start date, the standard MSA clauses
as a click-through. They hit submit. Within the second, a
counter-signed PDF of the contract lands in their inbox,
stamped with their answers and your signature block,
stored with a retention key that satisfies your auditor.
The whole flow runs on a single Cloudflare Worker that
fires when Tally's webhook hits — webhook handler,
PDFops /api/fill-form call, R2 storage,
transactional email — in under 400ms end-to-end.
The setup
Every B2B SaaS I run eventually accumulates a stack of customer-contract obligations: MSAs for new customers, DPAs for the GDPR-touching ones, order forms for each renewal, SOWs for the consulting tier, NDAs for the enterprise procurement gate. Most of these documents are AcroForm-shaped — the customer's company name and billing address, the plan they're on, the renewal date, the signer's name and title — and most of them get generated once and then sit in a contracts folder for the next seven years.
The traditional path is a DocuSign envelope: salesperson drags the customer's name into a template, clicks "send for signature," customer logs in, signs, the envelope gets archived. That works fine; it also costs $30-45/user/month per ops seat and adds two days of back-and-forth latency to every deal close. For a deal-shape where the customer has already accepted the terms in a form — clicked through every clause, typed their billing details — DocuSign is a lot of overhead for a flow that's already legally complete. (Companion: contract PDFs from onboarding flows covers the broader template-fill pattern across MSAs, DPAs, and SOWs.)
The shape of the fix is to treat the form submission itself as the signing event and emit a fully-stamped PDF immediately. Tally fires a webhook on every submission; a Cloudflare Worker picks it up, calls PDFops against your contract template with the customer's answers, stores the result in R2 with a retention-grade key, and emails the customer a download link. Your operations team never touches the deal close-out; the contract archive grows by one row per submitted form.
The flow
- Customer submits Tally form: the click-through is the acceptance; Tally fires its webhook
- Tally → Worker: POST hits the Worker with HMAC signature in the
tally-signatureheader (~50msnetwork) - Worker → signature verification: HMAC-SHA256 of the raw body against the shared secret (
~1ms) - Worker → R2: fetch the contract template (cached at the edge after first fetch;
~10-30ms) - Worker → PDFops:
POST /api/fill-formwith the template + the customer's answers + your counter-signature metadata (~100-200msincluding network) - Worker → R2: stash the filled contract with a retention key built from the customer ID + a UUID (
~30-50ms) - Worker → email service: transactional email to the customer with the download URL (
~10msvia webhook to Resend or similar) - Worker → Tally:
200 OK; Tally records the webhook as delivered
End-to-end: ~250-400ms. Tally's webhook UI shows the green checkmark before the customer has finished closing the tab; the contract is in their inbox by the time they open it.
The function
Here's the whole thing — ~75 lines of TypeScript, no abstractions. The Tally HMAC verification is real (Tally's signing scheme is HMAC-SHA256 over the raw request body, base64-encoded, compared against the tally-signature header). The template fetch goes through R2's binding so there's no egress charge from same-Worker reads.
// src/worker.ts
//
// Cloudflare Worker. Fires when Tally POSTs a form submission.
export interface Env {
CONTRACT_TEMPLATES: R2Bucket; // bound to your templates bucket
CONTRACT_ARCHIVE: R2Bucket; // bound to your archive bucket
TALLY_WEBHOOK_SECRET: string; // env var
PDFOPS_BASE_URL: string; // "https://pdfops.dev"
RESEND_API_KEY: string; // env var
RESEND_FROM: string; // "contracts@yourdomain.com"
}
const TEMPLATE_VERSION = '2026.05.msa-v4';
const COUNTERSIGNER = { name: 'TJ Hayes', title: 'Founder', signed_at: () => new Date().toISOString() };
interface TallyPayload {
data: {
fields: Array<{ key: string; label: string; value: string | string[] | boolean }>;
submissionId: string;
createdAt: string;
};
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (req.method !== 'POST') return new Response('method not allowed', { status: 405 });
// 1. Verify Tally's HMAC signature.
const raw = await req.text();
const sig = req.headers.get('tally-signature') ?? '';
if (!(await verifyTally(raw, sig, env.TALLY_WEBHOOK_SECRET))) {
return new Response('bad signature', { status: 401 });
}
const payload = JSON.parse(raw) as TallyPayload;
// 2. Project Tally's field array into the AcroForm field names your
// template uses. The mapping is template-specific; this is the
// shape for the MSA template — adapt per contract.
const byKey = Object.fromEntries(payload.data.fields.map((f) => [f.key, f.value]));
const customer = {
company_name: String(byKey['company_name'] ?? ''),
signer_name: String(byKey['signer_name'] ?? ''),
signer_title: String(byKey['signer_title'] ?? ''),
billing_email: String(byKey['billing_email'] ?? ''),
plan: String(byKey['plan'] ?? ''),
start_date: String(byKey['start_date'] ?? ''),
agreement_version: TEMPLATE_VERSION,
countersigner_name: COUNTERSIGNER.name,
countersigner_title: COUNTERSIGNER.title,
countersigned_at: COUNTERSIGNER.signed_at(),
};
// 3. Fetch the contract template from R2 (template-version-pinned key).
const tplObj = await env.CONTRACT_TEMPLATES.get(`msa/${TEMPLATE_VERSION}.pdf`);
if (!tplObj) return new Response('template missing', { status: 500 });
const templatePdf = await tplObj.arrayBuffer();
// 4. Call PDFops /api/fill-form with the template + the projection above.
const fd = new FormData();
fd.append('pdf', new Blob([templatePdf], { type: 'application/pdf' }), 'msa-template.pdf');
fd.append('fields', JSON.stringify(customer));
const pdfResp = await fetch(`${env.PDFOPS_BASE_URL}/api/fill-form`, { method: 'POST', body: fd });
if (!pdfResp.ok) {
// 5xx so Tally retries (Tally retries on 5xx with exponential backoff).
return new Response(`PDFops error: ${await pdfResp.text()}`, { status: 500 });
}
const filledPdf = await pdfResp.arrayBuffer();
// 5. Archive the filled contract under a retention-grade key.
// Keep the submissionId in the key path so audit retrieval is
// deterministic; UUID-suffix to avoid object-name collisions.
const archiveKey = `contracts/${payload.data.submissionId}-${crypto.randomUUID()}.pdf`;
await env.CONTRACT_ARCHIVE.put(archiveKey, filledPdf, {
httpMetadata: { contentType: 'application/pdf' },
customMetadata: {
template_version: TEMPLATE_VERSION,
company_name: customer.company_name,
countersigned_at: customer.countersigned_at,
submission_id: payload.data.submissionId,
},
});
// 6. Fire-and-forget email with a signed download URL.
// The presigned URL is what the customer opens; the R2 object stays
// private so revoking access is just rotating the signing key.
const downloadUrl = await signedR2Url(env.CONTRACT_ARCHIVE, archiveKey, { expiresInSeconds: 60 * 60 * 24 * 7 });
ctx.waitUntil(fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: env.RESEND_FROM,
to: customer.billing_email,
subject: `Your countersigned ${customer.plan} agreement`,
html: `<p>Your ${customer.plan} agreement is countersigned and on file. ` +
`Download a copy: <a href="${downloadUrl}">${downloadUrl}</a></p>`,
}),
}));
return new Response('OK');
},
};
async function verifyTally(raw: string, sig: string, secret: string): Promise<boolean> {
if (!sig) return false;
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['verify'],
);
const signature = Uint8Array.from(atob(sig), (c) => c.charCodeAt(0));
return crypto.subtle.verify('HMAC', key, signature, new TextEncoder().encode(raw));
}
That's the production-shape. Deploy with wrangler deploy, point Tally's webhook at https://<your-worker>.workers.dev, set the four env vars, drop your MSA PDF into the templates bucket, and every submitted form produces a countersigned contract in the customer's inbox within a second.
One auth-boundary footgun worth flagging: the
example uses access: 'private' by default on the
R2 archive object and emails a presigned URL with a one-week
expiry. That's the right shape for an initial download link — but
contracts are long-lived artifacts and the link expiring 7 days
later breaks audit retrieval. For long-term access, keep the
object private and front it with your own download route handler
that checks the requesting user's claim (customer can fetch
their own contract; finance/legal can fetch any). The
presigned URL is the convenience for the first download; the
access layer is what survives the audit.
The contract template
Build the template once in Acrobat, Mac Preview, or LibreOffice Draw — paste your MSA prose into the body, drop AcroForm fields where customer-specific data goes, and rename the fields to short predictable names (company_name, signer_name, signer_title, billing_email, plan, start_date) that match the keys your Worker projects from Tally. Put your counter-signature block at the bottom with fields for countersigner_name, countersigner_title, countersigned_at, and agreement_version.
Version the template path in R2: msa/2026.05.msa-v4.pdf. When your terms change — quarterly is typical for a growing SaaS — upload msa/2026.08.msa-v5.pdf and bump TEMPLATE_VERSION in the Worker. The previous version stays in R2 unchanged, which means contracts signed against v4 remain forensically reproducible from the same template you signed them with. The agreement_version field stamps the rendered PDF too, so a customer looking at a contract from eighteen months ago knows exactly which terms they accepted.
Keep the field-naming stable across template versions. If you rename billing_email to contact_email in v5, the Worker's projection map breaks silently for v5 submissions until you update it. A schema-version field on the template itself (template_schema: '2') plus a switch in the Worker is a cheap way to keep multiple versions co-resident during a migration.
What "signed" means in this pattern
The PDF the customer receives says "countersigned by <you> on <timestamp>" on the signature block and stamps the template version. That's a representation of an agreement that's already complete — the customer clicked through the terms in Tally, your Worker signed the resulting PDF, both halves are on file. For most B2B SaaS contracts this is sufficient: the click-through plus the emitted PDF plus the R2 archive entry plus the email delivery receipt plus the Tally submission record are five pieces of evidence pointing at the same agreement.
It's not the same as a cryptographic signing event with a signer certificate or a TSA-backed timestamp. If your contract category requires non-repudiation in the technical sense — the signer literally cannot deny signing — you need a real e-sign workflow with the signer's certificate (DocuSign, Adobe Sign, or a niche provider like SignWell). The pattern in this post sits one layer up: it's the artifact-generation layer that produces a defensible contract PDF on every form submit, which is enough for the long-tail of "every B2B SaaS deal" but not enough for the kind of contract a real-estate transaction or a regulated-industry vendor agreement needs.
The clean separation: Tally captures intent, the Worker emits the artifact, the R2 archive proves storage, the Resend log proves delivery. If you later need to add a real e-sign step, the artifact this Worker produces is exactly what you'd feed into a DocuSign envelope as the document under signature.
The cost math
Customer contracts scale with deals, which scale with growth. At 2,000 contracts per year across a multi-product portfolio — MSAs, DPAs, renewal order forms, SOWs, NDAs — here's how the substrate matters:
- Manual: salesperson templates in Word and emails for signature: ~20 minutes of ops time per contract (template prep, customer back-and-forth, signed-PDF storage). At $40/hr loaded ops cost, 2,000 × 20 min = ~667 hours = ~$27,000/yr in ops drag, plus two-to-five days of latency per deal close that pushes invoice recognition into the next billing cycle.
- DocuSign / Adobe Sign / Dropbox Sign: $30-45/user/month for the ops seat doing the envelope work. A 5-seat ops team runs $1,800-$2,700/yr; a 15-seat team runs $5,400-$8,100/yr. Excellent product if you actually need the e-sign workflow — most B2B SaaS contracts don't, the click-through in Tally already established acceptance.
- DocRaptor / PDFShift / PDFMonkey: $0.05–$0.20/doc × 2,000 = $100-$400/yr. Cheap by SaaS standards, priced from a substrate that didn't anticipate edge-deployed apps; the per-document floor doesn't track the substrate's true unit economics.
- PDFops (post-beta): priced from the substrate's unit economics, lands materially below the incumbents at per-doc pricing. Globally distributed by default — your Worker calls hit the nearest edge. During beta: 100 requests per IP per month, free, no signup.
The point isn't that PDFops produces nicer contracts. It's that the substrate-level pricing makes per-deal PDF generation cheap enough that you stop reserving "auto-emit contract on form submit" for big customers and turn it on for every deal — and the two-day signature-latency tax goes away with it.
When this pattern doesn't fit
- You need a real e-sign workflow with signer certificates. Real-estate deals, regulated-industry vendor agreements, anything where "the signer cannot deny signing" is load-bearing — go to DocuSign / Adobe Sign / SignWell. The Worker pattern produces the artifact; e-sign tools produce the proof of signature. They compose: emit the contract here, feed it into the e-sign envelope as the document under signature.
- Your contracts vary substantially per customer, not just by field values. If every customer renegotiates clauses and the contract is essentially a fresh document each time, AcroForm fill gets fragile. Use an HTML-to-PDF rendering path with a templating engine (Handlebars/MJML/EJS into headless Chrome) instead. See the DocRaptor comparison for that side of the tradeoff.
- You need multi-party signatures (your countersignature plus a customer signature plus a guarantor plus a witness). The fill-form pattern is a single-pass tool; a multi-party flow needs a workflow engine that gates each step on the previous party's signature. Could be approximated with multiple PDFops calls chained behind a Durable Object — but at that point an off-the-shelf e-sign product is doing the same job with a much better failure-mode story.
- You're capturing payment at signature, not just intent. Stripe Checkout's
checkout.session.completedwebhook is the better trigger; see the Stripe invoice walkthrough for the Workers-shaped pattern. You can chain the two: payment-complete fires Stripe webhook, which kicks the contract emitter Worker the same way Tally would.
Try it
The fill-form endpoint is live and works against any AcroForm PDF — not just contracts. Drop in your MSA template (AcroForm fields named to match your projection) and call fill-form directly:
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@msa-template.pdf" \
-F 'fields={"company_name":"Acme Inc","signer_name":"Jamie Lee","signer_title":"CTO","billing_email":"billing@acme.example","plan":"Growth","start_date":"2026-06-01","agreement_version":"2026.05.msa-v4","countersigner_name":"TJ Hayes","countersigner_title":"Founder","countersigned_at":"2026-05-23T14:32:11Z"}' \
-o countersigned-msa.pdf
You'll get the countersigned PDF back. From there it's wiring it into a Worker handler shaped like the function above. The fill-form reference covers the full request schema, error codes, and per-field-type quirks (checkboxes, radios, multi-line text).
Deal-shape contracts that don't fit the Tally pattern, missing AcroForm field types, retention/access requirements that need a different storage primitive — drop a note on the waitlist form. The message field is the fastest way to influence what ships next.