Built an n8n workflow that stops treating HTTP 200 as a useful web-search result

We kept running into the same failure mode when wiring web search into n8n workflows: the HTTP Request node returns 200, the run continues, and something breaks three nodes later. The payload had no `organic` array, or it had two results where the next node assumed five, or a `link` that was a `javascript:` string. The status code said the call worked. Nothing in the workflow said the result was usable.

We built a small importable workflow that keeps those two questions apart, and we’re sharing it because the shape of the problem is not specific to any one search provider.

What it does

Manual Trigger -> Configure -> Primary Request -> Validate Primary -> Primary Valid?
|-- true --> Primary Receipt
`-- false --> Fallback Request -> Validate Fallback -> Fallback Receipt


Nine nodes. The two HTTP Request nodes POST `{“q”: “…”}` to a search endpoint. The two `Validate` nodes are Code nodes running the same deterministic validator source and byte-identical apart from the attempt number each one tags its result with. No model, no agent node, nothing that could return a different answer on a second run. The IF node branches on one boolean the validator produced.

What counts as valid

The validator checks transport first, then structure, and stops at the first failure with an explicit reason rather than a generic “failed”:

  • Transport: no item, request error, missing status, or a status outside 200–299.
  • Structure: body is not a JSON object; `organic` missing or not an array; a row that is not an object; a row missing a non-empty string `title`, `link` or `snippet`; a `link` that is not `http:` or `https:`; a `link` that cannot be parsed, including one that embeds userinfo.
  • Sufficiency: fewer than `minResults` unique links survived.

Duplicates collapse before the minimum is applied. Two links are the same target when they match after lowercasing scheme and host, dropping a default port, dropping the fragment and dropping a trailing slash. Query strings stay significant, so `?a=1` and `?a=2` remain two results. This matters more than it sounds: a response with six rows pointing at four distinct pages fails a minimum of five, and it fails for a reason you can read.

Setting `minResults` to a number rather than “more than zero” was the part that took us longest to settle. It comes out of the same habit as the rest of our web-access testing (deciding in advance what counts as a usable result, then holding the response to it) which is described in [the methodology behind NativePort’s web-access benchmarks] ( How we measure — NativePort ) if the framing is useful.

Bounded, and fail-closed

rejected primary triggers exactly one fallback request, with a different query configured up front. There is no second fallback, no retry loop, no backoff. If the fallback is also rejected, the run finishes normally with `accepted: false`, it does not throw, and it does not fall through to a “close enough” result.

Both HTTP nodes are set to Never Error plus Continue (using regular output) with full response enabled, so a 500, a timeout or an HTML error page arrives at the validator as data and gets judged, instead of aborting the execution before anything can decide about it.

The receipt

Both receipt nodes emit the same seven fields and nothing else:

```json 

"provider": "nativeport",
"attempts": 2,
"accepted": false,
"elapsedMs": 994,
"resultCount": 2,
"validation": {
"valid": false,
"reason": "insufficient_results",
"transportOk": true,
"httpStatus": 200,
"duplicatesRemoved": 1,
"minResults": 5,
"failedIndex": null,
"failedField": null
},
"fallbackReason": "transport_http_status"
}
```

`fallbackReason` is the primary attempt’s reason why a second attempt happened at all, and is `null` when the primary was accepted. There is no response body, no headers, no query text and no credential material in it, which is what makes it safe to log on every run.

Importing it

1. Workflows → Import from File, select the attached JSON. It ships with no credential attached, so it imports on a fresh instance.
2. Credentials → New → Header Auth. Name it `NativePort API key`, header name `Authorization`, header value `Bearer [REDACTED]`.
3. Open Primary Request and Fallback Request and select that credential in each. Both are set to Generic Credential Type → Header Auth; the file itself contains no key, no header value and no credential id.
4. Edit `primaryQuery`, `fallbackQuery` and `minResults` in the Configure node. Nothing else references them.

To use a different provider, change the URL in both HTTP nodes and adjust the field names in the validator; the structure of the flow does not depend on the vendor.

Known limitations

- The receipt is a decision record and carries no results. Downstream nodes that need the rows read them from the HTTP node of the accepted attempt.

  • One malformed row rejects the whole attempt rather than being dropped. Deliberate, but a single bad row can cost you a fallback.
  • Only `organic` is validated. Answer boxes and knowledge panels are ignored, so a response that answers in a different field still counts as unusable.
  • Deduplication is by normalised URL, not content, and links are never fetched to confirm they resolve.
  • There is no relevance judgement here at all. The validator can tell you results are present and well formed; it cannot tell you they answer the query.
  • Timeouts are per request (20 s each), so a worst case is roughly two of them.

Disclosure

We’re the NativePort team, and NativePort is used as the API access layer in this example. The example endpoint in the file is `https://api.nativeport.ai/serper/search`, and the same workflow works against any endpoint returning that response shape.

We’d be interested in how others are drawing this line in n8n. Specifically: do you validate provider responses in a Code node like this, in a Switch/Filter chain, or not at all until something downstream breaks? And if you allow a fallback, what stops it from becoming a retry loop?
````