MakingFlowDocs
Open app

Integrations

  • Webhooks

AI & MCP

  • MCP server
Docs/Integrations

Webhooks

https://quesli.com/docs/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.

POST body
{
  "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.

Answers are written by whoever filled in your form. Treat every value as untrusted input: escape it before rendering, never pass it to a shell or a query without parameterising.

Headers#

HeaderExampleNotes
X-MakingFlow-Signature-V2t=1757246400,v1=5257a8...HMAC-SHA256 proving the delivery came from us. Only sent when the endpoint has a signing secret.
X-MakingFlow-Delivery-Id550e8400-e29b-41d4-a716-446655440000Stable across every retry of this delivery. Deduplicate on it.
X-MakingFlow-Eventsubmission.createdWhat happened.
Content-Typeapplication/json
User-AgentMakingFlow-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.

The signature covers `${timestamp}.${body}` — the timestamp from the header, a full stop, then the raw body. Signing the body alone is the single most common mistake, and it fails every time with no clue why.

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.
Node — Express
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))
  })
Python — Flask
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 "", 200

Retries, 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.

AttemptWhen
1stImmediately, 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.

Delivery is at least once, not exactly once. If you accept a delivery but your response is slow enough for us to time out, we will retry one you already processed. Keep a record of handled X-MakingFlow-Delivery-Id values and ignore repeats — the id is stable across retries, and across a manual resend.

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.created is 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.

Next→MCP server

Something wrong or missing on this page? Tell us.

On this page

  • The short version
  • What we send
  • Headers
  • Verifying the signature
  • Retries, and why you may see a delivery twice
  • Responding
  • Testing your endpoint
  • Which URLs we can reach
  • What we don't do yet
  • A note on the older signature

Search documentation

Find a page or a section