Agent skill
Paperform Webhooks Skill
Receive and authenticate Paperform webhooks. Use when setting up a Paperform form-submission webhook endpoint, adding a secret custom header, debugging 401s, or handling New Submission and Partial Submission payloads (data array, submission_id, created_at, ip_address, charge).
Install this skill
npx skills add hookdeck/webhook-skills --skill paperform-webhooks
Paperform POSTs a JSON payload to your URL when a form is submitted. Each webhook fires on either New Submission or Partial Submission, chosen per webhook in the form editor. Webhooks are not signed. To authenticate them, enable the webhook's Custom headers toggle, add a secret header of your choosing, and have your endpoint compare that header with the value you stored.
This skill covers form-submission webhooks, not Papersign.
Security note: a static shared credential authenticates the sender, not the body, and gives no replay protection (no signature, no timestamp). Use HTTPS only, and rotate the credential if it leaks.
When to Use This Skill
- How do I receive Paperform webhooks?
- How do I authenticate Paperform webhooks if they aren't signed?
- How do I add a secret custom header to a Paperform webhook?
- How do I handle New Submission and Partial Submission payloads?
- Why does my Paperform webhook endpoint return 401?
Verification (core)
const crypto = require('crypto');
// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
const ha = crypto.createHash('sha256').update(String(a)).digest();
const hb = crypto.createHash('sha256').update(String(b)).digest();
return crypto.timingSafeEqual(ha, hb);
}
function verifyPaperformRequest(headers, {
headerName = process.env.PAPERFORM_WEBHOOK_HEADER || 'Authorization',
secret = process.env.PAPERFORM_WEBHOOK_SECRET,
} = {}) {
if (!secret) return false; // never accept when unconfigured
const received = headers[headerName.toLowerCase()]; // Node lowercases header names
return Boolean(received) && safeEqual(received, secret); // whole value, no prefix parsing
}
- The header name is yours: whatever you typed under Custom headers. The examples read it from
PAPERFORM_WEBHOOK_HEADER(defaultAuthorization) and look it up case-insensitively. PAPERFORM_WEBHOOK_SECRETis the exact, whole header value, including aBearerprefix if you typed one. Don't strip or parse the prefix; compare the entire value.- Missing header or mismatch:
401. Bad JSON or a body without adataarray:400. Handled:200. - Read the body raw (
express.raw({ type: '*/*' })) and authenticate before parsing it. - Python:
hmac.compare_digestonbytes(encode both sides; passingstrraises on non-ASCII).
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Triggers and Payload
Paperform has no event-type strings and the body has no type field. What differs is the Triggered On setting on each webhook:
| Trigger | Fires When |
|---|---|
| New Submission | A form is submitted |
| Partial Submission | A partial response has gone 30 minutes without an update and hasn't been submitted |
Paperform documents one payload and shows no separate Partial Submission example, so capture one before relying on a partial delivery's shape:
{
"data": [
{ "title": "Your name", "description": null, "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
}
To tell the two apart, create one webhook per trigger with a different URL, for example /webhooks/paperform?trigger=partial (the examples read this optional trigger query parameter; it's a convention of this skill, not something Paperform defines). Only one webhook of each type is sent per submission record. Paperform's request timeout is about 10 seconds: respond quickly.
Environment Variables
PAPERFORM_WEBHOOK_HEADER=Authorization # Name of the custom header you added
PAPERFORM_WEBHOOK_SECRET=your-secret-value # Exact full header value
Local Development
Paperform can't reach localhost. Tunnel with the Hookdeck CLI (no account required):
npx hookdeck-cli listen 3000 paperform --path /webhooks/paperform
Use the printed URL as the webhook URL in Paperform, then add the custom header.
Resources
- references/overview.md - What Paperform webhooks are, triggers and payload shape
- references/setup.md - Add the webhook and the secret custom header
- references/verification.md - Header check details, gotchas, debugging 401s
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests