Sync responses
A webhook in Sync response mode keeps the caller's HTTP connection open and answers it from inside the flow. What answers is a Schema Validator — the moment it has judged the payload — or an AI Node, with its answer; never the end of the workflow. A rejection closes the connection at once, with that validator's own response. A pass, or an AI Node's answer, is held until the last node on the path that can answer has spoken, and the rest of the flow keeps running behind the answer.
What answers, and when
Sync mode is set per webhook — responseMode: "sync", the Sync button in the Response Mode card. Everything below describes POST /api/in/:token on such a webhook. In None mode nothing here applies: the ingress answers 202 with { eventId, streamToken } and never waits.
POST /api/in/<webhook-id> responseMode: "sync"
│
│ the event is stored, the run starts
│
└── Schema Validator A
│
├── payload fails → A's error response (422 by default)
│ and the pipeline stops — nothing else is dispatched
│
└── payload passes → noted, connection still open
│
└── Schema Validator B (if there is one on the path)
│
├── fails → B's error response — B's status, B's wording
│
└── passes → B's success response (200 by default)
and the rest of the flow keeps running behind it
(a validator in Reply only mode, anywhere on the path, answers with its own
status and body the moment the run reaches it — final, and the flow goes on)A rejection answers as part of the validator's own step, before it dispatches anything downstream, and it is final: the pipeline stops there, so nobody else could contradict it. A pass is not final — a second validator further down the path can still say no — so the connection stays open until the last validator on the path has passed. When a validator passes and nothing that can answer hangs below it, that is the last one and the caller gets its success response right then; when the run ends after a pass, the same thing. Either way the open connection costs you the validations — a few milliseconds each — and never the length of the workflow: a flow that ends in Slack, an AI node and a database write answers just as fast as a flow with nothing after the validator.
There is a third word, for the end of an error branch: a Schema Validator in Reply only mode validates nothing and answers with the status and body configured on it the moment the run reaches it. That answer is final in the same way a rejection is — a pass noted by an earlier validator is discarded — but nothing stops: the payload goes through and the nodes after it run behind the answer.
An AI Node answers with its answer
An AI Node on the path of a Sync webhook answers the caller too. The body is the node's output — the same payload the next node receives — without its internal_ fields: { "aiResponse": "…" }in text mode, your fields with a structured output, thecategory of a classifier. The status is200, and it goes out as soon as the model is done: the nodes after the AI Node keep running behind the reply, and the caller does not wait for them.
POST /api/in/<webhook-id>
{ "question": "Which orders does Ana have?" }
200 OK
content-type: application/json; charset=utf-8
{ "aiResponse": "Ana has 2 orders: A-1 (paid, 120) and A-3 (shipped, 80)." }It counts as a pass, not as a rejection: if another AI Node or a Schema Validator comes later on the same path, that one answers instead — so a validator placed after the AI Node can still check what the model said. If the AI Node fails — the model call errors, or the node cannot run at all, for example because no provider is chosen yet — the answer is final and at once: 500 with{ error: "AI Node failed", nodeName, message, eventId }.
Both failures a sync caller can get — this one and Delivery failed from the Target URL, in the table below — are a 500, not a 502: Cloudflare, in front of the API, replaces any 502 with its own page, and the caller would get neither the reason nor the eventId. A URL in the reason loses its user and password: postgres://ana:secret@db.example.com comes back as postgres://***@db.example.com.
eventId.targetUrl: that delivery is queued when the event arrives, before any validator runs, and the validator only gates pipeline nodes. If you need the target protected too, leave targetUrl empty and deliver with an HTTP Action placed after the validator.When nothing in the flow answers
A Schema Validator and an AI Node are the only nodes that can answer in sync mode. A flow without one, or one the event routed around, still gets an answer — it just is not a verdict. Whoever resolves first wins; the rest is dropped.
| Situation | Status | Body |
|---|---|---|
| The last validator on the path passed the payload | 200 | { accepted: true, eventId } |
| A validator rejected it — its own response, whichever one it was | 422 | { error: "Schema Validation Failed", … } |
| The run reached a Reply-only validator — its reply, final | as configured 200 when none is set | as configured { accepted: true, eventId } when none is set |
The webhook has a targetUrl and nothing has answered yet — the delivery answers | 200 500 | { deliveryStatus, statusCode, latencyMs, response } { error: "Delivery failed", lastError } |
| The run reached an Approval Gate | 202 | { awaitingApproval: true, approvalNodeName, message } |
| A Merge node is still waiting for branches | 202 | { awaitingMerge: true, mergeNodeName, receivedBranches, requiredBranches, message } |
| A Delay node is holding for 30 s or more | 202 | { delayed: true, delayNodeName, delaySeconds, message } |
| The run ended and nobody said anything | 200 | { accepted: true, eventId, validated: false } |
| A validator passed, and whatever came after it never answered before the timeout | 200 | { accepted: true, eventId } — that validator's success response |
| Nobody answered before the timeout | 408 | { error: "Pipeline timeout", eventId } |
| Recognised as a repeat delivery — the flow does not run at all | 200 | { eventId, deduplicated: true, originalEventId } |
validated: false is deliberate and it is the one flag worth reading. It means the event was accepted and is running, and that nothing checked the payload — not that it passed. A200 with that flag and a 200 without it are different answers, and telling them apart is the reason sync mode stopped hanging: for a long time “the payload is valid” and “nobody answered” were indistinguishable from outside, and both ended in a 408 two minutes later.A 200 means accepted, not finished
{ "accepted": true } says your payload matched the schema and the event was taken. It says nothing about how the workflow ended. The Slack step can fail, the HTTP Action can get a 500, the database write can time out — all of that happens after this response has already gone out, and none of it can change it. If your code treats this 200 as “the work is done”, it will be wrong every time something downstream breaks, and it will be wrong silently.The body carries an eventId precisely because the answer is partial. That id is how you find out what actually happened, and it is also in the response header so you can log it even when the body has been configured down to nothing.
| To find out | Call | Scope |
|---|---|---|
| Every node the event went through, in order | GET /api/events/:eventId/timeline | events:read |
| Every delivery attempt to the target URL | GET /api/events/:eventId/deliveries | deliveries:read |
If what you actually want is the outcome of the whole workflow in the same request, sync mode is the wrong tool — use None mode and the streaming result, described on the Node SDK page. Sync mode answers what it knows at the moment it answers, and deliberately refuses to promise more.
Sync needs something that can answer
Turning Sync on is refused with a 400 when the flow has no node capable of answering — a Schema Validator or an AI Node. The check walks the whole graph downstream of the trigger, through every port — the else branch of a Conditional and the rejection branch of a Filter count — so either of them anywhere below the trigger satisfies it.
400 Bad Request
Sync mode keeps the caller's connection open until something in this flow
answers, and this flow has nothing that can. Add a Schema Validator or AI
downstream of this trigger — a Schema Validator replies 200 or 422 depending on
the payload, an AI Node replies with its answer, and the rest of the flow keeps
running behind that reply — then turn sync mode on again.The check only runs when the request is the one turning Sync on, so disconnecting the validator for a moment while you rearrange the canvas does not fail a save. And connecting the validator and switching to Sync in the same call works: the outgoing connections in the request body are what gets walked, falling back to the stored ones when the body does not carry any.
{ accepted: true, validated: false } as soon as the run ends. The save-time check is a product warning, not the thing that keeps the connection from hanging.Configuring the response
The response belongs to the Schema Validator that gives it. Every field of its syncResponse is optional, and a validator that sets none of them behaves exactly as described above. It is sent on POST /api/schema-validators and PATCH /api/schema-validators/:id, and edited in the validator's Response tab, where the preview runs the example payload through the fields as you type. Anything outside a range is refused with a 400. The whole object is replaced on every write — send it complete.
| Field | Range | Default | What it does |
|---|---|---|---|
| successStatus | 200–299 | 200 | The status for a payload that passed. Capped at 2xx on purpose: a 5xx on an accepted event invites the caller to retry, and a retried webhook is a duplicated event. |
| errorStatus | 400–499 | 422 | The status for a payload that failed. 4xx for the same reason: a bad payload is never going to succeed on a retry. |
| detail | full, fields-only, opaque | depends | How much of the failure the caller is told. See Detail levels. |
| messageTemplate | ≤ 500 chars | — | Replaces the wording of every failure message. See Message wording. |
| fieldTemplates | object | — | Per-field wording. The key is the exact field path, and it beats messageTemplate. |
| includePayload | none, full, failed-fields | none | Echo what was sent back to the sender. See Echoing the payload. |
| envelope | hostwebhook, problem+json | hostwebhook | The shape of the error body. See RFC 7807. |
| problemType | ≤ 500 chars | about:blank | The type URI of the RFC 7807 body. Only used when the envelope is problem+json. |
successStatus produces a 200, not a 299: clamping would invent a number nobody wrote.One default is not the validator's to know: the detail level that applies when none is chosen depends on the webhook the event came through — whether its ingress requires an API key or a signature. That part is served by GET /api/webhooks/:id as a computed, read-only syncDefaults, carrying the detail in force, why, a sentence explaining it, and the timeoutSeconds that will really be used while a validator answers (the longer wait when an AI Node answers is not in it — see How long the connection is held). The validator's syncResponse is laid over those defaults.
Detail levels, and why the default is not always the same
| Level | What the caller gets |
|---|---|
| full | Which fields failed and why. errors carries a path and a message per failure, and message is the array of path: text lines. This is what the endpoint has always returned. |
| fields-only | Which fields failed, and nothing else. errors is a list of bare { path } objects and message is the list of paths. Enough to paint a form field red; it does not publish your rules. |
| opaque | { "error": "Invalid payload", "eventId": "…" } and nothing more. No field path, no count, no webhookName or nodeName. The eventId stays so you can still find the run when someone writes in. |
full publishes the map of your schema. /api/in/:token takes no session. Someone probing it can post {}, read which fields are demanded, post those, read which formats are wanted, and in three or four rounds have written out your schema — which is your data model, not ours. That is why the default depends on the door rather than being one value.When you have not chosen a level, the default is:
| The webhook | Default detail | Why |
|---|---|---|
Requires an API key (requireApiKey) | full | A caller holding a key has already proved you let them in. |
Requires a signature (incomingSignatureType other than none) | full | Only someone with the secret can produce the signature. |
| Neither — the door is open | fields-only | Anyone with the URL can call it, so the reasons would map out the schema. |
allowedIps still defaults to fields-only.It is a default, not a ceiling. Set detail: "full" on an open ingress if that is what you want — it is your schema. What cannot happen is publishing it without having decided to. And because the default is computed rather than stored, removing the API key from a webhook drops it back to fields-only in the same instant, instead of leaving a full written into the record.
Message wording
messageTemplate replaces the text of every failure message; fieldTemplates overrides it for one field, keyed by the exact path. Three placeholders are understood: {{field}}, {{rule}} and {{expected}}.
{
"syncResponse": {
"messageTemplate": "{{field}} is not acceptable",
"fieldTemplates": {
"customer.email": "We need a work email address"
}
}
}{{rule}} is the name of the check that failed — required, type, format, minLength, enum, strict and so on — and {{expected}} is what that check wanted: email, 3, string. Both are empty for the rare failure that has no single expected value.
A placeholder that is not one of the three is left written in the output rather than deleted. A mistyped {{fied}} that vanished silently would be a message with a hole in it and no clue why; one that stays is visible before you save.
Echoing the payload back
includePayload adds a payload key to the error body. full returns the body exactly as it arrived. failed-fields returns a flat object whose keys are the full field paths — not a reconstruction of the original nesting:
"payload": { "customer.email": "not-an-email", "items[0].sku": 99 }A field that failed because it was missing has no value to show, so it simply does not appear in that object.
failed-fields is downgraded to none when the detail level is opaque. Its keys are the paths of the fields that failed, so returning it would leak exactly what fields-only leaks, through another door. full is still allowed with opaque, and the difference matters: it returns what the caller themselves just sent, so it can never contain a field name they did not already know.RFC 7807 (problem+json)
Setting envelope: "problem+json" switches the error and timeout bodies to RFC 7807 and sends them with content-type: application/problem+json; charset=utf-8. errors and payload ride along as extension members, which the RFC allows.
{
"type": "about:blank",
"title": "Schema Validation Failed",
"status": 422,
"detail": "2 fields failed validation",
"instance": "/events/6f3a91c2b45d8e0197ac2d64",
"errors": [
{ "path": "customer.email", "message": "must be a valid email" },
{ "path": "amount", "message": "is required" }
]
}The envelope is not applied to the success response. RFC 7807 describes problems; a 200 sent as application/problem+json is a misuse of the media type, and a generated client reading it would look for a title that is not there. The default type is about:blank, which is what the RFC prescribes when there is no document to point at — set problemType to a URL you actually publish.
With detail: "opaque" the 7807 body loses its detail line and its errors array as well. “3 fields failed validation” does not name them, but it does tell a prober how many they guessed right, which is the signal that level exists to cut.
How long the connection is held
20 seconds when only Schema Validators can answer, and it is not configurable. The number is not a preference: it is what makes our answer arrive before the caller's own intermediary hangs up. Off-the-shelf proxies cut at 60 s (nginx) and at 30 s (many load balancers), so a longer wait is never actually collected — what the caller sees is their proxy closing the socket, with no body and no eventId. And 30 is the worst number of all, because it is exactly the floor of those balancers: a race, and if theirs wins the caller gets nothing readable. Shorter would return a 408 on perfectly good requests while a cold container starts. Twenty leaves room for the cold start and still beats the 30-second floor with margin.
Up to 55 seconds when an AI Node can answer — anywhere in the flow, also behind a validator. A model with tools often needs more than twenty, and 55 still arrives before the 60-second cut of an off-the-shelf nginx. Your plan trims it: today that is 20 s on free, 30 s on pro and 55 s on enterprise. If the caller sits behind a 30-second load balancer of their own, that one can still cut first.
A validator does not get a timeout response of its own: a timeout is by definition a wait in which that validator never answered. If one validator had already passed, the caller gets that validator's success response when the wait runs out — the payload was validated, and a 408 would invite a retry of an event that already went in. Only when nothing at all answered does the generic 408 go out.
The account's ceiling is served as maxIngressSyncTimeoutSeconds by GET /api/auth/me/usage, in seconds. It can only trim the wait, never extend it.
maxIngressSyncTimeoutSeconds is not maxFlowCallSyncTimeoutMs. The second one is in the same /usage response, is measured in milliseconds, reaches 300 s on enterprise, and belongs to flow links between workspaces — a different wait, on internal traffic. Reading it here would promise 300 seconds on a connection this gateway cuts at 120.The event id header
Every sync response carries the header X-HostWebhook-Event-Id — success, failure and timeout alike. It is the same name and the same id that HostWebhook sends on the way out when it delivers to your server, so the same event is called the same thing in both directions and an inbound log lines up with an outbound one without translation.
It exists because the body is configurable and the header is not. opaque tells the caller nothing, timeoutBody can be replaced with anything at all — the header is the one place where a reference to open a ticket with is always available. It is not sent in None mode, and not on a response recognised as a repeat delivery.
Two requests, and what comes back
Both are against the same webhook: Sync mode, one Schema Validator downstream, requiring customer.email (a string in email format) and amount (a number). The webhook requires an API key, so the detail level defaults to full.
A payload that validates
curl -sS -D - -X POST https://api.hostwebhook.com/api/in/<webhook-id> \
-H "Content-Type: application/json" \
-H "Authorization: Bearer hwk_..." \
-d '{ "customer": { "email": "ada@example.com" }, "amount": 4200 }'HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
X-HostWebhook-Event-Id: 6f3a91c2b45d8e0197ac2d64
{
"accepted": true,
"eventId": "6f3a91c2b45d8e0197ac2d64"
}This comes back in milliseconds, not when the flow finishes. Everything after the validator — the Slack message, the database write, the delivery to your target URL — is still running when you read it.
A payload that does not
curl -sS -D - -X POST https://api.hostwebhook.com/api/in/<webhook-id> \
-H "Content-Type: application/json" \
-H "Authorization: Bearer hwk_..." \
-d '{ "customer": { "email": "not-an-email" } }'HTTP/1.1 422 Unprocessable Entity
content-type: application/json; charset=utf-8
X-HostWebhook-Event-Id: 6f3a91c2b45d8e0197ac2d64
{
"error": "Schema Validation Failed",
"eventId": "6f3a91c2b45d8e0197ac2d64",
"webhookName": "Orders intake",
"nodeName": "Order schema",
"message": [
"customer.email: must be a valid email",
"amount: is required"
],
"errors": [
{ "path": "customer.email", "message": "must be a valid email" },
{ "path": "amount", "message": "is required" }
]
}The same rejection on the same webhook without the API key requirement — an open ingress, so the detail level defaults to fields-only — says which fields failed and stops there:
{
"error": "Schema Validation Failed",
"eventId": "6f3a91c2b45d8e0197ac2d64",
"webhookName": "Orders intake",
"nodeName": "Order schema",
"message": ["customer.email", "amount"],
"errors": [{ "path": "customer.email" }, { "path": "amount" }]
}And with detail: "opaque", the whole body is:
{
"error": "Invalid payload",
"eventId": "6f3a91c2b45d8e0197ac2d64"
}Gotchas
- The status code is the real one. A rejected payload answers
422, not a200carrying the code inside the body. If you wrote a client against the oldsyncStatusfield, it is gone — read the HTTP status. - Adding a validator to a sync webhook that has a target URL changes what the caller gets. Without one, the answer was the target's own response —
{ deliveryStatus, statusCode, latencyMs, response }. With one, the validator answers first and the caller gets{ accepted, eventId }instead. - A rejected payload still reaches the target URL. The validator gates the pipeline nodes; the primary delivery is queued before it runs.
- An Approval Gate, a waiting Merge or a Delay of 30 s or more answers
202and gives up the wait. Those are “stop waiting for me” answers, not results. Put the validator before them. - A repeat delivery never reaches the flow. It answers
200withdeduplicated: trueand noX-HostWebhook-Event-Idheader, because no run happened. - A Reply-only validator beats a pass and does nothing in None mode. Its reply is final, so a 200 noted by a validator above it never goes out. In None mode there is no connection to answer, so it just passes the payload through.
timeoutBodyreplaces the body, it does not extend it. Nothing is merged in — if you need theeventIdin a custom timeout body, read it from the header.- The plan ceiling is enforced twice. Refused on save with a
403, and trimmed at request time for webhooks that already hold a larger value.