Guide to Paperform Webhooks: Features and Best Practices
Paperform webhooks send form submissions to your own code. Each time someone submits a form, or abandons one partway through, Paperform can POST the answers to your endpoint, so you can create records, start onboarding, issue a quote or hand the data to a system that has no direct Paperform integration.
This guide covers how Paperform webhooks are configured, what the submission payload looks like, how to authenticate requests with a custom header, the rules for partial submissions and failing endpoints, and the practices that keep a Paperform receiver reliable.
What are Paperform webhooks?
A Paperform webhook belongs to one form. You give it a URL and choose what triggers it: a New Submission or a Partial Submission. When that happens, Paperform sends a POST with a JSON body containing every question on the form and its answer, plus the submission's ID, time, IP address and any payment.
Webhooks are available on specific Paperform plans. Paperform does not sign them. You can, however, add custom headers to a webhook, and Paperform names authentication as one of their uses. Put a secret in a custom header and check it on every request.
Paperform webhook features
| Feature | Details |
|---|---|
| Configuration | Form editor > After Submission > Integrations & Webhooks > Webhooks > Add Webhook |
| Scope | Per form. Several webhooks per form |
| Triggers | New Submission or Partial Submission, one per webhook |
| Method and format | POST, JSON body |
| Custom headers | Custom headers toggle, then + Add Header. No answer piping in headers |
| URL answer piping | {{ key }} placeholders in the URL |
| Authentication | None built in. Add a secret custom header |
| Signature and timestamp | None |
| Event type | None in the body or headers |
| Time limit | About 10 seconds per request |
| Failures | Repeated failures disable the webhook and email the team owner and admins |
| Delivery history | Not recorded per submission |
| Testing | Test resends the latest submission (needs at least one) |
| Source IPs | Not published |
The submission payload
Paperform's sample payload, with its illustrative comments removed:
{
"data": [
{
"title": "question 1",
"description": "This is the first question",
"type": "address",
"key": "ba7ri",
"custom_key": "address_1",
"value": "343 Tester Road, Snohomish, Washington, 98290, United States"
},
{
"title": "question 2",
"description": "This is the second question",
"type": "text",
"key": "tgp8",
"custom_key": null,
"value": "Test 123"
}
],
"submission_id": "XXXXXXXXXXXXXXXXXXX",
"created_at": "2017-06-09 09:51:23",
"ip_address": "192.168.10.1",
"charge": null
}
data has one object per question, in every case. An unanswered optional question or a hidden question is still present, with all six properties: title (or ""), description (or null), type, the auto-generated pre-fill key, your custom_key (or null), and value (or null if there is no answer). charge holds payment information when a payment is made, and is null otherwise.
Nothing in the body says whether this is a new or a partial submission. Paperform's documented payload is the same shape either way, so give each trigger its own webhook, and its own URL path or header, if you need to tell them apart.
Inspect Paperform webhooks as they arrive. Point a webhook at a Hookdeck Inspect URL and click Test to see your form's exact payload, with no account or setup required.
Setting up Paperform webhooks
Generate a long random secret first, for example with openssl rand -hex 32.
- In the form editor, go to After Submission > Integrations & Webhooks.
- Open the Webhooks section and click Add Webhook.
- Enter your HTTPS endpoint URL.
- Under Triggered On, choose New Submission or Partial Submission.
- Turn on Custom headers, click + Add Header, and add a header such as
X-Paperform-Secretwith your secret as the value. IncludeBearerif you useAuthorizationwith a bearer token. - Click Create, then click Test to send the latest submission, or submit the form.
To route through Hookdeck, create a Hookdeck source with the Paperform type and use its URL in step 3. The source asks for an API Key Header and an API Key: enter the same name and the whole value. Names match case-insensitively, values exactly. The source accepts POST, which is the method Paperform uses.
Securing Paperform webhooks
Read your header, compare the whole value with your stored secret in constant time, and reject the request if either is missing.
const crypto = require('crypto');
const express = require('express');
const HEADER_NAME = (process.env.PAPERFORM_WEBHOOK_HEADER || 'x-paperform-secret').toLowerCase();
const SECRET = process.env.PAPERFORM_WEBHOOK_SECRET;
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on unequal lengths, so check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/paperform', express.raw({ type: '*/*' }), (req, res) => {
const received = req.get(HEADER_NAME);
if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
return res.sendStatus(401);
}
let submission;
try {
submission = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Index answers by your custom pre-fill keys, falling back to the generated key.
const answers = {};
for (const question of submission.data || []) {
answers[question.custom_key || question.key] = question.value;
}
// Store submission.submission_id and answers, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hmac
import os
HEADER_NAME = os.environ.get("PAPERFORM_WEBHOOK_HEADER", "X-Paperform-Secret")
SECRET = os.environ.get("PAPERFORM_WEBHOOK_SECRET", "")
def verify_paperform_request(headers) -> bool:
if not SECRET:
return False # never accept when unconfigured
received = headers.get(HEADER_NAME) # Starlette and Flask headers are case-insensitive
if not received:
return False
# Compare bytes: compare_digest raises TypeError on a non-ASCII str.
return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))
A static header proves the sender knows the secret, nothing more. It does not cover the body, so it cannot detect a modified submission, and with no timestamp a captured request can be replayed. Use HTTPS, a long random secret, and rotate it in Paperform and your environment together if it might have leaked. Do not put the secret in the URL: answer piping makes URLs easy to log and share.
Make Paperform webhooks production-ready. Hookdeck Event Gateway checks the custom header you configure, answers well inside Paperform's 10-second limit, and keeps every submission so a failing endpoint never gets the webhook switched off.
Paperform webhook limitations and pain points
A failing endpoint gets the webhook disabled
The Problem: After an outage, submissions stop arriving altogether, and they keep not arriving until someone re-enables the webhook.
Why It Happens: If Paperform cannot reach your URL repeatedly, it disables the webhook and emails the team owner and admins. Paperform does not say how many failures that takes, and the submission history does not record whether a delivery succeeded.
Workarounds:
- Respond quickly and keep the endpoint simple, so it rarely fails.
- Make sure the owner and admin email addresses are monitored.
- Backfill missed submissions from the Paperform API after an outage.
How Hookdeck Can Help: Hookdeck accepts each submission as it arrives and stores it, so your own downtime does not count as Paperform failing to reach the URL. Delivery to your service uses retries you control, and you can replay anything that failed.
Ten seconds to respond
The Problem: A handler that calls other services before responding times out, and the request counts as failed.
Why It Happens: Paperform caps webhook requests at about 10 seconds and recommends returning success early when your logic is complex.
Workarounds:
- Verify the header, store the submission, respond, and process afterwards.
How Hookdeck Can Help: Hookdeck responds to Paperform immediately and delivers to your service with a delivery timeout you choose.
No signature, no event type
The Problem: You cannot prove a submission body is genuine, and you cannot tell a partial submission from a complete one by looking at it.
Why It Happens: Paperform sends no signature and no trigger field. The only authentication is the custom header you add.
Workarounds:
- Use a long random header value over HTTPS.
- Use separate webhooks and paths for new and partial submissions.
- For anything that grants access or moves money, re-read the submission from the Paperform API by
submission_idbefore acting.
How Hookdeck Can Help: Hookdeck's Paperform source compares the header you name with the exact value you enter. Hookdeck answers with a 200 either way, but requests that fail the check are recorded as failed verification and never reach your service.
Partial submissions arrive late, and once
The Problem: You expect a partial submission webhook as soon as someone abandons the form, and nothing comes for half an hour, or ever.
Why It Happens: A partial submission webhook fires only after the response has gone 30 minutes without an update and has not been submitted. Only one webhook of each type is sent per submission record, so a person who returns, edits and leaves again does not trigger a second partial webhook.
Workarounds:
- Design abandoned-form follow-ups around a 30-minute delay.
- If you need the latest partial answers later, fetch them from the API.
How Hookdeck Can Help: Hookdeck shows exactly when each partial submission arrived and what it contained, which makes the 30-minute rule easy to confirm while you test.
Best practices
Key answers by custom_key
Set a custom pre-fill key on every question you read in code. Question titles change, generated keys are opaque, and custom_key is the stable name you control. Fall back to key where no custom key is set.
Expect every question, answered or not
Every question appears in data, with value set to null when unanswered. Check for null values rather than missing objects.
Deduplicate on submission_id
Store submission_id with the trigger type and skip repeats, which also protects you if you resend with Test. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Respond well within 10 seconds and do the real work from a queue. See why to process webhooks asynchronously.
Conclusion
Paperform posts each new or partial submission as an unsigned JSON body with every question, its keys and its answer. Add a secret custom header and compare it whole in constant time over HTTPS, use one webhook per trigger, key answers by custom_key, respond within 10 seconds, and keep the endpoint healthy, because repeated failures switch the webhook off.
Hookdeck Event Gateway checks the header, responds to Paperform at once, and keeps every submission until your service has processed it, so an outage costs you latency instead of a disabled webhook and lost submissions.
Get started with Hookdeck for free and handle Paperform webhooks reliably in minutes.