Skip to content
Blog/Webhooks and delivery

Your webhook payload is missing fields — and conditional logic is why

Skipped questions never reach your webhook. Here's the mechanism behind variable payload shapes — plus a parser that survives every branch.

Vellform Engineering9 min read4 sources
Seven answer rows on a dark grid: five drawn solid, two drawn as dashed outlines where a skipped question left no key at all.

Your handler ran clean for a week. Then, at 02:14, KeyError: 'f_size_5192' — the seats question — a 500 back to the sender, and a submission you now have to reconstruct by hand.

Nothing broke. One respondent answered an earlier question differently, took a branch that never asks how many seats, and so that key was not in the payload at all. Not null. Not "". Absent. Your parser indexed a key that does not exist on that path.

That is the whole answer. What follows is the mechanism, a way to know every shape your endpoint will ever receive before you deploy, and two parsers.

The symptom: your parser worked for a week, then threw a KeyError

Here are two deliveries from the same form, an hour apart. Same endpoint, same signature header, same event type. Look at answers.

json
// delivery 1 — respondent answered "Yes" on page 2
{
  "event": "form.submitted",
  "submissionId": "sub_7f21c9",
  "formId": "frm_9c04ab",
  "formTitle": "Enterprise trial request",
  "submittedAt": "2026-09-02T09:14:22.101Z",
  "answers": {
    "f_email_8823":   { "label": "Work email",         "type": "email",  "value": "dana@acme.io" },
    "f_company_1075": { "label": "Company name",       "type": "text",   "value": "Acme" },
    "f_team_4410":    { "label": "Buying for a team?", "type": "radio",  "value": "Yes" },
    "f_size_5192":    { "label": "How many seats?",    "type": "number", "value": 40 }
  }
}

// delivery 2 — respondent answered "No"
{
  "event": "form.submitted",
  "submissionId": "sub_7f2204",
  "formId": "frm_9c04ab",
  "formTitle": "Enterprise trial request",
  "submittedAt": "2026-09-02T10:02:47.883Z",
  "answers": {
    "f_email_8823":   { "label": "Work email",         "type": "email", "value": "sam@example.com" },
    "f_company_1075": { "label": "Company name",       "type": "text",  "value": "Bell & Sons" },
    "f_team_4410":    { "label": "Buying for a team?", "type": "radio", "value": "No" }
  }
}
Two deliveries from one form. The second respondent said no on page 2 and was never routed to the seats question.

The envelope is identical. event, submissionId, formId, formTitle and submittedAt are there every single time — that is the part the Standard Webhooks spec pins down, and the part you can safely write a type for. The body is not identical, and it was never going to be.

It took a week to bite because most people take the common branch. The rare path is rare by construction — that is what makes it a branch — so your test traffic and your first few hundred real responses can all miss it. The bug shipped on day one and surfaced on day seven.

The mechanism: payload shape is a function of the path taken

What "the path" means when a rule can target a page or a field

In Vellform, conditional logic is one flat list of rules on the form, and what a rule targets depends on the action it takes. skip_to_page and end_form target a page: when this answer matches, go there instead of straight on — that is the routing layer, and it is what carves a form into paths. show and hide target a single field on a page the respondent has already reached. Both kinds are written the same way, with the same 10 operators; equals, is_empty and greater_than cover most real forms.

So a submission is a walk through a graph of pages, with a second filter applied at each page it lands on. What gets collected is what was visible on the pages the respondent actually walked through, and what gets delivered is what was collected. There is no step in between where a skipped question is back-filled with a placeholder.

Two rule kinds, one outcome. A page the respondent was routed past collects nothing from any of its questions. A field a hide rule switched off collects nothing on a page they did reach. Either way the key is absent rather than empty, and the payload alone will not tell you which of the two happened. Typeform documents the same behaviour — its answers array carries only answered questions, and one skipped by a Logic Jump is not in the payload. This is what conditional forms do, not a quirk of one product.

Three states a question can be in, and only two produce a key

What the respondent didWhat arrivesWhat it tells you
Answered itThe key, with the valueA fact about the respondent
Saw it, left it blankThe key, with an empty value — null, an empty string, an empty arrayAlso a fact about the respondent: they were asked and declined
Was never routed to the page it lives onNo keyA fact about the form: that question was not part of this path
The middle row is the one people forget exists, and it is why null cannot stand in for absence.

Why absent is the correct behaviour and not a bug

It is tempting to ask senders to normalise this — emit every field every time, with null for the ones nobody answered. Don't. A null asserts that the question was asked and the respondent gave you nothing. That is a different fact from "this question was not on their path," and the difference bites where it costs most: a blank consent question you never asked is not a refusal.

Once both states are null in your database, no amount of later work recovers the difference. The sender is the only party that still knows, and it tells you by leaving the key out.

You can enumerate every payload shape before a single response arrives

Walk the page graph first, then the field rules

This is the part most people get backwards. They open the form, list the fields, and treat that list as the payload schema. It isn't — it is the union of every payload schema, which is a different and much less useful object.

Both passes are countable by looking, because every rule sits in one list rather than being scattered across the form. Routing pass first: start at the first page, and at each page read the rules whose target is a page. No rule means one outgoing edge, a rule means a second. Follow both. Every route from the first page to an ending gives you a set of pages. Then the field pass: for each route, drop any field a show or hide rule switches off given the answers that route implies. What is left is that route's key set.

Your detailsPage
Team size ≥ 10
ProcurementPage
otherwise
Check answersReview
One condition, two destinations. This is the routing pass — each route through the split collects a different set of answers, before any field-level rule is applied on top.

Nothing does this for you. There is no endpoint that returns your path set and no export that lists your shapes. The claim is narrower: the information is already sitting in the graph, readable in an afternoon, rather than something you discover from production logs six weeks in.

A worked example: four pages, one split, two payload shapes

  1. Page 1 — Work email, Company name.
  2. Page 2 — "Are you buying for a team?" One rule: when the answer equals "No", skip to the ending.
  3. Page 3 — "How many seats?" Reached only by falling through page 2.
  4. Ending — the confirmation the respondent sees.

Two routes reach the ending, so there are exactly two payload shapes:

text
path A ("No")   answers = { work_email, company_name, buying_for_team }
path B ("Yes")  answers = { work_email, company_name, buying_for_team, seats }

guaranteed  : work_email, company_name, buying_for_team
conditional : seats
Three keys are guaranteed on every delivery. One is conditional. That is the contract.

Add a second independent split and you get up to four. The count is bounded by the number of routes to an ending and is often smaller — two routes that ask the same questions produce the same shape. Shapes, not paths, are what your parser cares about.

What this buys you

The payload contract stops being folklore. It becomes four lines of text you can keep next to your handler, review in a pull request, and diff when someone edits the form. When a colleague adds a branch, "what does this do to the integration?" has a concrete answer instead of a shrug — and the guaranteed set, exactly, is the list of fields your downstream schema may treat as required.

Two parsers

The parser that breaks

python
def handle(payload):
    answers = payload["answers"]

    # 1. Direct indexing. Raises on any path that doesn't ask.
    seats = answers["f_size_5192"]["value"]

    # 2. Truthiness instead of presence. 0 seats and "" both read as missing.
    if answers.get("f_size_5192", {}).get("value"):
        upgrade(seats)

    # 3. Counting. "A full submission has 4 answers" is true of one path.
    if len(answers) < 4:
        raise ValueError("incomplete submission")

    # 4. Position. A JSON object is unordered; index 2 is not a promise.
    company = list(answers.values())[2]["value"]
Every line of this is a way of assuming the shape.

The len() check is the one that hurts most, because it looks like validation. It is a hard-coded assertion that every respondent takes the longest path, and it rejects perfectly good submissions with a 500.

The parser that survives

Three rules. Test key presence, never truthiness. Project every shape onto one flat record with the same keys every time. Keep a sentinel for "not asked" that is distinguishable from an empty answer.

python
NOT_ASKED = object()          # a sentinel; never equal to None or ""

FIELDS = {                    # field id -> your name for it
    "f_email_8823": "work_email",
    "f_company_1075": "company_name",
    "f_team_4410": "buying_for_team",
    "f_size_5192": "seats",
}

def normalise(payload):
    answers = payload.get("answers") or {}
    record = {}
    for field_id, name in FIELDS.items():
        if field_id not in answers:            # never put to this respondent
            record[name] = NOT_ASKED
            continue
        record[name] = answers[field_id].get("value")   # may be None, "", []
    return record


record = normalise(payload)

if record["seats"] is NOT_ASKED:
    pass                       # they were never on that page
elif record["seats"] in (None, ""):
    ask_sales_to_follow_up()   # they saw the question and skipped it
else:
    provision(record["seats"])
The normaliser is the only code that touches the raw payload. Everything downstream sees one fixed shape.

NOT_ASKED is a Python object, not JSON. Convert it at the boundary where you persist or forward the record — to a distinct string, a separate boolean column, or an omitted key. Whatever you choose, choose it deliberately: that is the moment the distinction survives or dies.

The same normaliser in Node

javascript
const NOT_ASKED = Symbol("not asked");

const FIELDS = {
  f_email_8823: "workEmail",
  f_company_1075: "companyName",
  f_team_4410: "buyingForTeam",
  f_size_5192: "seats",
};

function normalise(payload) {
  const answers = payload.answers ?? {};
  const record = {};
  for (const [fieldId, name] of Object.entries(FIELDS)) {
    record[name] = Object.hasOwn(answers, fieldId)
      ? answers[fieldId].value
      : NOT_ASKED;
  }
  return record;
}
Object.hasOwn rather than a truthiness check — 0, false and "" are all real answers.

One more rule, whatever the language: tolerate keys you have never seen. A field added to the form tomorrow shows up in answers tonight, and a parser that rejects unknown keys turns an ordinary edit into an outage. Stripe's webhook guidance makes the same point from the other side — its SDKs carry an explicit "unknown event" type so a receiver written today survives events invented later.

The typed-language footnote: optional, not nullable

typescript
// Wrong. The compiler now believes seats is always a property.
type Wrong = { seats: number | null };

// Right. Branch-dependent fields are optional properties.
type Right = { seats?: number };

// With "seats?: number" this is a compile error rather than a
// 3 a.m. runtime surprise:
//   const n: number = submission.seats;
Nullable says the key is always there. Optional says it might not be. Only one of those is true.

Turn on strict, and if you index answers by a dynamic id, turn on noUncheckedIndexedAccess too — otherwise TypeScript hands you a confident SubmissionAnswer for a key that isn't there.

Testing for branch coverage

One fixture per path, not one fixture per form

Most teams have exactly one saved webhook body, captured the day they built the integration, from whichever path they happened to walk. It tests the parser against the shape that was never going to break.

Your fixture set is your path set. Walk each route through the graph in a preview of your own form, submit, and save the body your endpoint receives. One fixture per route, however many that is. They are cheap and they never go stale on their own — they go stale when the form changes, which is precisely when you want to be told.

A test that fails when someone adds a branch

python
PATHS = [
    {"work_email", "company_name", "buying_for_team"},
    {"work_email", "company_name", "buying_for_team", "seats"},
]

def asked_keys(body):
    record = normalise(body)
    return frozenset(k for k, v in record.items() if v is not NOT_ASKED)

def test_fixtures_cover_every_path():
    covered = {asked_keys(load(f)) for f in fixture_files()}
    assert covered == {frozenset(p) for p in PATHS}
PATHS is the contract you derived from the graph. The test's job is to complain when reality and the contract disagree.

This fails in both useful directions: a branch added without a fixture, and a fixture whose shape you never wrote down. What it cannot catch is a change nobody tells you about — a colleague adding a page, or when an AI edit changes your payload contract. Nothing in a test suite can. That is what the note below is for.

Replaying a real delivery locally

There is no replay button. Save the raw body yourself — from your fixture directory, or straight out of your request log — and POST it at localhost. If your handler verifies the signature, and it should, recompute it: the signature is a hex HMAC-SHA256 over the exact bytes of the body, keyed by the form's signing secret, so any change to the body means a new signature.

bash
BODY=$(cat fixtures/path-b.json)
SECRET=whsec_your_test_secret

curl -sS -X POST http://localhost:3000/webhooks/forms \
  -H "Content-Type: application/json" \
  -H "X-FormForge-Signature: $(printf '%s' "$BODY" \
       | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')" \
  --data-binary "$BODY"
The header is X-FormForge-Signature. It is not the name you would guess, so copy it exactly — matching on anything else matches nothing.

Compare bytes, not parsed objects. If your framework re-serialises the body before your verification code sees it — a JSON body parser that hands you a dict is the usual culprit — the signature will never match, and you will spend an afternoon blaming the sender. If you have not pointed a form at an endpoint yet, the header table and a working verification snippet are on the integrations page.

Downstream: what absent fields do to your sink

Spreadsheets and CRMs flatten a union

A row has a fixed set of columns; a payload doesn't. Anything that writes submissions into a table is flattening the union of every path into one header row, and the cells for questions that were never asked come out empty.

Fine for reading, dangerous for counting. "How many people said no to marketing email?" gets a wrong answer if the blanks include everyone who was never shown the question. If you plan to compute anything from that sheet, keep the raw body in your own store alongside the row — the row cannot answer that question and the body can.

Of the five delivery destinations, the webhook is the one that hands your code the JSON as sent. The others put a submission in front of a person or into a row, and both are lossy on purpose.

Don't fill the gap with null on the way in

The tempting shortcut is an ORM default: nullable column, write None when the key is missing, move on. It works right up until someone asks a question about the data. "Asked and declined" and "never asked" now look identical in the row, and the distinction was destroyed at write time.

If you want it in one table, use a nullable column plus a boolean seats_asked. No ambiguity, and the boolean falls straight out of the key check you already did in the normaliser.

One last consequence: a handler that raises on an unexpected shape returns a 500, and a 500 is retriable — so the same unparseable body comes back twice more, about a second and then about four seconds later, failing identically each time. Three attempts and it is gone. Return a 400 instead. RFC 9110 draws the line senders retry across: a 4xx means the client appears to have erred, a 5xx means the server is aware that it has. There is more on what happens when your endpoint returns a 500.

From the respondent's side, the same page graph decides two other things: it is the same branching that makes a step count unknowable at step one, and the reason a review page that shows only what was actually asked is harder to build than it looks.

Questions people actually ask

Why isn't the field just null?
Because null is an answer. It says the respondent was asked and gave you nothing, which is a claim about a person. Absence is a claim about the form: this question was not on their path. Merging the two loses information that only the sender has, and no downstream process can recover it.
Do unanswered optional questions appear?
If the respondent reached the page and left an optional question blank, you can receive the key with an empty value — null, an empty string, or an empty array depending on the field type. If they were never routed to that page, there is no key. That is the whole distinction, and it is why your parser should branch on presence first and emptiness second.
Does field order change between deliveries?
answers is a JSON object keyed by field id, and the keys are written in page order then field order. Don't rely on it: a JSON object is an unordered set of members, and a parser is free to hand them back in any order it likes. Parse into a map and index by id. Never index by position.
What happens if my parser 500s on an unexpected shape?
You get retried into the same failure. It is 3 attempts total, roughly a second and then roughly four seconds apart, and a body your code cannot parse will not parse any better on attempt three. Return 400 for an unparseable body — 4xx is not retried, except 408 and 429 — log the raw bytes, and reconcile from the CSV export rather than hoping the ladder saves you.
Sources

Sources

Every link below was opened and checked against the sentence it is cited for.

  1. 01A competitor documenting the same behaviour in its own words: the answers array holds only answered questions, and a question skipped by a Logic Jump is not in the payload. Variable payload shapes are how conditional forms work, not a quirk of one product. Typeform — example webhook payload
  2. 02The defensive-receiver posture: return a 2xx before any slow work, dedupe on the event id rather than assuming exactly-once, do not depend on ordering, and keep working when an event type you have never seen arrives. Stripe — receive events in your webhook endpoint
  3. 03The envelope-versus-body split: the spec fixes the headers (webhook-id, webhook-timestamp, webhook-signature) and recommends a stable top level of type, timestamp and data. It says a payload for an event type should keep one schema — which is exactly the expectation a branching form breaks inside the data object, and why the check belongs there. Standard Webhooks specification
  4. 04Status-code semantics for the handler: 4xx means the client appears to have erred and 5xx means the server is aware that it has, which is the line senders retry across. Also the definitions of 400 Bad Request and 422 Unprocessable Content. RFC 9110 — HTTP Semantics

Vellform Engineering

We write about the parts of form infrastructure that only show up in production.

Webhook docs