API reference
Base URL: https://birthtracks.twosportday.com
The full machine-readable contract is published as an OpenAPI 3.1 spec at
/openapi.json. It is the
source of truth for the endpoints below — rather than maintain a separate
collection that can drift, import the spec directly:
- Postman / Insomnia — Import → URL →
https://birthtracks.twosportday.com/openapi.json; both build a ready-to-run request collection from it. - Generate a client — point any OpenAPI generator at the spec, e.g.
openapi-generator-cli generate -i https://birthtracks.twosportday.com/openapi.json -g python.
Authentication
Section titled “Authentication”All endpoints require a per-source registry API key as a bearer token:
Authorization: Bearer rgk_your_token_hereA practice mints its own keys in Birth Registry → Integrations. An EHR partner’s
feed keys are issued by BirthTracks instead — see
Two lanes. A key is shown once at
mint time, prefixed rgk_; only a hash is stored, so a leaked database can never
recover a usable credential. Keys do not expire — they stay valid until
revoked.
Each key carries scopes that gate which endpoints it may call:
| Scope | Grants |
|---|---|
ingest:write |
Submit Bundles — POST /api/registry/fhir/Bundle. |
ingest:read |
Read ingestion status — GET /api/registry/fhir/ingestions/{id}. |
A key holds the same capabilities as a person, named by what it may do rather than
who holds it: an ingest:write-only key is a Scribe key — it records births
and reads nothing. Birth Registry → Integrations offers a one-click Scribe key preset
that mints exactly that scope.
A missing, invalid, or revoked key returns 401 Unauthorized; a valid key lacking
the scope a route requires returns 403 Forbidden. Both are FHIR
OperationOutcomes. See Getting started.
Two lanes: your own key vs a partner feed
Section titled “Two lanes: your own key vs a partner feed”The wire format is the same everywhere, but which kind of key a Bundle is sent under decides how it is processed. A key is either a practice’s own key (the native, identified lane) or a partner feed key bound to an EHR partner (the partner lane). The difference is a property of the key, not of anything in the Bundle.
| Your own key (native lane) | Partner feed key (partner lane) | |
|---|---|---|
| How the key is obtained | Minted by the practice in Birth Registry → Integrations. | Minted by BirthTracks staff from your partner record — one key per practice, with the scopes you ask for — and handed over once. Every partner-key mint is recorded on BirthTracks’ audit trail. Ask your BirthTracks representative. A key minted in Integrations is always a native key, never a partner feed key. |
| Beta gate | Not applied. | Refused with 403 until your partner organisation is let into the partner-EHR beta, and whenever its contract status is not Active (Pending or Suspended). |
| What is stored | The identified record, as sent. | A de-identified spine only — masked at the door, before anything is persisted. See Partner onboarding. |
| Place of birth | Required — a record without one is rejected (required, Encounter.location.location.type). |
Optional. |
| Provider attribution (attendant, certifier, other providers) | Not recorded — Practitioner resources and the Encounter attendant are accepted but ignored. |
Recorded as provider identities keyed by your own provider id. |
A partner that sends its feed under a key minted in Integrations is on the native lane: nothing is de-identified, a spine-only Bundle with no place of birth is rejected, and no provider attribution is recorded.
Rate limits
Section titled “Rate limits”Requests are throttled at 120 per minute per API key, in one bucket shared
by both endpoints (submissions and status polls draw on the same budget); over
the limit returns 429 Too Many Requests. The budget keys on the API token, so
each source key gets its own bucket rather than sharing one behind a partner’s
egress IP; a request with no bearer token falls back to a per-IP bucket.
Ingestion is asynchronous, so a steady poll for status (rather than a tight loop)
stays well within budget.
Every response carries X-RateLimit-Limit and X-RateLimit-Remaining headers,
and a 429 adds Retry-After (seconds until the bucket refills) — back off for
at least that long. The 429 is the one error that is not a FHIR
OperationOutcome: it is the framework’s standard rate-limit response
({"message": "Too Many Attempts."} as JSON when your Accept header asks for
JSON, otherwise a plain HTML page), so branch on the status code and
Retry-After, not on the body.
POST /api/registry/fhir/Bundle
Section titled “POST /api/registry/fhir/Bundle”Submit a FHIR R4 BFDR Bundle for asynchronous ingestion. The body must be a JSON
object with resourceType: "Bundle". Send Content-Type: application/fhir+json.
Requires the ingest:write scope.
One or many courses of care. A Bundle is either:
- A single course of care — the Bundle holds the mother
Patient, childPatient(s), and newbornObservations directly. One record. - A batch — the Bundle’s entries are themselves Bundles; each inner Bundle is one course of care. Non-Bundle entries are ignored in this mode.
Each course of care is mapped and validated independently, so a batch lands its
good records and reports the bad ones individually via the accepted/rejected
counts on the status receipt.
What makes a record valid. A course of care is accepted only if it has:
- exactly one mother
Patient(resolved from ameta.profilenaming a mother/maternal BFDR profile), and - at least one child
Patient(profile naming child/newborn/fetus/decedent).
A record with no mother, more than one mother, or no child is rejected —
landed in outcome.rejected with an OperationOutcome explaining why — while the
rest of the batch still lands. Records are checked for this structural shape, not
yet for full BFDR IG conformance (see Limitations).
Idempotent retries. Send an Idempotency-Key header to make a network retry
of a POST safe. The key is scoped to your practice and applies to the whole
request (the Bundle). The first POST with a given key creates the ingestion; any
later POST with the same key returns the original 202 Accepted and its
Content-Location without re-ingesting — so a retry after a dropped response
never doubles a birth. Use a fresh key per distinct submission; reusing a key for
a different Bundle replays the first one. The header is optional — a request
without it is processed as before (see Duplicate handling
for how same-birth records are flagged when no key is used).
Responses
| Status | Meaning |
|---|---|
202 Accepted |
Queued. The Content-Location header holds the status URL; the body is a FHIR OperationOutcome. |
400 Bad Request |
The body was not a JSON FHIR Bundle. Returns an OperationOutcome. |
401 Unauthorized |
Missing, invalid, or revoked API key. Returns an OperationOutcome. |
403 Forbidden |
The key lacks the ingest:write scope, or it is a partner feed key whose partner is not yet in the partner-EHR beta or whose contract status is not Active. Returns an OperationOutcome. |
429 Too Many Requests |
Rate limit exceeded (see Rate limits). Not an OperationOutcome; honour Retry-After. |
Resources in a course of care
Section titled “Resources in a course of care”Beyond the mother and child Patients that make a record valid, these resources
are read when present. Anything else in the Bundle is ignored.
| Resource | Required | What it contributes |
|---|---|---|
Patient (mother) |
Yes, exactly one | Maternal demographics. Role resolved from a BFDR mother/maternal meta.profile. |
Patient (child) |
Yes, one or more | The newborn(s). Role resolved from a child/newborn/fetus/decedent profile. |
Observation |
Recommended | Birth weight, gestational age, and Apgar scores — see the LOINC codes. |
Practitioner |
Recommended on a partner feed | Provider attribution, so attendant-level benchmarking works — partner lane only; ignored on your own key. See below. |
Encounter |
Recommended | The attending provider (via participant, partner lane only), transfers of care, and the place of birth (via location) — see FHIR representation. |
Location |
Yes on your own key | The place of birth, referenced by the birth Encounter.location. Required on the native (identified) lane; optional for EHR partners. |
Composition |
Optional | A status of entered-in-error retracts the record. On a partner feed, the certifier is read from it. |
Consent |
Optional | The consent decision for the course of care. Only the first is read. |
Practitioner — provider attribution
Section titled “Practitioner — provider attribution”Provider attribution is recorded on the partner lane only (see
Two lanes). On your own key,
Practitioner resources are accepted without error but not stored, and no
attendant or certifier is recorded. Everything below describes a partner feed.
A provider’s stable identity is the first non-NPI identifier.value, falling
back to the resource id. The NPI is read when present but is kept as a discovery
signal only and is never the key — doulas and CPMs frequently have none. A
Practitioner carrying no stable id is skipped rather than stored under a
synthesized key a later push would not match, so send a stable id and keep it
stable.
Role is read from the practitioner’s qualification text/display and matched
coarsely by keyword — midwife (also CNM, CPM, LM), physician (also
obstetric/obstetrician, MD), doula, nurse (also RN), assistant;
anything unrecognised falls to other, which is excluded from attendant-type
benchmarks so an unmatched credential can never over-count attendants. The
midwife keywords are checked first, so a dual CNM, MD credential is recorded as
a midwife.
Unlike the mother and child, a provider’s display name and credentials are retained — provider attribution is not patient PHI.
Encounter — the attending provider
Section titled “Encounter — the attending provider”The attendant is the Encounter.participant marked with the HL7 v3
ParticipationType ATND (“attender”), matched independent of the coding system.
Nothing else promotes a practitioner to attendant: a doula listed as a participant
is recorded as present, not as attending. Like all attribution, the attendant is
recorded on a partner feed only.
An Encounter naming a recognised transfer type records a transfer of care
instead; see the data dictionary.
The certifier
Section titled “The certifier”On a partner feed, the certifier is the practitioner referenced by the first
Composition.attester[].party, falling back to the first Composition.author
when no attester names one. The referenced Practitioner is resolved to a
provider identity exactly as above. On your own key no certifier is recorded.
Place of birth
Section titled “Place of birth”The place of birth is read from the birth Encounter.location: resolve its
Location.type coding against the NCHS birth-delivery-occurred value set. On
your own (native) key a place of birth is required — a record with no
resolvable birthplace is rejected with an OperationOutcome
(required, Encounter.location.location.type), the same rule the CSV importer
applies, because a roster birth is never landed on an unknown or defaulted site.
EHR partners are exempt: partner-fed records are registry spine only, so place is
optional there.
Location.type coding |
Place of birth |
|---|---|
SNOMED 22232009 (Hospital) |
Hospital |
SNOMED 91154008 (Free-standing birthing center) |
Birth center |
SNOMED 408839006 (Planned home birth) · 67190003 (Home) |
Home |
SNOMED 39350007 (Clinic / doctor’s office) |
Clinic |
OTH (other) |
Other |
No Location.type coding, and Location.physicalType = ve (vehicle) |
En route |
An unrecognised Location.type coding is rejected naming the code — it is never
silently mapped to “other”. The rejection is code value with expression
Encounter.location.location.type. The vehicle fallback applies only when the
Location carries no type coding at all: an unrecognised type code is
rejected even if the Location is also a vehicle. The planned place of birth is read, when
present, from the BFDR planned-birthplace Observation (same value set); it is
optional and advisory.
GET /api/registry/fhir/ingestions/{ingestion}
Section titled “GET /api/registry/fhir/ingestions/{ingestion}”Get the status of a submitted Bundle, scoped to the practice that owns the key.
Requires the ingest:read scope. Poll until status is completed or failed.
Path parameters
| Name | Description |
|---|---|
ingestion |
The ingestion id from the submit response’s Content-Location header. |
Response body
{ "id": "0c5e...", "status": "completed", "accepted": 1, "rejected": 0, "outcome": { "accepted": [ { "reference": "CHART-001", "submission_uuid": "9b2f..." } ], "rejected": [] }, "completedAt": "2026-05-01T12:00:00+00:00"}Each entry’s reference names the record it reports on: the course of care’s
chart number (Bundle.identifier.value) when it has one, otherwise
record[N] — its zero-based position in the submission (the inner Bundle’s
position in batch mode).
status is one of queued, processing, completed, failed. On a completed
ingestion outcome is an object with accepted and rejected arrays of
per-record detail — each rejected entry carries a FHIR OperationOutcome, and
each accepted entry can carry a suspected_duplicate flag (see
Duplicate handling).
Whole-Bundle rejection
Section titled “Whole-Bundle rejection”A record that fails is landed in outcome.rejected[] under its own
reference (chart number or record[N]) while the rest of the batch still lands. When the
whole Bundle fails validation rather than an individual record, the ingestion
still reaches completed — with accepted: 0 and a single rejected entry whose
reference is the literal string "Bundle":
{ "status": "completed", "accepted": 0, "rejected": 1, "outcome": { "accepted": [], "rejected": [ { "reference": "Bundle", "outcome": { "resourceType": "OperationOutcome", "issue": [] } } ] }, "completedAt": "2026-05-01T12:00:00+00:00"}Failed ingestions
Section titled “Failed ingestions”status: "failed" is distinct from a completed ingestion that rejected every
record. It means the push could not be processed at all, and outcome is a small
error object rather than the accepted/rejected pair:
{ "id": "0c5e...", "status": "failed", "outcome": { "error": "The bundle could not be processed and the push was not accepted.", "exception": "App\\Exceptions\\…", "correlation_id": "9f1c…" }, "completedAt": "2026-05-01T12:00:00+00:00"}correlation_idis the support handle — quote it when you contact us, and it lets us find the failure without any record content in the ticket. (The exception message is deliberately not returned: a mapping error can embed submitted values, so only the exception class is surfaced.)- A related failure is a buffer-expired ingestion, whose
outcome.errorreads “The buffered Bundle for this ingestion is no longer available.” — the queued Bundle aged out of its short-lived buffer before the worker ran. Re-submit it.
| Status | Meaning |
|---|---|
200 OK |
The ingestion receipt. |
401 Unauthorized |
Missing, invalid, or revoked API key. |
403 Forbidden |
The key lacks the ingest:read scope, or it is a partner feed key whose partner is not in the partner-EHR beta or not Active. |
404 Not Found |
No ingestion with that id for the caller’s practice. An OperationOutcome with code not-found (“No ingestion found for that id.”). |
429 Too Many Requests |
Rate limit exceeded. Not an OperationOutcome; honour Retry-After. |
Duplicate handling
Section titled “Duplicate handling”Ingestion is accept-and-flag: a record that looks like one already on file is still stored, then marked for review — never silently merged or dropped.
The registry fingerprints each course of care by the mother’s name and the
newborn date/time, scoped to your practice (stored only as a keyed hash, never
in the clear). When a later submission matches a record already on file — the same
birth arriving again, including from a different input path such as a CSV import —
it is accepted and stored, then flagged in the ingestion outcome:
{ "status": "completed", "accepted": 1, "rejected": 0, "outcome": { "accepted": [ { "reference": "CHART-001", "submission_uuid": "0c5e…", "suspected_duplicate": true } ], "rejected": [] }, "completedAt": "2026-05-01T12:00:00+00:00"}- Matching tolerates formatting differences: the mother’s name is compared case- and whitespace-insensitively, and the birth time is normalized.
- If a record is missing the mother’s name or the newborn date/time, no duplicate check runs for it.
- A previously voided record is ignored, so a birth can be re-submitted after an earlier entry is voided.
- Resolving a suspected duplicate (merge, void, or keep) is a review step; the API does not do it automatically.
Chart numbers. Bundle.identifier.value is your record id
(external_record_id) and is unique within your practice (it may repeat
across different practices). Use one stable chart number per course of care and
re-send under that same id rather than minting a new one — a re-send under the
same id amends the record, while a new id lands a
second, flagged record.
Amendments and retractions
Section titled “Amendments and retractions”Sending a Bundle whose identifier.value matches a record already on file for
your practice amends that record: the correction is applied, the record’s
version advances, and its previous values are kept as history. Nothing is
duplicated, and the receipt marks the record so you can tell a correction from a
create:
{ "reference": "CHART-001", "submission_uuid": "0c5e…", "amended": true, "version": 2 }Two rules apply to amendments and not to first submissions:
- Older updates are ignored. If the Bundle carries a
meta.lastUpdated(ortimestamp) older than the version we already hold, the push is rejected with aconflictoutcome reading “Stale update ignored” and the record is left alone. Send your source system’s own last-modified stamp so retries and out-of-order deliveries can’t roll a record back. - An amendment may not merge two records. If a correction would make the
record a duplicate of a different record (same mother + newborn date/time), it
is rejected as a
conflictrather than merged. Resolve the two records first.
Retractions. To withdraw a record, send its chart number with either a
Composition whose status is entered-in-error, or a transaction/batch entry
with "request": { "method": "DELETE" }. The record is voided and drops out of
statistics and benchmarks immediately. A retraction identifies the record by chart
number only, so it does not need to carry the full course of care; retracting an
unknown chart number is rejected as not-found, and retracting twice is a no-op.
A retraction is also subject to the staleness rule above: one carrying a
meta.lastUpdated (or timestamp) older than the version on file is rejected as
a conflict (“Stale update ignored”) and the record stays live.
An accepted retraction is marked on the receipt with a retracted flag and the
record’s version, so you can tell it from a create or an amendment:
{ "reference": "CHART-001", "submission_uuid": "0c5e…", "retracted": true, "version": 3 }Re-sending a record after retracting it revives it as a further amendment version — one record, with its history intact, rather than a second one under the same id.
Idempotent retries vs. duplicate flagging are different mechanisms. An
Idempotency-Key makes a retry of the same POST
a no-op that replays the original receipt. Duplicate flagging catches the same
birth arriving again — a later submission (no key, a new chart number, or a
different input path such as CSV) whose mother/newborn fingerprint matches a
record already on file. The first prevents accidental re-ingestion; the second
surfaces genuine duplicates for review.
Consent
Section titled “Consent”Include a FHIR Consent resource in the Bundle to record the patient’s consent
decision for the course of care. The registry reads the first Consent and treats
it as granted only when its status is active and it carries no deny
provision; anything else — proposed, rejected, inactive, an explicit deny,
or no Consent at all — is recorded as not granted.
Consent is recorded, not enforced: a missing or denied consent does not block
ingestion. A consent decision is stamped on every course of care at intake (the
Consent.dateTime is preserved when present), so the registry never treats
unconsented data as consented. How unconsented records may be used downstream is a
policy decision, separate from intake.
Error shape
Section titled “Error shape”Every error except a 429 rate-limit response (see Rate limits)
is a FHIR OperationOutcome:
{ "resourceType": "OperationOutcome", "issue": [ { "severity": "error", "code": "structure", "diagnostics": "Request body must be a JSON FHIR Bundle (resourceType \"Bundle\").", "expression": ["Bundle.resourceType"] } ]}Limitations and versioning
Section titled “Limitations and versioning”- No list endpoint. The API is push-and-poll: submit a Bundle, then read that ingestion by id. There is no endpoint to list or query prior ingestions or records, so keep the ingestion ids — and your own chart numbers — that you care about.
- Structural validation only. Records are validated for the BFDR shape (recognized roles, exactly one mother, at least one child) and the declared IG version is stored, but Bundles are not yet checked for full BFDR Implementation Guide conformance.
- Unversioned, additive. Endpoints live under
/api/registry/fhir/with no version segment. Changes are additive where possible; the OpenAPI spec at/openapi.jsonis the source of truth for the current contract.