Oystehr
Async FHIR Interactions

Async FHIR Interactions

The Oystehr FHIR API supports the FHIR Asynchronous Interaction Request Pattern (opens in a new tab), which allows you to execute FHIR operations asynchronously. This is useful for long-running operations where you don't want to wait for the response to complete before continuing.

When a request is accepted asynchronously, the API returns immediately with a job reference. You can then poll for the result at your own pace or cancel the job if it is no longer needed.

Supported Interactions

The following FHIR interactions can be executed asynchronously:

InteractionHTTP MethodEndpoint
SearchGET /{ResourceType} or POST /{ResourceType}/_searchSearch for resources
ReadGET /{ResourceType}/{id}Read a single resource
UpdatePUT /{ResourceType}/{id}Update a resource
PatchPATCH /{ResourceType}/{id}Apply JSON Patch operations
DeleteDELETE /{ResourceType}/{id}Delete a resource
HistoryGET /{ResourceType}/{id}/_historyGet resource history
Version ReadGET /{ResourceType}/{id}/_history/{versionId}Read a specific version
Batch/TransactionPOST /Execute a batch or transaction bundle

Starting an Async Request

To make any supported FHIR request asynchronous, add the Prefer: respond-async header to your request. If accepted, the API returns:

  • HTTP 202 Accepted
  • A Content-Location header containing the URL for polling the job status, e.g. https://fhir-api.zapehr.com/r4/async-job/{jobId}

Start an async search for Patient resources with the v3 SDK:

import { Patient } from 'fhir/r4b';
import Oystehr from '@oystehr/sdk';
 
const oystehr = new Oystehr({
  accessToken: '<your_access_token>',
});
 
// Start an async search
const handle = await oystehr.fhir.search<Patient>(
  {
    resourceType: 'Patient',
    params: [{ name: 'name', value: 'Jon' }],
  },
  { mode: 'async-bundle' }
);
 
console.log(handle.jobId);            // The async job ID
console.log(handle.contentLocation);  // Full polling URL

Async Read

const handle = await oystehr.fhir.get<Patient>(
  {
    resourceType: 'Patient',
    id: '2419a78e-4c0a-411d-b9e2-90dc081f5efa',
  },
  { mode: 'async-bundle' }
);

Async Update

const handle = await oystehr.fhir.update<Patient>(
  {
    resourceType: 'Patient',
    id: '2419a78e-4c0a-411d-b9e2-90dc081f5efa',
    active: true,
    name: [{ given: ['Aegon'], family: 'Targaryen' }],
  },
  { mode: 'async-bundle' }
);

Async Delete

const handle = await oystehr.fhir.delete<Patient>(
  {
    resourceType: 'Patient',
    id: '2419a78e-4c0a-411d-b9e2-90dc081f5efa',
  },
  { mode: 'async-bundle' }
);

Async History

// Full history for a resource
const handle = await oystehr.fhir.history(
  {
    resourceType: 'Patient',
    id: '2419a78e-4c0a-411d-b9e2-90dc081f5efa',
  },
  { mode: 'async-bundle' }
);
 
// Specific version read
const versionHandle = await oystehr.fhir.history(
  {
    resourceType: 'Patient',
    id: '2419a78e-4c0a-411d-b9e2-90dc081f5efa',
    versionId: '6b43753b-08f1-4c71-afa8-c83704e42aab',
  },
  { mode: 'async-bundle' }
);

Async Batch/Transaction

// Async batch
const batchHandle = await oystehr.fhir.batch<Patient>(
  {
    requests: [
      { method: 'GET', url: '/Patient?name=Jon' },
      { method: 'GET', url: '/Patient?name=Arya' },
    ],
  },
  { mode: 'async-bundle' }
);
 
// Async transaction
const txHandle = await oystehr.fhir.transaction<Patient>(
  {
    requests: [
      {
        method: 'POST',
        url: '/Patient',
        resource: {
          resourceType: 'Patient',
          active: true,
          name: [{ given: ['Sansa'], family: 'Stark' }],
        },
      },
    ],
  },
  { mode: 'async-bundle' }
);

Polling for Results

Once you have started an async job, poll the job status endpoint to check whether the job is complete.

GET /r4/async-job/{jobId}

Poll for async job completion with the v3 SDK:

// Option 1: Poll manually
const status = await oystehr.fhir.getAsyncJob<Patient>(handle.jobId);
 
if (status.status === 202) {
  console.log('Still processing...');
}
 
if (status.status === 200 && status.mode === 'bundle') {
  const entry = status.bundle.entry?.[0];
  console.log(entry?.response?.status); // e.g. "200 OK"
  console.log(entry?.resource);         // The resulting resource
}
 
// Option 2: Wait for completion with automatic polling
const result = await oystehr.fhir.waitForAsyncJob<Patient>(handle.jobId, {
  pollIntervalMs: 1000,   // Poll every 1 second
  timeoutMs: 600000,      // Timeout after 10 minutes
});

Polling Responses

The polling endpoint returns different responses depending on the job status:

In Progress (202 Accepted)

While the job is still processing, the endpoint returns 202 Accepted with an X-Progress header:

HTTP/1.1 202 Accepted
X-Progress: processing

Completed (200 OK)

When the job completes successfully, the endpoint returns 200 OK with a completion Bundle of type batch-response. The result of the original interaction is in the first entry:

{
  "resourceType": "Bundle",
  "type": "batch-response",
  "entry": [
    {
      "response": {
        "status": "200 OK"
      },
      "resource": {
        "resourceType": "Bundle",
        "type": "searchset",
        "entry": [
          {
            "resource": {
              "resourceType": "Patient",
              "id": "2419a78e-4c0a-411d-b9e2-90dc081f5efa",
              "name": [{ "given": ["Jon"], "family": "Snow" }]
            }
          }
        ]
      }
    }
  ]
}

The entry[0].response.status reflects the status of the original FHIR interaction:

Original Interactionentry[0].response.statusentry[0].resource
Search / History200 OKBundle (searchset or history)
Read / Version Read200 OKThe requested resource
Update200 OKThe updated resource
Delete204 No ContentNot present
Batch / Transaction200 OKBundle (batch-response)

Expired (410 Gone)

If the result TTL has expired or the stored result is no longer available:

HTTP/1.1 410 Gone

Not Found (404 Not Found)

If the job was deleted, canceled, or does not exist:

HTTP/1.1 404 Not Found

Canceling an Async Job

You can cancel an in-progress job or clean up a completed job by sending a DELETE request:

DELETE /r4/async-job/{jobId}
await oystehr.fhir.cancelAsyncJob(handle.jobId);

The API responds with 202 Accepted.

Cancellation stops processing and marks the job as canceled before or after major operations. It does not force-stop in-flight database operations or roll back already-committed work.

Async Bulk Output Mode

By default, async jobs return results in bundle mode — the completed job response is a FHIR batch-response Bundle as shown above.

Alternatively, you can request results in bulk mode, which returns output as NDJSON (opens in a new tab) files grouped by resource type. This is useful when you expect large result sets and want to process results as a stream of individual resources.

To request bulk output mode, add the _outputFormat=application/fhir+ndjson query parameter to your request along with the Prefer: respond-async header.

// The SDK handles the _outputFormat parameter automatically when mode is 'async-bulk'
const handle = await oystehr.fhir.search<Patient>(
  {
    resourceType: 'Patient',
    params: [{ name: 'name', value: 'Jon' }],
  },
  { mode: 'async-bulk' }
);
 
const status = await oystehr.fhir.waitForAsyncJob(handle.jobId, {
  pollIntervalMs: 1000,
  timeoutMs: 600000,
});
 
if (status.status === 200 && status.mode === 'bulk') {
  // Bulk manifest with NDJSON file URLs
  console.log(status.manifest.transactionTime);
  console.log(status.manifest.output);
  // [{ type: 'Patient', url: 'https://...' }]
 
  // Fetch and parse NDJSON output files.
  for (const file of status.manifest.output) {
    const response = await fetch(file.url, {
      headers: status.manifest.requiresAccessToken ? { Authorization: 'Bearer <your_access_token>' } : undefined,
    });
 
    if (!response.ok) {
      throw new Error(`Failed to download bulk output (${file.type}): HTTP ${response.status}`);
    }
 
    const ndjson = await response.text();
    const resources = ndjson
      .split('\n')
      .filter((line) => line.trim().length > 0)
      .map((line) => JSON.parse(line));
 
    console.log(file.type, resources.length);
 
    if (file.type === 'Patient') {
      const patient = resources.find((resource) => resource.resourceType === 'Patient');
      console.log('Found Patient:', patient?.id);
    }
  }
}

Bulk Mode Completion Response

When a bulk mode job completes, polling returns 200 OK with a manifest instead of a Bundle:

{
  "transactionTime": "2024-01-15T10:30:00.000Z",
  "request": "/Patient",
  "requiresAccessToken": true,
  "output": [
    {
      "type": "Patient",
      "url": "https://presigned-s3-url/Patient.ndjson"
    }
  ],
  "error": []
}

Each entry in the output array contains:

  • type — the FHIR resource type contained in the file
  • url — a pre-signed URL to download the NDJSON file

The NDJSON file contains one JSON-encoded FHIR resource per line.

Bulk Mode Error Response

If the async job fails in bulk mode, the polling endpoint returns an OperationOutcome directly:

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "not-found",
      "details": { "text": "Not found" }
    }
  ]
}

Supported Interactions for Bulk Output

All async-supported interactions can be used with bulk output mode:

  • Search — results are grouped by resource type into separate NDJSON files
  • Read / Update / Version Read — the result resource is returned as a single-line NDJSON file
  • History — history entries are grouped by resource type
  • Batch / Transaction — all response resources are grouped by resource type across all entries
  • Delete — returns an empty manifest (no output files)

Complete Workflow Example

Here is a complete example showing the async workflow end-to-end:

import { Patient } from 'fhir/r4b';
import Oystehr from '@oystehr/sdk';
 
const oystehr = new Oystehr({
  accessToken: '<your_access_token>',
});
 
// 1. Start an async search
const handle = await oystehr.fhir.search<Patient>(
  {
    resourceType: 'Patient',
    params: [{ name: 'name', value: 'Jon' }],
  },
  { mode: 'async-bundle' }
);
 
console.log(`Async job started: ${handle.jobId}`);
 
// 2. Wait for the result (polls automatically)
const result = await oystehr.fhir.waitForAsyncJob<Patient>(handle.jobId, {
  pollIntervalMs: 2000,
  timeoutMs: 300000,
});
 
// 3. Process the result
if (result.status === 200 && result.mode === 'bundle') {
  console.log(`Interaction status: ${result.interactionStatus}`);
  console.log(`Result resource:`, result.resource);
}