---
title: Debugging a failed FHIR search
description: Read the OperationOutcome a failed FHIR R4 search returns, map each status to its HPB code and fix, and trace the correlation id to the audit record Huli support needs.
nav: Recipes
order: 80
version: v1
source: handwritten
updated: 2026-06-02
---

# Debugging a failed FHIR search

A FHIR search that returns anything other than `200 OK` hands you a structured
`OperationOutcome`. Read it correctly and you resolve most failures yourself in one pass:
the HTTP status names the category, the body carries the Huli code, and the response
headers carry the correlation id you hand to support when the failure is on our side.

## Audience

You integrate against the Huli FHIR API, you already authenticate (admin bearer token
or SMART Backend Services), and you have a search returning a non-`200` status. You read
JSON without a viewer and you want a repeatable triage path instead of a guess.

## You'll need

- A request that reproduces the failure — the exact URL, method, and the token you sent.
- `curl`, or a Go, Python, or Node HTTP client if you prefer a language client.
- The ability to capture **response headers**, not just the body. `curl -i` or `curl -D -`
  prints them; most language clients expose them on the response object.

<Callout variant="info">
Every error from the FHIR API is a FHIR `OperationOutcome`, never a bare string and never
the `{ "error": { "code", "message" } }` shape used by Huli's internal APIs. Decode the
body as JSON and read `issue[0]` — that is where the machine-readable signal lives.
</Callout>

## End state

You can take any failed search, classify it from `issue[0].code` and the `HPB-…` prefix in
`issue[0].diagnostics`, apply the documented fix for that class, and — when the cause is
not on your side — extract the correlation id from the response headers and give support
the four facts they need to find the matching audit record.

## Steps

### 1. Capture the response with its headers

Replay the failing search with headers visible. The `-i` flag prints the status line and
all response headers ahead of the body.

```bash
curl -i "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" \
  -H "Authorization: Bearer $HULI_API_KEY" \
  -H "Accept: application/fhir+json"
```

A failing response looks like this on the wire — status line, headers (correlation id
included), then the `OperationOutcome` body:

```http
HTTP/2 403
content-type: application/fhir+json
x-correlation-id: 01965e9f-2a17-7000-9007-0000000000c4

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "forbidden",
      "diagnostics": "HPB-00104: Insufficient scope"
    }
  ]
}
```

### 2. Read the OperationOutcome

The body is always this shape — `resourceType`, then an `issue` array. Each issue carries
exactly three fields. There is no `details`, no `coding`, no `text`.

```json
{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "forbidden",
      "diagnostics": "HPB-00104: Insufficient scope"
    }
  ]
}
```

Two fields drive your triage:

- `issue[0].code` — the FHIR `IssueType`, derived from the HTTP status: `400` → `invalid`,
  `401` → `security`, `403` → `forbidden`, `404` → `not-found`, `409` → `conflict`,
  `429` → `throttled`, `5xx` → `exception`.
- `issue[0].diagnostics` — a human-readable message prefixed with the Huli code. The
  `HPB-…` code is the prefix; split on `": "` to extract it.

Pull both apart programmatically rather than substring-matching the whole sentence. Split
on the first `": "`: the head is the `HPB-…` code, the tail is the message. If the string
carries no `": "`, treat that as a non-conforming body (see "What can go wrong") rather
than assuming the whole sentence is the code.

:::CodeGroup

```typescript
const url = new URL('https://api.huli.ai/fhir/R4/Patient');
url.searchParams.set('name', 'Fernández');
url.searchParams.set('_count', '20');

const resp = await fetch(url, {
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    Accept: 'application/fhir+json',
  },
});

if (resp.status !== 200) {
  const outcome = (await resp.json()) as {
    issue: { code: string; diagnostics: string }[];
  };
  const issue = outcome.issue[0];
  const fhirCode = issue.code; // e.g. "forbidden"
  const diagnostics = issue.diagnostics; // "HPB-00104: Insufficient scope"
  const sep = diagnostics.indexOf(': ');
  // No "HPB-...: " prefix — unexpected/non-conforming body.
  const hpbCode = sep === -1 ? '' : diagnostics.slice(0, sep);
  const message = sep === -1 ? diagnostics : diagnostics.slice(sep + 2);
  const correlationId = resp.headers.get('X-Correlation-Id');
  const retryAfter = resp.headers.get('Retry-After'); // set only on 429

  console.log(resp.status, fhirCode, hpbCode, message);
  console.log('correlation id:', correlationId);
  if (retryAfter) {
    console.log('retry after (s):', retryAfter);
  }
}
```

```python
import requests

resp = requests.get(
    "https://api.huli.ai/fhir/R4/Patient",
    params={"name": "Fernández", "_count": 20},
    headers={
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/fhir+json",
    },
    timeout=30,
)

if resp.status_code != 200:
    outcome = resp.json()
    issue = outcome["issue"][0]
    fhir_code = issue["code"]                       # e.g. "forbidden"
    diagnostics = issue["diagnostics"]              # "HPB-00104: Insufficient scope"
    hpb_code, sep, message = diagnostics.partition(": ")
    if not sep:
        # No "HPB-...: " prefix — unexpected/non-conforming body.
        hpb_code, message = "", diagnostics
    correlation_id = resp.headers.get("X-Correlation-Id")
    retry_after = resp.headers.get("Retry-After")   # set only on 429

    print(resp.status_code, fhir_code, hpb_code, message)
    print("correlation id:", correlation_id)
    if retry_after:
        print("retry after (s):", retry_after)
```

```java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

public class ClassifyFhirSearch {

    public static void main(String[] args) throws Exception {
        // Let the client percent-encode the accent — never pre-encode here.
        String name = URLEncoder.encode("Fernández", StandardCharsets.UTF_8);
        URI uri = URI.create(
            "https://api.huli.ai/fhir/R4/Patient?name=" + name + "&_count=20");

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
            .uri(uri)
            .header("Authorization", "Bearer " + System.getenv("HULI_API_KEY"))
            .header("Accept", "application/fhir+json")
            .GET()
            .build();

        HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() != 200) {
            String body = response.body();
            // Hand-parse the two fields we triage on; a real client would
            // use a JSON library to read issue[0].code / .diagnostics.
            String fhirCode = extract(body, "code");          // e.g. "forbidden"
            String diagnostics = extract(body, "diagnostics"); // "HPB-00104: Insufficient scope"
            int sep = diagnostics.indexOf(": ");
            // No "HPB-...: " prefix — unexpected/non-conforming body.
            String hpbCode = sep == -1 ? "" : diagnostics.substring(0, sep);
            String message = sep == -1 ? diagnostics : diagnostics.substring(sep + 2);

            String correlationId =
                response.headers().firstValue("X-Correlation-Id").orElse(null);
            String retryAfter = // set only on 429
                response.headers().firstValue("Retry-After").orElse(null);

            System.out.println(response.statusCode() + " " + fhirCode
                + " " + hpbCode + " " + message);
            System.out.println("correlation id: " + correlationId);
            if (retryAfter != null) {
                System.out.println("retry after (s): " + retryAfter);
            }
        }
    }

    // Minimal value lookup for a flat "key":"value" — illustrative only.
    static String extract(String json, String key) {
        String needle = "\"" + key + "\":\"";
        int start = json.indexOf(needle);
        if (start == -1) {
            return "";
        }
        start += needle.length();
        int end = json.indexOf('"', start);
        return end == -1 ? "" : json.substring(start, end);
    }
}
```

```go
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
)

type operationOutcome struct {
	ResourceType string `json:"resourceType"`
	Issue        []struct {
		Severity    string `json:"severity"`
		Code        string `json:"code"`
		Diagnostics string `json:"diagnostics"`
	} `json:"issue"`
}

func classify(resp *http.Response) {
	body, _ := io.ReadAll(resp.Body)
	defer resp.Body.Close()

	var oo operationOutcome
	if err := json.Unmarshal(body, &oo); err != nil || len(oo.Issue) == 0 {
		fmt.Printf("non-OperationOutcome body (status %d): %s\n", resp.StatusCode, body)
		return
	}

	issue := oo.Issue[0]
	hpbCode, message, found := strings.Cut(issue.Diagnostics, ": ")
	if !found {
		// No "HPB-...: " prefix — unexpected/non-conforming body.
		hpbCode, message = "", issue.Diagnostics
	}

	fmt.Printf("status=%d fhirCode=%s hpb=%s message=%q\n",
		resp.StatusCode, issue.Code, hpbCode, message)
	fmt.Printf("correlation-id=%s\n", resp.Header.Get("X-Correlation-Id"))
	if ra := resp.Header.Get("Retry-After"); ra != "" {
		fmt.Printf("retry-after=%s\n", ra)
	}
}
```

:::

### 3. Map the failure to its fix

Match on the status plus the `HPB-…` code, then apply the fix. These four cover the
overwhelming majority of failed searches.

<StatusBadge code="401" /> `security` / `HPB-00106` — auth failed. The token is missing,
malformed, or revoked. Confirm the header reads `Authorization: Bearer <token>` with a
single space, and that the variable holding it is actually populated in this shell. A
second `401`, `HPB-00107` (auth expired), means a SMART Backend Services access token has
passed its 5-minute TTL — mint a fresh one and retry. An admin bearer token does not
expire on a timer, so `HPB-00107` against an admin key usually means a SMART access token
is being sent on a request you intended to authenticate with the admin key.

<StatusBadge code="403" /> `forbidden` / `HPB-00104` — insufficient scope. The token
authenticated but lacks the scope this search needs. A `Patient` name search needs
<Scope name="system/Patient.rs" />; a write needs <Scope name="system/Patient.cru" />.
Re-mint the key in **Practice Settings → Integrations → API Keys** with the read+search
(`rs`) scope selected for every resource you query.

<StatusBadge code="400" /> `invalid` / `HPB-00101` — validation error. A search parameter
is malformed or unknown — most often an un-encoded accent or a typo'd parameter name.
URL-encode `á` as `%C3%A1`, and check each parameter against the search reference for that
resource. Note that hand-built `curl` URLs need the literal `%C3%A1`, while language
clients percent-encode for you — passing a pre-encoded value into a client library
double-encodes it and the search matches nothing.

<StatusBadge code="429" /> `throttled` / `HPB-00105` — rate limited. You exceeded the
per-key request budget. Read the `Retry-After` response header (in seconds) and back off
for that long before retrying. Add jitter if several workers share one key.

### 4. Surface the correlation id

When the failure is on Huli's side — a `5xx` `exception`, or a `4xx` whose fix you have
already applied and which still fails — the correlation id ties your request to our
server-side record. It is on the response, not the body:

```bash
# Print only the correlation id from a failing request
curl -s -o /dev/null -D - \
  "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez" \
  -H "Authorization: Bearer $HULI_API_KEY" \
  -H "Accept: application/fhir+json" \
  | grep -i '^x-correlation-id:'
```

In a language client, read the `X-Correlation-Id` response header (shown in the Python and
Go samples in step 2). Capture and log it on every non-`200` response as a matter of
course — it is the single fastest way for support to locate your request, and it is gone
once the response is discarded.

### 5. Hand support the audit facts

Each FHIR search Huli serves writes an audit record server-side, keyed by the same
correlation id you captured. Support locates the matching record from the facts you
provide — there is no client-facing audit-lookup endpoint, so give them everything needed
to find it on the first try:

- The **correlation id** from `X-Correlation-Id`.
- The **UTC timestamp** of the request, ISO-8601 with offset (e.g. `2026-06-02T14:22:09-06:00`).
- The **exact URL** you called, including query parameters (redact nothing — the audit
  record stores the search parameters and they must match).
- The **`client_id`** of the credential you authenticated with (the API key's client
  identifier, not the secret).

<Callout variant="warning">
Never send your bearer token, client secret, or private key to support. The `client_id`
and correlation id are sufficient to locate the record; the secret material is not needed
and must not leave your environment.
</Callout>

## What to verify

- The response body parses as JSON and `resourceType` is `OperationOutcome`.
- `issue[0].code` matches the HTTP status per the mapping in step 2.
- The `HPB-…` prefix you split out of `issue[0].diagnostics` matches the status
  (`HPB-00101`/400, `HPB-00104`/403, `HPB-00105`/429, `HPB-00106`/401, `HPB-00107`/401).
- On a `429`, a `Retry-After` header is present and you honored it before retrying.
- You captured `X-Correlation-Id` before discarding the response.

## What can go wrong

- **Substring-matching the whole `diagnostics` sentence.** The message text can change;
  the `HPB-…` prefix and `issue[0].code` are the stable contract. Split on `": "` and
  branch on the code, not the prose.
- **A `diagnostics` string with no `HPB-…: ` prefix.** Every conforming error leads with
  `HPB-…: `. If your split finds no `": "` separator, you are looking at a non-conforming
  or unexpected body (a proxy error page, a truncated response) — fall back to the HTTP
  status and the correlation id rather than treating the whole string as a code.
- **Looking for `issue.details` or a `coding` array.** Neither exists on this API. The
  only fields on an issue are `severity`, `code`, and `diagnostics`.
- **Reading the correlation id from the request instead of the response.** The id is
  assigned server-side and returned on the `X-Correlation-Id` _response_ header. If you
  only logged the request, you have nothing to give support.
- **Retrying a `429` immediately.** Without honoring `Retry-After` you compound the
  throttle. Back off for the advertised seconds, then retry.
- **Treating `HPB-00107` (auth expired) as `HPB-00106` (auth failed).** Expired means the
  credential was valid and timed out — refresh the SMART access token rather than
  re-checking the key. The fixes differ.
- **Mixing up the token and discovery hosts.** The token endpoint is host-rooted at
  `POST https://api.huli.ai/auth/token`, while SMART discovery and JWKS are issuer-rooted
  under `/fhir` (`https://api.huli.ai/fhir/.well-known/smart-configuration` and
  `https://api.huli.ai/fhir/.well-known/jwks.json`). A `401` that resists every credential
  fix is often a request sent to the wrong path.

## Next recipes

- **Run your first authenticated Patient search** — the green-path single request these
  failures are the inverse of.
- **Authenticate as a SMART Backend Service** — `client_credentials` + `private_key_jwt`
  (RS384) and the 5-minute token lifecycle behind `HPB-00107`.
- **Paginate a large patient list** — follow the `Bundle.link` entry with
  `relation: "next"` once your search returns `200`.
