Choosing a CLI authentication path

The huli CLI talks to the same FHIR R4 surface three different ways, and the right one depends on who runs it and how long it runs. Pick the path first, then copy the one command that matches. This recipe maps each workload to a path, gives you the exact invocation, and names the failures that tell you the path was wrong.

Audience

You wire integrations against the Huli FHIR API and you are about to script the huli CLI into something — a developer's laptop, a cron job, a CI pipeline, or a quick one-shot from a shell. You know what a bearer token is and you have read the FHIR base URL at least once.

You'll need

  • The huli CLI on your PATH, with huli auth setup available (run huli --help to confirm the binary resolves).
  • For the M2M path: an api_key registered with a JWKS URI, an RS384 signing key whose public half is published at that JWKS URI, and the scopes the workload needs. An admin on your organization registers the key in Practice Settings → Integrations → API Keys.
  • For the one-off path: an admin bearer token, minted once in the same place and shown once. It is long-lived and scoped to one organization.
  • The base host https://api.huli.ai and the FHIR base https://api.huli.ai/fhir/R4/. The token endpoint is host-rooted at https://api.huli.ai/auth/token, not under /fhir.

End state

You have chosen one of three paths and run one authenticated request through it. The CLI holds credentials in the shape that path expects — a stored M2M profile, an interactive session, or a bearer string passed per command — and a Patient search returns a searchset Bundle instead of an OperationOutcome.

Steps

1. Match your workload to a path

Read the row that describes who runs the CLI and how often, then jump to that path's command below.

WorkloadPathCLI surfaceCredential lifetimeStatus
A human running the CLI interactively from a laptop, acting as themselvesInteractive OAuth (Auth-Code + PKCE)huli auth loginShort-lived session, refreshed in the backgroundNo CLI subcommand today
An unattended server, cron job, or CI pipeline acting as a service, not a personSMART Backend Services (client_credentials + private_key_jwt, RS384)huli auth setup5-minute access token, re-minted automatically from your signing keyAvailable
A one-shot command, a debugging session, or a script that already holds an admin keyAdmin bearer token passed per command--token flag or Authorization headerLong-lived admin key, no refreshAvailable

The dividing questions, in order:

  1. Is a person sitting at the keyboard, and do you want requests attributed to that person? That is interactive OAuth — a v1 API auth mode the CLI has no subcommand for today, so fall through to one of the next two.
  2. Is the caller a service running without a human present? That is huli auth setup.
  3. Is this a single throwaway call, or do you already hold an admin bearer token? Pass it per command with --token.

2a. Interactive OAuth — no CLI subcommand today

Treat the interactive row in the decision table as a signpost, not a CLI instruction: for CLI access today, use M2M (huli auth setup, step 2b) or an admin bearer token (step 2c).

2b. M2M — huli auth setup (client_credentials + private_key_jwt)

This is the path for any unattended caller. The CLI signs a private_key_jwt client assertion with your RS384 key, exchanges it at the token endpoint for a 5-minute access token, and re-mints that token automatically as it expires.

Run the one-time setup:

huli auth setup \
  --base-url https://api.huli.ai \
  --client-id clinica-san-rafael-integration \
  --jwks-uri https://integrations.clinica-san-rafael.example/jwks.json \
  --private-key ./san-rafael-signing-key.pem \
  --scope "system/Patient.rs system/Appointment.rs"

The flags map one-to-one onto the SMART Backend Services handshake:

  • --base-url is the host root. The CLI derives the token endpoint as https://api.huli.ai/auth/token and discovery as https://api.huli.ai/fhir/.well-known/smart-configuration from it.
  • --client-id is the sub of your signed assertion and identifies the api_key row.
  • --jwks-uri must match the JWKS URI registered on that api_key — the server fetches your public key from there to verify the RS384 signature.
  • --private-key points at the RS384 private key whose public half lives at that JWKS URI.
  • --scope is the space-separated set of scopes to request. Request only what the workload uses.

Once setup completes, every CLI command authenticates from the stored profile — no token flag, no header. Run any read command (a Patient search filtered by name, for example) and behind it the CLI POSTs the assertion to https://api.huli.ai/auth/token, receives an RS384 access token valid for 5 minutes, and attaches it as a bearer on the FHIR request. When the token expires, the next command re-mints it from your key — you never handle the access token yourself.

Pick scopes from the v1 set. Letters are r=read, s=search, c=create, u=update. Read plus search is .rs; full write is .cru.

  • Read + write resources: system/Patient.cru,system/Appointment.cru, system/Encounter.cru,system/Observation.cru.
  • Read-only resources: system/Practitioner.rs,system/Organization.rs.
  • Provenance is create plus read plus search only, client-POSTed:system/Provenance.rs together withsystem/Provenance.c.

2c. One-off — admin bearer token via --token or header

For a single call, a debugging session, or a script that already holds an admin key, skip the stored profile and pass the token per command. The admin bearer token comes from Practice Settings → Integrations → API Keys, is shown once, and is long-lived.

Export it so it never lands in shell history:

export HULI_API_KEY="<paste your admin bearer token here>"

Pass it to the CLI with the --token flag on any read command, or — calling the API directly rather than through the CLI — ride the same token in the Authorization header with one space after Bearer:

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

What to verify

  • For the M2M path: huli auth setup exits cleanly, and a follow-up read command (a Patient search by name) with no --token flag returns a Bundle of type searchset. That proves the stored profile minted a token without you handling it.
  • For the one-off path: the same read with --token "$HULI_API_KEY" returns a searchset Bundle. Status is 200. 200 OK
  • Either way, the response resourceType is Bundle and type is searchset — not OperationOutcome.
  • On the M2M path, you requested only the scopes the workload uses. A read-only reporting job should not request .cru on any resource.

What can go wrong

Every failure comes back as a FHIR OperationOutcome with exactly this shape — no details, no coding, no text:

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "security",
      "diagnostics": "HPB-00106: Authentication failed"
    }
  ]
}

Classify machine-side on the HTTP status and issue[0].code (the FHIR IssueType: 400invalid, 401security, 403forbidden, 404not-found, 409conflict, 429throttled, 5xxexception). The Huli code is the prefix of issue[0].diagnostics — split on ": " to read it. The five you will hit choosing a path:

401 Unauthorized HPB-00106 — auth failed. The credential is missing,

malformed, or revoked. On the M2M path, the assertion signature did not verify — confirm --jwks-uri matches the URI registered on the api_key and that --private-key is the RS384 key whose public half sits there. On the one-off path, confirm the header reads Authorization: Bearer <token> with a single space and that $HULI_API_KEY is exported in this shell.

401 Unauthorized HPB-00107 — auth expired. You presented an access token past

its lifetime. This is specific to the 5-minute M2M token: it means a cached token outlived its window. With huli auth setup the CLI re-mints automatically, so seeing this usually means you pinned a raw token by hand instead of letting the stored profile refresh it. You will not see this from an admin bearer token, which does not expire on a timer.

403 Forbidden HPB-00104 — insufficient scope. The credential authenticated

but lacks the scope the request needs. On the M2M path, widen --scope and re-run huli auth setup (and confirm the api_key is allowed those scopes). On the one-off path, confirm the admin key was granted the scopes the request needs; if not, mint a new key in Practice Settings → Integrations → API Keys.

400 Bad Request HPB-00101 — validation error. A request parameter is

malformed — most often an un-encoded accent in a hand-built URL. Encode á as %C3%A1, or let the CLI and HTTP clients encode the raw string for you.

429 Too Many Requests HPB-00105 — rate limited. You exceeded the per-key request

budget. Read the Retry-After response header and back off for that many seconds before retrying. Unattended M2M jobs should honor Retry-After rather than tight-looping.

Next recipes

  • Run your first authenticated Patient search — one round-trip against the FHIR API with an admin bearer token, and the four errors you hit first.
  • Authenticate as a SMART Backend Service — the full client_credentials + private_key_jwt (RS384) handshake under the hood, for when you want to build the token exchange yourself instead of letting the CLI drive it.
  • Paginate a large patient list — follow the Bundle.link entry whose relation is next to walk every page of a searchset, whichever auth path you chose here.