DEV Community

Cover image for Webhook-First Form Handling: Piping Submissions Into Your Own Stack

Webhook-First Form Handling: Piping Submissions Into Your Own Stack

Webhook-First Form Handling: Piping Submissions Into Your Own Stack with onsubmit.dev (form backend)

For backend-heavy applications, form handling rarely ends with β€œsend me an email.” A submission may need to enter a queue, be normalized and persisted, create a CRM lead, or trigger an internal workflow. onsubmit.dev (form backend) can sit at the public edge of that architecture: the browser submits to a managed form endpoint, while your own stack receives the resulting webhook and performs the domain-specific work.

The interesting part of this approach is the separation between public form ingestion and application processing. Your backend no longer needs a dedicated internet-facing form controller just to deal with browser submissions.

The architecture

A webhook-first pipeline can look like this:

Browser / static site
        |
        | form POST
        v
onsubmit.dev (form backend)
        |
        | webhook
        v
Your webhook endpoint
        |
        +----> Database
        |
        +----> Queue / event bus
        |
        +----> CRM
        |
        +----> Internal services
Enter fullscreen mode Exit fullscreen mode

This is especially useful when the frontend and backend have different deployment lifecycles. A marketing site can remain static, for example, while submissions ultimately enter infrastructure owned by the backend team.

The ingestion layer deals with accepting the form submission. Your webhook handler remains responsible for business semantics.

Why put an ingestion layer in front?

You could expose /api/contact from your application and POST directly to it. For many products, that's entirely reasonable.

The webhook-first model becomes more interesting when forms are outside your main application boundary: documentation sites, landing pages, Astro-generated sites, campaign pages, or independently deployed React/Vue frontends.

Instead of making every frontend deployment aware of your internal application topology, you get a boundary like:

untrusted/public input
       ↓
form ingestion
       ↓
webhook boundary
       ↓
trusted application pipeline
Enter fullscreen mode Exit fullscreen mode

That boundary lets your application concentrate on what happens after ingestion.

For example, a β€œrequest a demo” submission might result in a durable internal event rather than a synchronous chain of API calls:

form.accepted
    ↓
queue
    β”œβ”€β”€> lead persistence worker
    β”œβ”€β”€> CRM synchronization worker
    └──> analytics worker
Enter fullscreen mode Exit fullscreen mode

Now a temporary CRM outage does not necessarily have to make the original form-processing workflow fail.

Keep webhook handlers boring

A common webhook mistake is doing every piece of work before returning the HTTP response.

Imagine this handler:

receive webhook
    ↓
validate
    ↓
insert database row
    ↓
call CRM
    ↓
send notification
    ↓
update analytics
    ↓
return 200
Enter fullscreen mode Exit fullscreen mode

You have just coupled webhook acknowledgement to the latency and availability of several unrelated systems.

For production systems, a better boundary is usually:

receive
  β†’ authenticate/validate
  β†’ assign or read an event ID
  β†’ durably persist/enqueue
  β†’ acknowledge

worker
  β†’ normalize
  β†’ apply business rules
  β†’ update DB
  β†’ synchronize CRM
  β†’ trigger downstream events
Enter fullscreen mode Exit fullscreen mode

This makes the webhook endpoint a thin adapter rather than another business-logic controller.

Assume delivery can happen more than once

Once a form submission becomes a webhook, treat it like any other distributed event.

In particular, consumers should be idempotent. If the same submission reaches your endpoint twice, processing it twice should not create two CRM contacts, send two welcome sequences, or produce duplicate database records.

If the incoming event has a stable unique identifier, store it alongside the processing state and enforce uniqueness at the database level. Conceptually:

INSERT INTO webhook_events (event_id, payload, status)
VALUES (:event_id, :payload, 'received')
ON CONFLICT (event_id) DO NOTHING;
Enter fullscreen mode Exit fullscreen mode

A uniqueness constraint is substantially safer than an application-level β€œcheck whether this exists, then insert” sequence, which can race under concurrent delivery.

Downstream integrations need the same consideration. If your CRM API supports idempotency keys, propagate a stable identifier into those calls.

Preserve the raw event

It is tempting to immediately transform a form payload into your internal Lead or ContactRequest model and discard the original.

Keeping the raw webhook payload is often worth the small storage cost.

It gives you something to inspect when a mapping changes, a worker has a bug, or an external integration needs to be replayed. A useful internal event record typically contains the provider/event identifier, receipt timestamp, raw payload, processing status, attempt count, and last failure.

That creates a useful distinction between β€œwe received this submission” and β€œall downstream effects completed successfully.”

Treat the webhook as an external API

Putting onsubmit.dev (form backend) in front of the form does not make the receiving webhook inherently trusted. The webhook route is still an externally reachable integration boundary.

Use the authentication or verification mechanism documented for the integration, validate what you receive, enforce payload limits where appropriate, and keep credentials out of frontend code. Consult the current service documentation before implementing verification rather than assuming a particular signature scheme or payload format.

After authenticity checks, apply your own domain validation as well. Transport validity and business validity are different questions.

A syntactically valid submission can still contain a product ID your application does not recognize or a field value that violates an internal invariant.

Version your internal contract

External form fields tend to evolve independently from backend models.

Instead of letting a form payload flow unchanged through every service, normalize it at the ingestion boundary:

external submission
       ↓
webhook adapter
       ↓
internal FormSubmissionReceived v1
       ↓
business consumers
Enter fullscreen mode Exit fullscreen mode

That adapter is the right place to rename fields, convert strings to internal types, attach application metadata, and translate a particular form into your domain vocabulary.

It also prevents a marketing-site field rename from silently becoming a breaking schema change across several backend consumers.

Failure handling matters more than the happy path

For a serious pipeline, decide what each type of failure means before shipping it.

Transient downstream failures should normally be retryable. Invalid domain data may belong in a dead-letter path. Permanent CRM rejection should be observable without forcing endless retries. Database and queue operations should have clear durability semantics.

Monitoring should answer at least three questions: Are submissions arriving? Are they being processed? Are downstream side effects succeeding?

Those are separate health signals. A webhook endpoint returning successful responses says little about whether a queue worker has been failing for six hours.

Where this pattern fits

Webhook-first processing is a particularly clean fit for teams that already operate queues, workers, event-driven services, or centralized integration infrastructure. It also helps when multiple frontend stacks need the same ingestion mechanism; the service provides integrations/packages for Astro, Next.js, React, and Vue.

For a tiny site where the only requirement is persisting a row in the same application database, adding another architectural boundary may not buy much. Directly handling the POST yourself can remain the simpler design.

The value appears when public form ingestion and internal business processing are genuinely different concerns.

The useful abstraction

The main architectural shift is to stop thinking about a form as a frontend feature that needs a bespoke backend route.

Think of it as an event source:

FormSubmissionReceived
Enter fullscreen mode Exit fullscreen mode

onsubmit.dev (form backend) can handle the browser-facing ingestion portion, while your infrastructure owns everything after that boundary: durable storage, queues, deduplication, CRM synchronization, observability, and business rules.

That gives backend teams a familiar integration model without requiring every static site or frontend application to become another custom form-processing service.

Top comments (0)