Email a PDF for every form submission
Someone fills in a form; you want a finished document in their inbox (a confirmation letter, a registration summary, an invoice, a certificate), not a notification. The form tool fires a trigger, one HTTP step turns the answers into a PDF, and the email step attaches it. This page gives the three routes that need no server (Zapier, Make, Google Apps Script) and one that does (a Tally webhook receiver in Python). Every request body on this page was executed against the live API before publishing; the form-tool and email-tool steps are quoted from the vendors' help pages, fetched on 2026-10-23, and the page says which steps were not observed first-hand.
Pick the route
- Zapier (Tally, Typeform, Jotform, Google Forms, any form with a Zapier trigger): trigger → Webhooks by Zapier → Gmail: Send Email. The PDF travels as a 24-hour download link.
- Make: Watch New Responses → HTTP: Make a request → Email or Gmail. The PDF travels as binary data inside the scenario.
- Google Forms: an Apps Script trigger on the linked sheet; the full script is in the Google Sheets guide (section Form submission → PDF by email).
- Your own endpoint: Tally's webhooks (free on every plan, per Tally's help) POST the submission as JSON to a URL you run; about 30 lines of Python turn it into a PDF and an email.
One honest note before you build: Tally's webhook payload already contains a submissionPdfUrl, a plain PDF of the raw answers. Use this guide when you want a formatted document (your layout, a letter, an invoice or certificate from a hosted template, a report with a table of contents), not a dump of the answers.
Zapier: form trigger → Webhooks → Gmail
Tally's help page describes a Zap as a trigger plus actions, with "a new Tally form submission" as the trigger event, and says the Zapier integration is available to all Tally users. Typeform and Jotform expose equivalent new-submission triggers. After the trigger, add Webhooks by Zapier → Custom Request, method POST, URL https://papertype.dev/v1/pdf, headers Authorization: Bearer dp_your_key and Content-Type: application/json, and this body with the form's fields inserted (create the key at /signup):
{
"markdown": "# Workshop registration\n\nSubmitted {{submitted_at}}\n\n| Field | Value |\n|---|---|\n| Name | {{name}} |\n| Email | {{email}} |\n| Sessions | {{sessions}} |\n| Notes | {{notes}} |",
"title": "Workshop registration",
"theme": "clean",
"page_numbers": true,
"filename": "submission-{{submission_id}}.pdf",
"output": "url"
}Test the step. The live response for the sample submission was:
{
"url": "https://papertype.dev/v1/files/XXXXXXXXXXXXXXXX.pdf",
"filename": "submission-2wgx4n.pdf",
"pages": 1,
"bytes": 12866,
"expires_at": "2026-10-24T03:05:43Z"
}Then add Gmail → Send Email (Zapier's Gmail page lists it as "Create and send a new email message"). Map the respondent's email answer into To, write the subject and body, and map url into the Attachment field. Zapier's "Send files in Zap workflows" article states the two conditions for a link in a file field: it must be publicly accessible (no login) and a direct download link (the file itself, not a page that previews it), and suggests testing the link in a private browser window. The url above meets both: fetching it with no credentials returned Content-Type: application/pdf and exactly the bytes the JSON reported. Free-text answers are the usual snag: a double quote or line break inside an answer breaks the JSON above. Keep long answers out of the table, or switch to the POST event with Payload Type JSON and one Data row per field so Zapier escapes them (details in the Zapier and Make guide).
Make: Watch New Responses → HTTP → Email
Tally's Make page names the trigger: Watch New Responses, "which triggers when someone submits a selected Tally form". Follow it with HTTP → Make a request: URL https://papertype.dev/v1/pdf, method POST, body content type JSON, body input method Data structure with the fields below (Make escapes quotes and newlines in mapped values), Parse response No, and the API key in a keychain header as described in the Zapier and Make guide. Leave output out; the response body is the PDF itself, which Make exposes as the module's binary Data. This is the body the live check sent:
{
"markdown": "# Workshop registration\n\nSubmitted {{1.createdAt}}\n\n| Field | Value |\n|---|---|\n| Name | {{1.Name}} |\n| Email | {{1.Email}} |\n| Sessions | {{1.Sessions}} |\n| Notes | {{1.Notes}} |",
"title": "Workshop registration",
"theme": "clean",
"page_numbers": true,
"filename": "submission-{{1.submissionId}}.pdf"
}Live result: 200, Content-Type: application/pdf, X-Pages: 1, Content-Disposition: attachment; filename="submission-2wgx4n.pdf". Then add Email → Send an email (Make's Email app page: send and receive emails, iterate attachments; it needs an existing mailbox, and Make advises against personal accounts) or Gmail → Send an email, map the respondent's address into To, and in Attachments set the file name to submission-{{1.submissionId}}.pdf and the data to the HTTP module's Data. The attachment mapping is the standard Make file-field pattern; the Make UI steps on this page were not exercised first-hand.
Your own endpoint: a Tally webhook receiver in Python
Tally's webhook help: when someone submits a form, "a notification containing the response data gets sent to your URL in JSON format via a POST request"; you add the endpoint under the form's Integrations tab, optionally with a signing secret and extra headers. The payload has data.formName, data.createdAt, data.submissionId and a data.fields list of {label, type, value} (multiple-choice values arrive as lists). This function turns that into Markdown; it was executed on a payload with Tally's published shape, including a pipe and a line break inside an answer:
def submission_to_markdown(payload: dict) -> str:
data = payload.get("data", {})
rows = []
for f in data.get("fields", []):
v = f.get("value")
if isinstance(v, list):
v = ", ".join(str(x) for x in v)
v = "" if v is None else str(v)
v = v.replace("|", "\\|").replace("\n", "<br>")
rows.append(f"| {f.get('label', f.get('key', ''))} | {v} |")
title = data.get("formName", "Form submission")
when = data.get("createdAt", "")
return (f"# {title}\n\nSubmitted {when}\n\n| Field | Value |\n|---|---|\n"
+ "\n".join(rows))The handler (FastAPI shown; Flask or Express is the same three calls) renders and emails. The render call below is what the live check ran; the SMTP send is standard library code that was not run here, since this server sends no email:
import os, json, smtplib, requests
from email.message import EmailMessage
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/tally")
async def tally(req: Request):
payload = await req.json()
data = payload["data"]
to = next(f["value"] for f in data["fields"] if f["type"] == "INPUT_EMAIL")
r = requests.post("https://papertype.dev/v1/pdf",
headers={"Authorization": "Bearer " + os.environ["PAPERTYPE_API_KEY"]},
json={"markdown": submission_to_markdown(payload), "title": data["formName"],
"theme": "clean", "page_numbers": True,
"filename": f"submission-{data['submissionId']}.pdf"}, timeout=60)
r.raise_for_status() # 400/401/413/422/429 carry a JSON detail
msg = EmailMessage()
msg["To"], msg["From"], msg["Subject"] = to, os.environ["MAIL_FROM"], "Your registration"
msg.set_content("Thanks for registering. Your summary is attached.")
msg.add_attachment(r.content, maintype="application", subtype="pdf",
filename=f"submission-{data['submissionId']}.pdf")
with smtplib.SMTP_SSL(os.environ["SMTP_HOST"], 465) as s:
s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"]); s.send_message(msg)
return {"ok": True}Return quickly and let Tally retry on failure (Tally's page documents request failure and retries); if rendering plus SMTP takes longer than you like, queue the work and return 200 first. Verify Tally's signing secret on every request if you set one.
What to expect, and limits
- Status codes. A body with no
markdown,htmlortemplatereturns400({"detail": "Provide 'html', 'markdown', or 'template' + 'data'."}, observed when the mapped field was empty); wrong key401; input over 2 MB413; a bad option or a missing template field422naming it; quota429. All carry a JSONdetail. In Zapier the step errors and the run stops; in Make set Return error if HTTP request fails to Yes. - Links expire. With
output: "url"the finished PDF (never the answers you sent) is kept at an unguessable link for 24 hours, then deleted; see the privacy policy. The link is public, and the document contains the respondent's answers, so attach it in the same Zap run and do not write the link anywhere else. The Make, Apps Script and own-endpoint routes store nothing at all. - Quota. Free-plan output carries a small
papertype.devfooter mark. Paid plans (from $9/month) do not. The free plan is 50 documents a month, one per submission; the API returns429at the limit, never overage. A form with 200 submissions a month needs the $9 plan. - Images. The renderer has no network access. Images, fonts and stylesheets at external URLs are never fetched; inline them as
data:URIs. A request that references an external URL fails with a 422 that names the URL, rather than silently dropping the asset. A logo goes in as adata:image/png;base64,…URI, or use a hosted template'slogofield. - Templates. For an invoice, certificate or report rather than a table of answers, send
{"template": "invoice", "data": {...}}instead ofmarkdown; the body is in the Zapier and Make guide, and a missing required field returns422naming it.
Questions
Tally already gives me a submission PDF link. Why this?
Tally's `submissionPdfUrl` is a plain record of the answers. This route gives you a document you designed: your title and layout, a cover page or table of contents, a certificate or invoice from a hosted template, or a Markdown letter that uses the answers in sentences. If a raw record is all you need, use Tally's link.
Can the email go to the person who submitted the form?
Yes: map the form's email question into the To field (Zapier, Make) or read the INPUT_EMAIL field (own endpoint). Only send what your form's privacy notice told respondents to expect.
Is the respondent's data stored by PaperType?
The answers you send are rendered and discarded; the API logs the page count, never the content. With `output: "url"` the finished PDF is kept for 24 hours at an unguessable link so Zapier can fetch it; the Make, Apps Script and own-endpoint routes receive the bytes directly and nothing is kept.
Which parts of this page were tested?
Executed on 2026-10-23 against the live API with a temporary key: both request bodies (200; JSON link with pages 1 and 12,866 bytes; PDF bytes with X-Pages 1 and the filename header), an unauthenticated download of the link (application/pdf, byte count matching), the 400 response for an empty body, and the Python `submission_to_markdown` function on a payload with Tally's published shape. Quoted from vendor help pages fetched the same day, not observed: Tally's Zapier and Make triggers and webhook behaviour, Zapier's file-field rules and the Gmail Send Email action, Make's Email app. Not run: the SMTP send. If a step is wrong, tell support@papertype.dev and the page gets fixed.
Google Forms?
Google Forms has no direct webhook, but its linked sheet fires an installable `onFormSubmit` trigger in Apps Script. The complete script, including a Gmail send with the PDF attached, is in the Google Sheets guide linked below.
Related
Free previews carry a watermark and are deleted after 24 hours. Free API output carries a footer mark. The renderer runs no JavaScript and never fetches external URLs. Privacy · Terms · Refunds