Complete guide

A complete guide to hosted form backends: validation, storage, delivery, and observability

A hosted form backend is a service that owns the four stages between a submitted form and a person reading the message: it decides whether the payload is acceptable, makes it durable, delivers it to its destinations, and records what happened at each step. You keep the form. It keeps the path after submit.

Most comparisons stop at the first stage — how the form is built, how fields are configured — and most failures live in the last two. A form that validates correctly and stores nothing loses data the first time a mail provider rate-limits you. A form that stores everything and records nothing tells you it worked until a customer disagrees.

This guide goes through the four stages in the order a submission travels through them, then covers the seams, because that is where implementations quietly disagree with their own documentation. It ends with a worked reference implementation and a failure taxonomy you can use to diagnose a real form.

What "hosted" means, precisely

Three arrangements get called hosted, and the differences decide what you operate.

A hosted endpoint. A third party runs the receiving service. You post to an absolute URL and get back a status; they validate, store, notify, and record. Your only operational duty is the account.

A platform function. The receiving code runs on the host that serves your site. It is hosted in the sense that you do not run a server, but you own the handler: its storage choice, its mail API key, its retry logic, its failure path.

Self-hosted software. You run the service, its database, and its mail integration. Everything above, plus upgrades, backups, and the pager.

The rest of this article applies to all three, because the four stages exist regardless of who runs them. Only the last one is optional in practice, and that is the mistake it describes.

Pillar one: validation

Validation answers one question — is this payload acceptable — and it has to answer it on the server, because the browser belongs to the visitor. Client-side checks improve the experience; they are not a boundary.

A useful backend validates in three layers, and it is worth knowing which layer a given rejection came from.

Shape. Is the body parseable at all? Content type understood, no oversized payload, field names within limits, values of permitted types. Rejecting here is cheap and unambiguous: a malformed request should never reach storage.

Owner rules. What the person who owns the form decided: this field is required, this one must look like an email, this message may not contain nine links. These are configuration, not code, and they change without a deploy.

Classification. Is this likely junk? A honeypot that was filled, a submission faster than a person can type, a payload that is one long non-Latin string, a disposable mail domain. Classification is not validation: a spam verdict should not reject the request, it should store it with a verdict attached.

That last distinction is the one most implementations get wrong. Dropping suspected spam silently means a false positive becomes a lost customer nobody ever hears from. Storing it as spam, with the reasons, costs a row and keeps the decision inspectable and adjustable.

a rejection should say what to fix
HTTP/1.1 422 Unprocessable Entity
content-type: application/json

{
  "error": "validation_failed",
  "message": "Email is required.",
  "fields": { "email": "required" }
}

The response shape matters more than it looks. A client rendering "something went wrong" has thrown away the one piece of information the visitor could act on. A field-level error lets a form highlight the input and keep everything else the visitor typed.

Pillar two: storage

Storage is what makes a submission outlive the request. It is also the only stage whose importance is invisible until delivery fails — at which point it becomes the entire point.

What a stored submission needs to be useful later:

  • The field values, as submitted, with types preserved (a number stays a number, a multi-select stays an array).
  • A timestamp in UTC, and the timezone the form owner reads it in.
  • Request metadata — the client address, user agent, origin, referrer, and any UTM parameters. This is what answers "was this real" later.
  • A status that reflects the submission's own lifecycle, separate from delivery: received, held, spam, partial.
  • Attachments, with their own storage keys, sizes, and content types, and a deletion path that removes them.

Three decisions about storage get made once and then cost years of work if wrong. Retention: an archive that silently expires after thirty days is a data-loss schedule if the form is a legal or support record. Export: CSV out is table stakes; being able to filter before exporting is what makes it usable. Deletion: deleting a submission must delete its files, or the record and the disk disagree forever.

There is also a question of what storage should not be. A form backend is not a CRM, and a submission record should not grow into one. The moment it does, the fields become a schema, the schema needs migrations, and you have built a database with a form in front of it.

Pillar three: delivery

Delivery turns a stored record into an event other people and systems can act on. A modern backend has three channel types, and they fail independently.

ChannelFailure looks likeWhat good behaviour is
Owner notification Provider rejects the message; the address bounces; the sending domain is not authenticated A per-message status with the provider id, the error text, and a retry policy with backoff
Autoresponse The submitter's address is invalid or suppressed; the template fails to render a field Skipped and recorded as skipped, never silently counted as sent
Webhook The receiver is down, slow, or returns 500 for an hour Signed requests, bounded retries, a visible log, and a replay once the receiver is fixed

Two properties make the difference between a backend and a coin flip. Idempotency: the same event must not produce two notification emails or two webhook calls because a retry happened. A delivery attempt has an identity, and re-sending the same attempt is a no-op unless a human asked for a replay. Independence: a failing webhook must not stop the owner's email, and vice versa. Channels that share a transaction share a fate.

Signing is the other half of webhook reliability. Without a signature, your receiver cannot distinguish a genuine event from a stranger posting JSON to your endpoint — and a form endpoint is public by design, so anyone who learns the URL can try.

verifying a signed webhook (Node)
import { createHmac, timingSafeEqual } from 'node:crypto';

// Header: x-freelyform-signature: t=<unix seconds>,v1=<hex hmac>
function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  // Reject stale deliveries so a captured request cannot be replayed forever.
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(parts.v1 ?? '', 'utf8');
  return a.length === b.length && timingSafeEqual(a, b);
}

Note what the signature covers: the timestamp and the raw body together. Signing only the body allows an old request to be replayed; signing only the timestamp protects nothing.

Pillar four: observability

Observability is the stage that decides whether you learn about failures from a log or from a person. It is also the stage that is easiest to fake: a green "delivered" badge next to every submission looks like observability and can be a hardcoded status.

What a real record contains, per submission:

  • The submission's own state, and its history — when it was received, held, released, or classified.
  • One delivery row per channel: status, attempt count, provider message id, error text, last attempt time.
  • The request's provenance: client address, user agent, origin, referrer.
  • For webhooks, the response code and body from the receiver on the last attempt.

Four capabilities follow from that data, and each answers a question support actually gets asked:

Retry. Automatic, bounded, with backoff — and visible in the record, because a retry you cannot see is indistinguishable from a drop.

Replay. A deliberate re-send after the cause is fixed. This is what turns a failed webhook into a recoverable event instead of a lost one.

Suppression. A hard bounce means the address does not exist; a complaint means the recipient reported it as spam. Both have to survive as state, or the next send damages the sending domain's reputation for everyone on the account.

Usage. Volume against the plan's allowance, because a quota discovered by being blocked is a support ticket, while a quota discovered at fifty percent is an upgrade conversation.

One design choice inside usage deserves its own sentence. When an account exceeds its monthly allowance, the good behaviour is to keep storing submissions and stop notifying — holding them in a state the owner can release later — rather than rejecting visitors. Rejecting the visitor loses the lead and hides the cause; holding the submission loses nothing and makes the quota visible.

The seams between the pillars

Most bugs live where two stages meet, because each stage is tested on its own.

Validation → storage. A payload that passes validation but exceeds a column limit fails at insert, after the visitor has been told it was accepted. Storage limits have to be part of validation, not a surprise at the end.

Storage → delivery. If the delivery job is enqueued before the record is committed, a fast worker can read a row that does not exist yet. Commit, then enqueue; the reverse order is a race that shows up under load only.

Delivery → observability. A retry that overwrites the previous attempt's error destroys the diagnosis. Each attempt is an event, and the record should accumulate rather than replace.

Deletion → everything. Deleting a submission while a delivery is queued produces a job pointing at nothing. The queue has to tolerate the target disappearing, and the deletion has to be recorded somewhere, because "it was here yesterday" is a real support case.

Quota → validation. Where a submission is counted matters. Spam should not consume an allowance, and a saved draft should not either, or a form that half-validates burns the customer's month on records they never received.

A worked reference flow

Putting the four pillars together produces a sequence that is worth memorising, because every failure maps onto one step.

  1. Parse the body by content type; reject malformed payloads before anything else.
  2. Validate against the form's rules; return field-level errors with a message a visitor can act on.
  3. Classify for spam; a verdict is stored with the record, not used to discard it.
  4. Check the allowance; over it, store as held and stop — do not reject the visitor.
  5. Commit the record, then enqueue delivery.
  6. Deliver each channel independently, recording a row per channel with status and provider id.
  7. Retry transient failures with backoff; expose the attempt count and the last error.
  8. Record usage, and warn the owner at thresholds rather than at the wall.

Eight steps, and the interesting thing is how many are about not losing things: not losing the payload to a validation gap, not losing the record to a race, not losing the lead to a quota, not losing the diagnosis to an overwritten error.

A failure taxonomy

SymptomPillarUsual cause
The visitor sees a success page, nothing arrivesDeliveryA send failed and nothing surfaced it; no per-channel record exists
Some submissions arrive, some do not, same formDelivery → observabilityRetries fire for transient errors but nothing records which attempt succeeded
The form worked, then stopped after a redesignValidationField names changed; the endpoint now rejects a payload the browser considers valid
Attachments disappear but the message staysStorageRetention applies to files only; the record outlives them by design
The same alert fires twiceDeliveryA retry after a timeout that had actually succeeded; no idempotency key
A quiet month, then a wall of rejectionsObservabilityThe allowance was reached with no warning, and the overage policy rejects instead of holding
Notifications stop for one recipient onlyObservabilityThe address was suppressed after a hard bounce, and the suppression is working as intended

Notice that only two of the seven are validation problems, and none are about how the form looks. That distribution is the argument for evaluating a backend by its records rather than its builder.

Twelve questions to ask before you commit

  1. Does validation run on the server, and do errors identify the field?
  2. Is the payload stored before delivery is attempted?
  3. Is there one delivery row per channel, with provider id and error text?
  4. Are retries automatic, bounded, and visible?
  5. Can a failed delivery be replayed without a support ticket?
  6. Are webhook events signed, with the timestamp inside the signature?
  7. Do hard bounces and complaints become persistent state?
  8. What happens at the monthly allowance — held or rejected?
  9. Do spam submissions and saved drafts consume the allowance?
  10. Does the archive expire, and is that documented as retention rather than a limit?
  11. Can you export filtered data, and delete a submission with its files?
  12. Can the markup point somewhere else later without changing a single field?

Where to go next

  • Features — how each of the four stages is implemented here, including the parts that are deliberately absent.
  • What is a form backend — the definition and the decision, if you have not settled those yet.
  • Static sites without server code — payload formats and the request lifecycle.
  • Webhooks — signature verification, retries, and replay in detail.
  • Limits and quotas — the numbers this article describes, as they apply today.

Backend architecture questions

What does a hosted form backend actually host?

Four things, in order: the validation that decides whether a payload is acceptable, the storage that makes a submission outlive the request, the delivery that reaches an inbox or another system, and the observability that records what each of those did. You host the form; the service hosts the path after submit.

Why is server-side validation necessary if the browser already validates?

Because the browser is the visitor's software, not yours. Anyone can post with curl, and a payload that never passed a form is indistinguishable from one that did. Server-side validation is the only version that is a guarantee; the browser's version is a courtesy that improves the experience.

What should a form backend record about a delivery?

A status per channel, the provider message id when the send succeeded, the provider error text when it did not, the attempt count, and timestamps. That set answers "did it arrive", "why not", and "has anything changed since" without reading server logs.

What is the difference between a retry and a replay?

A retry is automatic and part of the same event: the system tries again after a transient failure, with backoff, up to a limit. A replay is a human decision after the cause is fixed: the same event is re-sent deliberately, and the record shows it happened.

How do attachments change the storage and delivery picture?

Files add a location, an allowlist for types, a size cap, a retention window, and a download path that must not be guessable. They also change deletion semantics: deleting a submission has to delete its files, or the archive and the disk disagree.

What is the most common observability gap in form handling?

Recording that a submission was accepted but not recording what happened to each delivery attempt. A single "sent" flag cannot distinguish a delivered message from one the provider rejected, so the first person to notice a failure is a customer.