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.

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.

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/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.

{
  "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: 400invalid, 401security, 403forbidden, 404not-found, 409conflict, 429throttled, 5xxexception.
  • 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.

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);
  }
}
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)
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);
    }
}
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.

401 Unauthorized 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.

403 Forbidden forbidden / HPB-00104 — insufficient scope. The token

authenticated but lacks the scope this search needs. A Patient name search needs

system/Patient.rs; a write needs 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.

400 Bad Request 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.

429 Too Many Requests 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:

# 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).

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 Serviceclient_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.