Webhooks
Receive every MakingFlow form submission as a signed JSON POST. Payload, signature verification, retries and delivery guarantees.
Every time someone completes one of your forms, MakingFlow POSTs the response to a URL you choose, as JSON. This page is everything you need to build the endpoint that receives it.
Webhooks are configured per form, in MakingFlow under the form → Integrations → Webhooks. A form can have several endpoints, and each one gets its own copy of every response.
The short version
- We POST JSON. Reply 2xx within 10 seconds.
- If you set a secret, verify the signature over
timestamp.body— not the body alone. - Delivery is at least once. Deduplicate on
X-MakingFlow-Delivery-Id. - Failures retry for about 7 hours, then stop.
What we send
One POST per completed response, per endpoint. Only completed responses — a partially filled form that is never submitted does not fire.
{
"event": "submission.created",
"form": {
"id": "b3f1c2a4-5d6e-4f70-8a91-2c3d4e5f6a7b",
"title": "Job Application",
"publicId": "k7hqz2"
},
"submission": {
"id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"submittedAt": "2026-09-07T14:22:31.004Z"
},
"answers": [
{ "fieldId": "0f1e2d3c-...", "question": "Full name", "value": "Ada Lovelace" },
{ "fieldId": "4b5a6978-...", "question": "Years of experience", "value": 12 },
{ "fieldId": "8c9d0e1f-...", "question": "Languages", "value": ["Go", "Rust"] },
{ "fieldId": "2a3b4c5d-...", "question": "Available?", "value": true }
]
}value takes the shape of the field that produced it: a string, a number, a boolean, an array of
strings for multi-select, or an object for structured fields like file uploads. Write your handler
to tolerate all of them.
question is the field's label at the time of submission, included so a delivery reads on its own.
It changes if the form is edited, so key your logic on fieldId, which does not.
Headers
| Header | Example | Notes |
|---|---|---|
| X-MakingFlow-Signature-V2 | t=1757246400,v1=5257a8... | HMAC-SHA256 proving the delivery came from us. Only sent when the endpoint has a signing secret. |
| X-MakingFlow-Delivery-Id | 550e8400-e29b-41d4-a716-446655440000 | Stable across every retry of this delivery. Deduplicate on it. |
| X-MakingFlow-Event | submission.created | What happened. |
| Content-Type | application/json | |
| User-Agent | MakingFlow-Webhook/1.0 |
Verifying the signature
Optional but strongly recommended. Without it, anyone who learns your endpoint URL can post fake responses to it. Add a secret when you create the webhook, and we sign every delivery with it.
Two more things that will cost you an afternoon if missed:
- Use the raw request body. Parsing the JSON and re-serialising it reorders keys and changes whitespace, so the HMAC will not match even though the data is identical.
- Check the timestamp. Reject anything more than 5 minutes old. Skip this and a captured delivery can be replayed at you forever, which is the entire reason the timestamp is in there.
import crypto from "node:crypto"
// Express: capture the RAW body. Re-serialising the parsed object reorders
// keys and the signature will not match.
app.post("/webhooks/makingflow",
express.raw({ type: "application/json" }),
(req, res) => {
const raw = req.body.toString("utf8")
const header = req.get("X-MakingFlow-Signature-V2") ?? ""
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2)),
)
const timestamp = Number(parts.t)
const signature = parts.v1
// Reject replays. Without this check the timestamp is decoration.
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send("stale")
}
const expected = crypto
.createHmac("sha256", process.env.MAKINGFLOW_WEBHOOK_SECRET)
.update(`${timestamp}.${raw}`)
.digest("hex")
// timingSafeEqual throws on a length mismatch, so check length first.
const a = Buffer.from(signature ?? "", "utf8")
const b = Buffer.from(expected, "utf8")
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature")
}
// Answer for the delivery FIRST, then do the slow part. We give up after
// 10 seconds and retry, which for you means processing it twice.
res.sendStatus(200)
const deliveryId = req.get("X-MakingFlow-Delivery-Id")
if (alreadyHandled(deliveryId)) return // retries are normal
handle(JSON.parse(raw))
})import hmac, hashlib, time
from flask import request, abort
@app.post("/webhooks/makingflow")
def makingflow():
raw = request.get_data() # bytes, not request.json
header = request.headers.get("X-MakingFlow-Signature-V2", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or abs(time.time() - int(timestamp)) > 300:
abort(400) # replay window
expected = hmac.new(
SECRET.encode(),
f"{timestamp}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature or ""):
abort(401)
if already_handled(request.headers["X-MakingFlow-Delivery-Id"]):
return "", 200 # retries are normal
handle(request.get_json())
return "", 200Retries, and why you may see a delivery twice
Anything that is not a 2xx is retried. A 3xx counts as a failure — we do not follow redirects, so point the webhook at its final URL.
| Attempt | When |
|---|---|
| 1st | Immediately, as the response is stored |
| 2nd | ~30 seconds later |
| 3rd | ~2 minutes later |
| 4th | ~10 minutes later |
| 5th | ~1 hour later |
| 6th | ~6 hours later — the last one |
Intervals are jittered by up to 20% so that a receiver coming back up is not hit by every queued delivery in the same instant. After attempt 6 we stop and mark the delivery failed; the form owner can still resend it by hand.
Order is not guaranteed. A delivery that needed three attempts arrives after one that succeeded
first time, even if it was submitted earlier. Sort by submission.submittedAt rather than arrival
order.
Responding
Reply 2xx as soon as you have the delivery safely stored, then do the real work. We wait 10 seconds and treat anything slower as a failure — so an endpoint that finishes the job before replying gets retried, and processes everything twice.
Your response body is kept and shown to the form owner, truncated, so a short error message here is the fastest way to tell them what went wrong.
Testing your endpoint
Send test in the webhook settings posts a sample payload immediately and shows the status you
returned. The test body carries an extra "test": true field and is signed exactly like a real
delivery, so it exercises your verification code properly. It is not recorded as a delivery and is
never retried.
While developing, a request-inspector service such as webhook.site gives you a public URL you can point at and watch. Note the next section before reaching for a tunnel to your laptop.
Which URLs we can reach
The request comes from our servers, not from the browser of the person filling in your form. So the
URL has to be reachable from the public internet: a localhost address points at our machine, not
yours, and private and link-local ranges are refused outright.
For local development, use a tunnel that gives you a public hostname. And plan without a firewall allowlist: we run on serverless infrastructure with no fixed egress addresses, so there is no stable set of IPs to permit.
What we don't do yet
Stated plainly so you can design around it rather than discover it:
- One event.
submission.createdis all we send. Publishing a form, editing one, or deleting a response fire nothing. - Per form, not per workspace. A form created later starts with no webhooks — it does not inherit them.
- No delivery API. Delivery history is visible in the app; there is no endpoint to poll for it.
- Delivery records, including the body we sent, are kept for 30 days. Deleting a response deletes its delivery records with it, so a resend is no longer possible.
A note on the older signature
Deliveries currently carry a second header, X-MakingFlow-Signature, in the format sha256=<hex>,
signing the body alone. It exists only so integrations built before the timestamped format keep
working, and it will be removed.
Do not build against it. It cannot express a replay window, so a delivery captured once can be
replayed at your endpoint indefinitely. If you are verifying it today, move to
X-MakingFlow-Signature-V2, which is already being sent alongside.
Something wrong or missing on this page? Tell us.