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 -iorcurl -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 FHIRIssueType, 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. TheHPB-…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.
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.
forbidden / HPB-00104 — insufficient scope. The tokenauthenticated but lacks the scope this search needs. A Patient name search needs
Re-mint the key in Practice Settings → Integrations → API Keys with the read+search (rs) scope selected for every resource you query.
invalid / HPB-00101 — validation error. A search parameteris 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.
throttled / HPB-00105 — rate limited. You exceeded theper-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_idof the credential you authenticated with (the API key's client identifier, not the secret).
What to verify
- The response body parses as JSON and
resourceTypeisOperationOutcome. issue[0].codematches the HTTP status per the mapping in step 2.- The
HPB-…prefix you split out ofissue[0].diagnosticsmatches the status (HPB-00101/400,HPB-00104/403,HPB-00105/429,HPB-00106/401,HPB-00107/401). - On a
429, aRetry-Afterheader is present and you honored it before retrying. - You captured
X-Correlation-Idbefore discarding the response.
What can go wrong
- Substring-matching the whole
diagnosticssentence. The message text can change; theHPB-…prefix andissue[0].codeare the stable contract. Split on": "and branch on the code, not the prose. - A
diagnosticsstring with noHPB-…:prefix. Every conforming error leads withHPB-…:. 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.detailsor acodingarray. Neither exists on this API. The only fields on an issue areseverity,code, anddiagnostics. - Reading the correlation id from the request instead of the response. The id is assigned server-side and returned on the
X-Correlation-Idresponse header. If you only logged the request, you have nothing to give support. - Retrying a
429immediately. Without honoringRetry-Afteryou compound the throttle. Back off for the advertised seconds, then retry. - Treating
HPB-00107(auth expired) asHPB-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-configurationandhttps://api.huli.ai/fhir/.well-known/jwks.json). A401that 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 behindHPB-00107. - Paginate a large patient list — follow the
Bundle.linkentry withrelation: "next"once your search returns200.