Back to Blog
Engineering6 min read

Forms Without a Database: How Webhook-First Collection Works

Almost every form product works the same way. Someone fills out your form, the vendor writes the submission to their database, and you get a dashboard to read it from. Notifications, exports, and integrations are all built on top of that stored copy.

That copy is so standard that most teams never ask whether they need it. It's worth asking, because a form is not really a storage problem. It's a transport problem wearing a storage costume.

What a submission actually is

Trace a form submission to its end state and it almost always finishes somewhere you already own. A lead goes into your CRM. An intake form goes into your patient record system. An event check-in goes into your attendee list. A support request becomes a ticket.

The vendor's database isn't the destination. It's a waiting room the data sits in until something moves it somewhere real.

Waiting rooms aren't free. That copy has to be encrypted, access-controlled, backed up, retained on a schedule, deleted on request, and disclosed if it leaks. You inherit all of that for data that was only ever passing through.

The webhook-first shape

The alternative is to treat the submission as something to deliver rather than something to keep:

  1. The form is submitted.
  2. The payload is validated against the form's schema.
  3. It's POSTed to your endpoint.
  4. It's discarded.

What survives is delivery metadata — a timestamp, success or failure, response time, any error your endpoint returned. Enough to answer "did it arrive?" without keeping what arrived.

This isn't novel. It's how payment processors hand off events and how most internal systems talk to each other. It's just unusual to apply it to the front door, where the collection tool is normally the system of record by default.

What gets harder

This is a real trade, so it's worth being specific about the cost.

There's no inbox to fall back on. If your endpoint is down when a submission is delivered, that submission is gone. There's no vendor-side copy to replay from, because there's no vendor-side copy at all. Your endpoint's uptime becomes your data's durability, and that is a genuine cost — one worth weighing honestly rather than papering over.

You have to build the boring parts. Storage, search, export, deduplication, and access control are now yours. If you don't already have a system that does those things, you're not avoiding a database — you're just moving where it lives.

Debugging is different. You can't open a submission from three weeks ago and inspect the exact payload, because it isn't there. You need your own logging at the receiving end, which is good practice regardless but is now mandatory rather than optional.

What gets easier

The breach surface shrinks to the transit window. You can't leak a store you don't have. This is the one that matters most, because it's the difference between a bad week and a disclosure obligation.

Data residency stops being a negotiation. "Where do you store our data?" has a short answer when the answer is "we don't." No regional replicas, no sub-processor list for storage, no data-residency addendum.

There's nothing to migrate. Leaving a webhook-first tool means pointing the form somewhere else. There's no export, no historical archive to move, no long tail of data held hostage by a switching cost.

Making the receiving end boring

Once the vendor isn't the system of record, your endpoint carries real weight. Three things do most of the work.

Verify the request is genuinely from your form tool. SingleForm signs each request with HMAC-SHA256 over the form ID, a timestamp, and a single-use nonce, so your endpoint can confirm the caller holds your secret and that the request isn't a replay of an earlier one. Reject anything outside the timestamp window and compare signatures in constant time.

import express from "express";
import { singleform } from "singleform-express-webhook";

const app = express();

app.post(
  "/webhooks/intake",
  singleform({ secret: process.env.SINGLEFORM_SECRET }),
  async (req, res) => {
    // Signature and timestamp already checked by the middleware.
    const saved = await db.intake.create({ data: req.body });

    res.json({
      success: true,
      data: { submissionId: saved.id, message: "Thanks — we've got it." },
    });
  }
);

There are equivalents for Python (pip install singleform-webhook) and Rails (gem "singleform_webhook"), or you can verify by hand — it's about fifteen lines, and the exact construction is documented.

Answer in the shape the sender expects. SingleForm reads your response and shows it to the person who submitted the form, so a success: false with a real message becomes a useful error at the moment it can still be fixed, instead of a silent failure discovered later.

Be idempotent. Networks being what they are, your endpoint will eventually see the same submission twice — a timeout on the sender's side that actually succeeded on yours is enough to do it. Key on something stable and make the second write a no-op. This costs little to build and saves you from reasoning about duplicates under pressure later.

When this is the wrong choice

If you want a tool that is the system of record — a team collecting responses with no backend, no engineer, and nowhere else for the data to go — a webhook-first form is a poor fit. That team wants the inbox, and there's nothing wrong with wanting the inbox.

The model earns its keep when you already have somewhere for the data to live, and the vendor's copy is pure liability: an extra store to secure, disclose, and eventually delete, holding data you already have.

If that describes your setup, the right number of copies of your form data is one. And it should be yours.

Try the live webhook example or create a form to test delivery to your own endpoint.