ABDM Consent Management & Health Information Exchange (M2/M3)
What this document covers
This document explains how orvo-abha exchanges a patient's health records with other systems in India's ABDM (Ayushman Bharat Digital Mission) network, under the patient's explicit consent. In plain terms: a hospital or clinic ("HIP" — Health Information Provider) holds a patient's records; another system ("HIU" — Health Information User, e.g. a different hospital or the patient's own PHR app) wants to read those records; ABDM's gateway brokers a consent request to the patient, and once the patient approves it on their Consent Manager app, the actual encrypted medical data (FHIR bundles) flows from the HIP to the HIU. Everything here is asynchronous — every step is a POST that gets acknowledged immediately (202), with the real result delivered later via a separate inbound callback from ABDM.
This single orvo-abha codebase plays both roles simultaneously, because it hosts many different facilities:
- HIU (outbound) role — when a facility using this system wants to pull in a patient's records from some other provider (including a patient pulling their own records into the PHR app — "self-fetch").
- HIP (inbound) role — when some other system asks ABDM to pull records out of one of the facilities running on this system.
The consent lifecycle
Every consent request moves through one of these states, stored in the Consent table's status column:
REQUESTED → GRANTED → (REVOKED | EXPIRED)
↘ DENIED
- REQUESTED — the HIU asked; the patient hasn't decided yet.
- GRANTED — the patient approved on the ABDM Consent Manager app. ABDM issues a signed "consent artefact" describing exactly which HI types, date range, and care contexts were approved.
- DENIED — the patient said no.
- REVOKED — the patient later withdrew a previously granted consent.
- EXPIRED — the consent's
dataEraseAtdate passed without being revoked.
ABDM's compliance rule is blunt: once a consent goes REVOKED or EXPIRED, every copy of data pulled in under it must be deleted, not just have its status flag flipped. This codebase enforces that in one place (updateConsentStatus in consent-callback.operations.ts) — see the Purge on revoke/expiry box under Health Information Request & Transfer.
Everything below is grounded in the actual route files, Zod schemas, and service/operation code under:
src/modules/abdm/hip/consent/(consent.routes.ts, consent.callback.routes.ts, consent.schema.ts, and theoperations/folder)src/modules/abdm/hip/health-document/src/modules/abdm/phr/lhr/src/modules/abdm/hip/hiu-subscription/
Mount points, confirmed from src/app.ts:
| Router | Mounted at | Rate limiter |
|---|---|---|
consentRouter |
/v1/consents, /v2/consents |
writeLimiter |
healthDocumentRouter |
/v1/health-documents |
writeLimiter |
lhrRouter |
/v1/phr/lhr, /v2/phr/lhr |
generalLimiter |
hiuSubscriptionRouter |
/v1/hiu-subscription |
generalLimiter |
consentCallbackRouter |
/ (root — ABDM calls fixed absolute paths) |
none |
subscriptionCallbackRouter |
/ (root) |
none |
Outbound-only note: consent.routes.ts itself applies no authentication middleware (no requireXHipId, no requireAuth) — it's called by orvo-hub over the Orvo session cookie, and facility scoping happens by an optional hipId query/body parameter that falls back to "the single active facility" if omitted (see resolveFacilityForConsent). This is a single-tenant-per-deployment assumption baked into the consent module; health-document and hiu-subscription routers, by contrast, do enforce X-HIP-ID at the router level.
Consent Lifecycle — HIU Outbound (init / status / fetch / create)
This is the side where a facility on this system asks ABDM, on the patient's behalf, for permission to pull in health records held somewhere else (another hospital, or — for a patient using the PHR app — a "self-fetch" of the patient's own records).
GET /v1/consents
Lists consent requests this facility has made as an HIU (excludes patient self-fetch/PATRQT requests, which are a separate, auto-driven flow not meant for the operator's manual queue).
- Who calls this: orvo-hub (facility dashboard), via the Orvo session cookie. No ABDM header required.
- Query params:
abhaAddress,hiuId,status(optional filters). - Example response:
{
"success": true,
"message": "Success",
"data": [
{
"id": "1c2b3a4d-...",
"consentId": "a9f0e6b0-6e2f-4b9b-9f0a-1a2b3c4d5e6f",
"abhaAddress": "ravi.kumar@sbx",
"hiuId": "IN0310000702_1",
"status": "GRANTED",
"purposeCode": "CAREMGT",
"hiTypes": ["DIAGNOSTICREPORT", "PRESCRIPTION"],
"dataDateRangeFrom": "2024-01-01T00:00:00.000Z",
"dataDateRangeTo": "2026-01-01T00:00:00.000Z",
"patientName": "Ravi Kumar"
}
],
"requestId": "..."
}
- Trigger / next: purely a read; no side effects.
POST /v1/consents/consent/init
Submits a raw ABDM-shaped consent request. This is the low-level wrapper — it accepts exactly the consent object ABDM's own consent/v3/request/init endpoint expects, for callers who want full control (rather than the simplified /create wrapper below).
- Who calls this: internal caller / orvo-hub with full ABDM payload knowledge.
- Request body (
HiuConsentRequestInitSchema):
{
"consent": {
"purpose": { "text": "Care Management", "code": "CAREMGT" },
"patient": { "id": "ravi.kumar@sbx" },
"hip": { "id": "IN0310000741_1", "name": "City Hospital", "type": "HIP" },
"hiu": { "id": "IN0310000702_1", "name": "Orvo Technologies Private Limited", "type": "HIU" },
"hiTypes": ["DiagnosticReport", "Prescription"],
"permission": {
"accessMode": "VIEW",
"dateRange": { "from": "2024-01-01T00:00:00.000Z", "to": "2026-01-01T00:00:00.000Z" },
"dataEraseAt": "2026-04-01T00:00:00.000Z",
"frequency": { "unit": "DAY", "value": 1, "repeats": 0 }
}
}
}
- Response:
202 Acceptedwith whatever ABDM's gateway returned synchronously (usually just an ack; the realconsentRequest.idtypically lands async via theon-initcallback below, though the sandbox does sometimes return it inline). - Trigger / next: ABDM asynchronously calls back
on-init(assigningconsentRequest.id), then the patient is notified on their Consent Manager app to approve/deny, which triggershiu-notify.
POST /v1/consents/consent/status
Polls ABDM for the current status of an in-flight consent request.
- Request body (
HiuConsentRequestStatusSchema):{ "consentRequestId": "b2f8b2b0-6e2f-4b9b-9f0a-1a2b3c4d5e6f" }(the ABDM id fromon-init). - Response:
202 Accepted; the actual status arrives via theon-statuscallback.
POST /v1/consents/consent/fetch
Requests the full, signed consent artefact for a GRANTED consent (the exact HI types/date-range/care-contexts the patient actually approved — which can differ from what was originally requested, since the patient can edit the request before granting).
- Request body (
HiuConsentFetchSchema):{ "consentId": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f" }(the ABDM artefact id). - Response:
202 Accepted; the artefact itself arrives viaon-fetch. - Note: in practice, the codebase already calls this automatically the moment
hiu-notifyreportsGRANTED(see below), so callers rarely need to invoke it directly.
POST /v1/consents/create — simplified consent request
The friendly, all-in-one wrapper most callers actually use. It fills in sensible defaults (date ranges, HIU id, purpose normalization) and internally builds and submits the full ABDM consent/init payload.
- Who calls this: orvo-hub (operator-initiated "request records from another facility") and the PHR self-fetch flow.
- Request body (
CreateConsentSchema):
{
"abhaAddress": "ravi.kumar@sbx",
"purpose": "Care Management",
"dataTypes": ["DiagnosticReport"],
"fromDate": "2024-01-01T00:00:00.000Z",
"toDate": "2026-01-01T00:00:00.000Z",
"hiuId": "IN0310000702_1",
"hipId": "IN0310000741_1",
"accessMode": "VIEW"
}
- Example response (
201):
{
"success": true,
"message": "Consent request created",
"data": {
"requestId": "0f2a...",
"consentId": "cons-9c1e6a70-1a2b3c4d5e6f",
"status": "REQUESTED",
"initiationStatus": "SUBMITTED",
"gatewayResponse": { "requestId": "0f2a..." }
}
}
- Date-range defaults, when the caller omits them:
toDatedefaults to now minus a 60-second clock-skew safety margin, not now itself — ABDM rejectsdateRange.tooutright if it is even slightly later than the gateway's own clock, so a request built at the true current instant can lose a race against ordinary host/NTP/transit skew and fail with an ABDM-side date-validation error.fromDatedefaults totoDateminus 1 year, anddataEraseAttotoDateplus 90 days. - Important gotcha documented in the schema: for a "blind" self-fetch (patient tapping fetch records,
externalHipknown but not the exact care contexts yet) or a broad-discovery request, the code deliberately sendship: nullandcareContexts: nullto ABDM. Pinning a facility/care-context ABDM doesn't yet know about causes ABDM to silently 202 the request and then drop it — noon-initever arrives. The intended target HIP is still tracked locally underartefactJson.externalHipfor correlation. - Trigger / next: same as
consent/initabove —on-init→ patient decision on the CM app →hiu-notify.
GET /v1/consents/details/:requestId
Fetches a single consent record by its consentId. Returns 404 (wrapped in a 200-status success envelope with message: 'Consent not found') if missing.
GET /v1/consents/health-info, GET /v1/consents/data-pushes, GET /v1/consents/health-info/:transactionId/status, GET /v1/consents/health-info/:transactionId/records
Read-only monitoring endpoints for the data-flow (HIU) side — covered in detail under Health Information Request & Transfer below, since they're the query surface for that flow.
Consent Lifecycle — HIP Inbound (list / details / approve / deny / revoke / auto-approve)
This is the side where another system (acting as HIU) requests data that lives in one of this system's facilities. Under the real ABDM design, the HIP itself never grants consent — only the patient does, via their Consent Manager app — the HIP's job is just to serve data once ABDM confirms a grant (see consent.hip-notify callback below). The endpoints in this section are a local operator convenience layer with an important caveat, documented directly in the code.
POST /v1/consents/:consentId/approve, POST /v1/consents/:consentId/deny, POST /v1/consents/:consentId/revoke
Operator actions on a consent request that's targeting this facility's data.
- Who calls this: orvo-hub (facility dashboard), Orvo session cookie.
- Request body (deny/revoke):
{ "reason": "duplicate request" }(optional). - Example response (approve):
{ "success": true, "message": "Consent approved", "data": { "...consent row with status: GRANTED..." } }
Caveat — read this before relying on
/approve. ABDM has no gateway endpoint for a HIP to grant consent; consent is granted exclusively by the patient on the CM app. Calling this endpoint only flips the localConsent.statuscolumn toGRANTED— it does not call ABDM, does not produce a real ABDM consent artefact, andrequestHealthInformationOperation(which requires a genuineabdmArtefactId) will not find one to act on. In effect, this only changes what the local dashboard displays; it is not a substitute for the patient's actual decision and does not unblock the real data flow. The genuine HIP-side approval path is theconsent.hip-notifycallback (below), which reflects ABDM's own record of what the patient decided. If the goal is to skip manual per-request review for a trusted HIU, use the ABDM-native auto-approval / subscription mechanism instead (next section, and see HIU Subscription).
/revokesimilarly only sets the local status toREVOKEDand stores arevokeReason; the real-world revoke path is initiated by the patient and arrives as aconsent.hip-notifycallback withstatus: REVOKED.
GET /v1/consents/artifacts, GET /v1/consents/artifacts/:artefactId
Lists (or fetches one of) the GRANTED consent artefacts held for this facility as HIP — i.e., consents where some other HIU has been genuinely approved by the patient to pull this facility's data. Optional hipId query param scopes to a specific facility.
Auto-approval rules — GET/POST /v1/consents/auto/approve, PATCH /v1/consents/auto/approve/:ruleId/status, DELETE /v1/consents/auto/approve/:ruleId
Lets a facility pre-register a trusted HIU + set of HI types so that a new inbound REQUESTED consent (via consent.hip-notify) is auto-flipped to GRANTED locally without waiting on a human operator, if the rule matches.
- Create (
CreateAutoApprovalRuleSchema):
{
"hiuId": "IN0310000702_1",
"hiuName": "Apollo Health City",
"dataTypes": ["DIAGNOSTICREPORT", "PRESCRIPTION"],
"expiryDays": 90
}
- Response (
201): the createdAutoApprovalRulerow. - Toggling:
PATCH /:ruleId/statuswith{ "enabled": true|false }.
Same caveat as /approve above applies here too — this only flips the local flag when checkAndAutoApproveConsent runs during consent.hip-notify processing; it does not itself call ABDM.
Consent Callbacks — Inbound ABDM Gateway Notifications
Every path below is an Inbound ABDM callback — ABDM's gateway POSTs to it, not our own frontend. No Bearer/session auth is applied (ABDM is not a logged-in user); these routes are protected only by not being guessable/by the callback logger recording every hit for audit (abdmCallbackEvent). All controller handlers immediately respond 202 { "status": "accepted" } (via accepted(res)) and then process the payload asynchronously — ABDM's callback window is short, and processing (DB writes, decryption, Pusher events) must not hold up the HTTP response.
All the real, ABDM-facing paths carry the mandatory /api prefix (/api/v3/...) — this was reverse-engineered from real inbound traffic (abdmCallbackEvent.route) and is required, not optional.
POST /api/v3/hiu/consent/request/on-init — consent.on-init
ABDM acknowledges our consent/init submission and assigns its own consentRequest.id.
- Inbound ABDM callback.
- Example payload (
HiuConsentOnInitCallbackSchema— deliberately loose/.passthrough(), since a strict schema that rejects an unexpected ABDM field would 422 and silently drop a real callback):
{
"consentRequest": { "id": "b2f8b2b0-6e2f-4b9b-9f0a-1a2b3c4d5e6f" },
"response": { "requestId": "0f2a-our-original-request-id" }
}
Or, on rejection:
{
"error": { "code": "ABDM-9999", "message": "HIP and HIU cannot be the same entity" },
"response": { "requestId": "0f2a-our-original-request-id" }
}
- What happens: the handler looks up the local
Consentrow by therequestIdit stashed at creation time (artefactJson.requestId), then stampsartefactJson.abdmConsentRequestId. If ABDM instead sent anerror, the local consent is markedDENIEDwith the error preserved. If therequestIdcorrelation fails (the sandbox sometimes omitsresponse.requestId), a fallback heuristic matches the most recentREQUESTEDself-fetch (purposeCode: PATRQT) consent, then anyREQUESTEDHIU-initiated consent. Emitsconsent:initializedon the facility's Pusher channel. - Next: the patient is asked to decide on their CM app →
hiu-notify.
POST /api/v3/hiu/consent/request/on-status — consent.on-status
Delivers the answer to a consent/status poll.
- Inbound ABDM callback.
- Example payload (
HiuConsentOnStatusCallbackSchema):
{ "consentRequest": { "id": "b2f8b2b0-...", "status": "GRANTED" } }
- What happens: looks up the consent by the stored
abdmConsentRequestId, mapsstatusto the internalConsentStatusenum, and calls the sharedupdateConsentStatustransition (which triggers the revoke/expiry purge described below when applicable). Emitsconsent:status-changed.
POST /api/v3/hiu/consent/request/notify — consent.hiu-notify
The pivotal callback on the HIU side — ABDM tells us the patient's actual decision (approve/deny), and on later transitions, revoke/expiry too.
- Inbound ABDM callback.
- Example payload (
HiuConsentNotifyCallbackSchema):
{
"notification": {
"consentRequestId": "b2f8b2b0-6e2f-4b9b-9f0a-1a2b3c4d5e6f",
"status": "GRANTED",
"consentArtefacts": [{ "id": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f" }]
}
}
- What happens on
GRANTED: stores every artefact id (artefactJson.abdmArtefactIds), transitions the consent toGRANTED, then for each artefact: (1) firesconsentService.fetchArtefact(artefactId)to pull the full signed detail (on-fetch, below), and (2) immediately also firesrequestHealthInformationOperationto kick off the actual data pull — deliberately not waiting foron-fetch, because the sandbox delivers it unreliably and a granted artefact can expire within minutes. A multi-HIP grant produces one artefact per HIP, and each is requested independently. For a patient self-fetch (PATRQT), also emitsphr-self-fetch:granted/phr-self-fetch:syncingPHR-app events. Emitsconsent:granted+ aCONSENT_GRANTEDnotification onDENIED. - What happens on
DENIED: transitions toDENIED, emitsconsent:denied+CONSENT_DENIEDnotification. - Reused for later transitions: ABDM sends this same webhook again for
REVOKED/EXPIREDon an already-decided request — the handler recognizes any status besides GRANTED/DENIED and routes it through the sameupdateConsentStatustransition (and its purge behavior). - Acknowledgement required: the handler must call back ABDM's own
notify/hiu/on-notifyendpoint to acknowledge receipt; failure is queued as anabdm-ackretry job rather than dropped.
POST /api/v3/hiu/consent/on-fetch — consent.on-fetch
Delivers the full, ABDM-signed consent artefact after we called consent/fetch.
- Inbound ABDM callback.
- Example payload (shape inferred from usage —
payload.consentis the artefact detail, itself matching the ABDM consent-artefact structure):
{
"consent": {
"id": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f",
"consentDetail": {
"patient": { "id": "ravi.kumar@sbx" },
"hip": { "id": "IN0310000741_1", "name": "City Hospital" },
"hiu": { "id": "IN0310000702_1" },
"hiTypes": ["DiagnosticReport"],
"careContexts": [
{ "patientReference": "ravi.kumar@sbx", "careContextReference": "ENC-2026-00123" }
],
"permission": {
"accessMode": "VIEW",
"dateRange": { "from": "2024-01-01T00:00:00.000Z", "to": "2026-01-01T00:00:00.000Z" },
"dataEraseAt": "2026-04-01T00:00:00.000Z"
}
},
"signature": "eyJhbGciOiJSUzI1NiJ9..."
}
}
- What happens: matches the consent by
abdmArtefactId(or scans theabdmArtefactIdsarray for multi-HIP grants). Non-blockingly verifies the artefact's detached-JWSsignatureagainst ABDM's published JWKS (logged, not enforced — an unconfirmed verification isn't treated as proof of tampering). Critically: the patient can edit the HI types / date range / expiry before granting, so the values in this fetched artefact — not the originally requested ones — are synced onto the consent's top-level query columns (hiTypes,dataDateRangeFrom/To,artefactExpiry) so the operator portal shows what was actually granted. Stores per-artefact detail keyed by artefact id (so a multi-HIP grant's second artefact doesn't clobber the first). Emitsconsent:artefact-fetched; for self-fetch, immediately triggers the health-info request.
POST /api/v3/consent/request/hip/on-notify — consent.hip-notify
ABDM tells this facility, acting as HIP, that a consent involving its data changed state (a third-party HIU was granted/revoked/expired access, or a brand-new third-party request just landed with REQUESTED).
- Inbound ABDM callback. Also runs
attachXHipId(non-blocking — ABDM doesn't reliably sendX-HIP-IDon every HIP-bound callback). - Example payload (
ConsentManagementNotifyCallbackRequestSchema):
{
"notification": {
"status": "GRANTED",
"consentId": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f",
"consentDetail": {
"schemaVersion": "v3",
"consentId": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f",
"createdAt": "2026-07-18T10:00:00.000",
"patient": { "id": "ravi.kumar@sbx" },
"careContexts": [
{ "patientReference": "ravi.kumar@sbx", "careContextReference": "ENC-2026-00123" }
],
"purpose": { "text": "Care Management", "code": "CAREMGT" },
"hip": { "id": "IN0310000741_1", "name": "City Hospital" },
"hiu": { "id": "IN0999999999_1", "name": "Some Other Hospital" },
"consentManager": { "id": "sbx" },
"hiTypes": ["DiagnosticReport"],
"permission": {
"accessMode": "VIEW",
"dateRange": { "from": "...", "to": "..." },
"dataEraseAt": "...",
"frequency": { "unit": "DAY", "value": 1 }
}
},
"signature": "eyJhbGciOiJSUzI1NiJ9...",
"grantAcknowledgement": true
}
}
- What happens: finds the local
Consentrow byartefactJson.abdmArtefactId, or — if this is the first time ABDM has told us about a brand-new third-party request — creates one on the spot (createHipSideConsentRecord, taggedartefactJson.role: 'HIP', which matters for the purge logic below). On a freshREQUESTEDnotification, checks the facility'sFacilitySettings.consentDefaults.autoApproveConsentsflag and any matchingAutoApprovalRule; if configured (and, optionally, if the patient is already known at this facility via an existingCareContext), auto-grants locally without waiting for a human. Otherwise transitions the status viaupdateConsentStatus. Emitsconsent:hip-status-changed+ aCONSENT_REVOKED/CONSENT_EXPIREDnotification when applicable. Acknowledges back to ABDM viaonNotifyHip(queued as a retry job on failure). - Next, on GRANTED: ABDM will separately instruct this HIP to actually push the data via
hip/health-information/request, below.
Health Information Request & Transfer — the actual FHIR data movement
This is the heart of the system: once a consent is GRANTED, this is where the real, encrypted medical records move from the HIP that holds them to the HIU that requested them. All payloads here are end-to-end encrypted using ABDM's Fidelius scheme (ECDH key exchange on Curve25519 + AES-256-GCM), so ABDM's gateway itself never sees the plaintext.
POST /v1/consents/health-info/request (and the internal requestHealthInformationOperation)
Triggers the actual data pull for a GRANTED consent + artefact.
- Who calls this: orvo-hub / internal callers (also fired automatically from
hiu-notifyandon-fetchabove — most callers never need to hit this directly). - Request body (
RequestHealthInfoSchema):
{
"consentId": "cons-9c1e6a70-1a2b3c4d5e6f",
"fromDate": "2025-01-01T00:00:00.000Z",
"toDate": "2026-01-01T00:00:00.000Z",
"hiTypes": ["DiagnosticReport"]
}
- What it does internally: generates a fresh Fidelius X25519 key pair + 32-byte nonce for this one request, stores the private key locally (needed to decrypt the incoming push), and POSTs an ABDM
hiRequestpayload naming our owndataPushUrl(.../api/v3/hiu/health-information/on-request) as where the HIP should send the encrypted bundle:
{
"hiRequest": {
"consent": { "id": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f" },
"dateRange": { "from": "2025-01-01T00:00:00.000Z", "to": "2026-01-01T00:00:00.000Z" },
"dataPushUrl": "https://abha.orvo.app/api/v3/hiu/health-information/on-request",
"hiTypes": ["DiagnosticReport"],
"keyMaterial": {
"cryptoAlg": "ECDH",
"curve": "Curve25519",
"dhPublicKey": {
"expiry": "2026-07-19T10:00:00.000Z",
"parameters": "Curve25519/32byte random key",
"keyValue": "base64-public-key..."
},
"nonce": "base64-32-random-bytes..."
}
}
}
- Response:
202 Accepted,{ "transactionId": "<our uuid>", "status": "REQUESTED" }. - Sandbox workaround: if the consented HIP happens to be one of this system's own registered facilities (self-fetch, or two facilities on the same deployment), ABDM's sandbox doesn't reliably route the request back to a HIP sharing our own bridge URL — so after a short delay, the code calls
handleHipHealthInfoRequestdirectly (a local loopback), bypassing ABDM's broken self-routing.
POST /api/v3/hiu/health-information/on-request — the data-push callback, HIU side
Inbound ABDM callback — but not from ABDM's control plane; this is where the HIP itself (relayed through ABDM) POSTs the actual encrypted health records. This one URL carries two very different payload shapes:
1. Acknowledgement-only (no entries) — ABDM relaying the HIP's initial ack of our request:
{
"response": { "requestId": "<our transactionId>" },
"hiRequest": { "transactionId": "<ABDM's own transfer id>", "sessionStatus": "ACKNOWLEDGED" }
}
The handler captures hiRequest.transactionId (ABDM's transfer id, different from our own) so the real data push — tagged with that transfer id — can later be correlated back to our original request.
2. The actual encrypted data — realistic (abbreviated) shape per HipHealthInformationSchema:
{
"transactionId": "abdm-transfer-id-...",
"pageNumber": 0,
"pageCount": 1,
"entries": [
{
"content": "base64(ciphertext || 16-byte-authTag)...",
"media": "application/fhir+json",
"checksum": "5d41402abc4b2a76b9719d911017c592",
"careContextReference": "ENC-2026-00123"
}
],
"keyMaterial": {
"cryptoAlg": "ECDH",
"curve": "Curve25519",
"dhPublicKey": {
"expiry": "2026-07-18T11:00:00.000Z",
"parameters": "Curve25519/32byte random key",
"keyValue": "base64-hip-public-key..."
},
"nonce": "base64-32-random-bytes..."
}
}
- What happens: for each entry, decrypts
contentusing Fidelius (our stored private key + nonce from the original request, combined with the HIP's public key + nonce in this payload — the AES salt/IV are both derived from XORing the two nonces, never sent as separate fields). Cross-checks the sender's MD5checksumagainst the decrypted plaintext (log-only — GCM's auth tag already guarantees integrity). Every decrypted entry is stored inFhirBundleExchange(direction: INBOUND) alongside the still-encrypted original, then normalized into queryableFhirResourcerows for the LHR (see next section). Emitshealth-info:received/health-info:processed, and aDATA_PUSH_RECEIVEDorDATA_PUSH_FAILEDnotification — distinguishing "HIP genuinely sent nothing" from "HIP sent data that failed to decrypt" (a common real failure mode from a nonce/key correlation race, since the ack and the data push are two independent async callbacks with no ordering guarantee — the code retries the correlation lookup up to 3 times over 3 seconds). - Acknowledgement required: must POST back a per-entry
hiStatus(OK/ERRORED) vianotifyHealthInfoAcknowledge— required, or ABDM will retry the whole push.
POST /api/v3/hip/health-information/request — the push instruction, HIP side
Inbound ABDM callback — ABDM instructs this facility, acting as HIP, to actually send the encrypted FHIR data to the requesting HIU. This is the callback that drives the actual outbound transfer — the HIP calls notify (.../data-flow/v3/health-information/notify) once the push completes, which ABDM forwards to the HIU.
- Example payload (
ConsentManagementHealthInfoRequestSchema/HiuHealthInfoRequestSchema):
{
"transactionId": "abdm-transfer-id-...",
"hiRequest": {
"consent": { "id": "9c1e6a70-6e2f-4b9b-9f0a-1a2b3c4d5e6f" },
"dateRange": { "from": "2025-01-01T00:00:00.000Z", "to": "2026-01-01T00:00:00.000Z" },
"dataPushUrl": "https://requesting-hiu.example.org/on-request",
"keyMaterial": {
"cryptoAlg": "ECDH",
"curve": "Curve25519",
"dhPublicKey": { "keyValue": "..." },
"nonce": "..."
}
}
}
- What happens, step by step:
- Immediately acknowledges ABDM (
onHealthInfoRequest) before any heavy processing — required promptly. - Looks up the consent by
abdmArtefactId. Refuses to push if the consent is no longerGRANTED(revoked/denied since the artefact was fetched) or itsdataEraseAthas passed. - Enforces the granted scope, not just what the request claims: drops any requested
hiTypesnot actually inconsent.hiTypes, and clamps the requesteddateRangeto the consent's granted window — "only data types that are granted in the consent are shared," per ABDM's own rule. - Collects matching records: pre-built FHIR bundles from uploaded
HealthDocumentrows, plus anything the registered FHIR-converter registry can produce from linkedCareContextrows — both scoped to only the care-context references actually listed in the consent artefact. - Encrypts each FHIR bundle with a single fresh HIP key pair/nonce for the whole push (so the HIU only needs one public key to decrypt everything), computes an MD5 checksum, and stores both plaintext and encrypted forms in
FhirBundleExchange(direction: OUTBOUND). - Size-guards any single entry over 10 MB (rejected with an
ERROREDstatus rather than attempted) and byte-size-based pages the entries — bin-packed into ~5 MB pages rather than a fixed entry count, since a page of 10 tiny records and 10 large ones are very different POST sizes. - POSTs each page to the HIU's
dataPushUrl. - Calls
notifyHealthInfoSent(ABDM's.../health-information/notify) with per-care-context delivery status, which ABDM relays onward.
- Immediately acknowledges ABDM (
- Emits:
health-info:push-completed/health-info:push-failedon the facility's Pusher channel.
Purge on REVOKED / EXPIRED — mandatory data deletion
Every status transition (from any callback above) runs through the shared updateConsentStatus. When the new status is REVOKED or EXPIRED, three things are purged, per ABDM's compliance rule that HIUs and HIPs "mandatorily get rid of the health records... for whose consents have been revoked [or] expired":
Consent.artefactJson— for consents held as HIP (role: 'HIP'), the permission/careContexts detail is stripped down to just{ role, abdmArtefactId, purgedAt, purgedReason }, so a late-arriving callback can still find the row, but it no longer authorizes a further data push.FhirBundleExchangerows withdirection: INBOUNDfor thatconsentId— the copies held as HIU, pulled in from a HIP under this consent, are hard-deleted. (Outbound rows — data this system pushed out as HIP — are untouched; a HIU-side consent's lifecycle doesn't affect the source EMR's own records.)FhirResourcerows (the LHR-normalized records — see next section) for thatconsentId— deleted too, so a revoked/expired consent doesn't leave records fully visible through the LHR views, which don't otherwise check live consent status on every read.
Health Documents — HIP Operator Document Upload
In plain terms: this is how a facility's staff manually upload a patient document (a PDF discharge summary, a scanned lab report, etc.) into the system so it becomes available to hand over the next time an HIU requests that patient's records under a valid consent — for facilities that don't have a live HIS/EMR integration producing FHIR data automatically.
All routes require X-HIP-ID (requireXHipId applied at the router level — mounted at /v1/health-documents).
POST /v1/health-documents
Uploads a new document.
- Who calls this: orvo-hub operator UI, with
X-HIP-ID: <facility's ABDM HIP id>. - Request body (
UploadDocumentSchema):
{
"abhaAddress": "ravi.kumar@sbx",
"careContextReference": "ENC-2026-00123",
"hiType": "DISCHARGESUMMARY",
"title": "Discharge Summary - Cardiology",
"content": "JVBERi0xLjQKJcOkw7zDtsO...",
"mimeType": "application/pdf",
"fileSize": 245678
}
- Security check before anything is stored: the base64
contentmust genuinely start with the magic bytes of its declaredmimeType(PDF%PDF, JPEG, PNG) — a client only sendsmimeTypeas a label, so nothing upstream otherwise confirms the bytes match it, and a mismatched upload is rejected before it can be stored and later handed to ABDM as a "health record". PDFs are additionally scanned for embedded action tokens (/JavaScript,/JS,/Launch,/OpenAction,/SubmitForm,/ImportData) and rejected if any are present — a deliberately conservative pre-storage guard (src/utils/file-signature.ts), not a substitute for an antivirus/CDR service at the infrastructure boundary, which production still needs. - Response (
201): the createdHealthDocumentrow (id, metadata,fhirBundle). - What happens: builds a spec-shaped FHIR
documentBundle (Composition→DocumentReferencewith the correct SNOMED code for thehiType, referencing a realPatientresource), caches it on the row, and upserts a matchingCareContextrow so the document becomes discoverable during ABDM discover/link. Best-effort auto-links the care context with the CM if a usable link token already exists (e.g. from a prior Scan & Share visit); if the same care context was already linked under a differenthiType, notifies the CM of the hiType change instead (the correct ABDM call for an already-linked context, distinct from adding a brand-new one). - Next: this document's
fhirBundlebecomes one of the entries served from thehip/health-information/requestpush handler described above, the next time a valid consent covers thiscareContextReference.
GET /v1/health-documents
Lists documents for the resolved facility, optionally filtered by abhaAddress and/or hiType. Query: limit (1–100, default 20), offset.
GET /v1/health-documents/:id
Fetches one document, including its full content (base64) and fhirBundle (omitted from the list view for size).
DELETE /v1/health-documents/:id
Deletes a single document. 404 if not found.
DELETE /v1/health-documents
Bulk delete, scoped by the resolved facility and an optional abhaAddress query filter. Refuses to run (throws) if neither a resolvable facility nor an abhaAddress filter is present — guarding against accidentally wiping the entire table.
LHR — Local Health Record (normalized, queryable records)
In plain terms: once encrypted FHIR bundles are decrypted and received (in the health-information-transfer step above), they're still just an opaque blob per care context. LHR is the layer that unpacks those bundles into individual, queryable FHIR resources (one row per Observation, Condition, DocumentReference, etc.) so the PHR app (and any dashboard) can show a proper timeline, group records by source hospital, and open a single record without re-parsing a whole bundle every time.
All routes require a PHR Bearer token: requireAuth (extracts the token) + requirePhrAbhaAddress (decodes the sub claim as a candidate ABHA address, then confirms the token is genuinely ABDM-issued by calling ABDM's own GET /profile with it — a short-lived cache avoids round-tripping on every request). Mounted at /v1/phr/lhr and /v2/phr/lhr (identical router).
GET /v1/phr/lhr/timeline
The main paginated record feed.
- Query (
TimelineQuerySchema):abhaAddress(required — but the effective value is always the token-verified address, never a client-supplied override, precisely to prevent one patient reading another's records),resourceType,hipId,fromDate/toDate(ISO-8601 with offset),limit(1–100, default 20),cursor(opaque — internally a keyset cursor on(resourceDate, id)descending, safe to keep paginating through even with concurrent new ingests). - Example response:
{
"success": true,
"data": {
"data": [
{
"id": "clx1a2b3c0000qzrm8h6j9f2a",
"resourceType": "DiagnosticReport",
"resourceDate": "2026-06-01T00:00:00.000Z",
"hipId": "IN0310000741_1",
"careContextReference": "ENC-2026-00123",
"hiType": "DIAGNOSTICREPORT",
"data": { "resourceType": "DiagnosticReport", "...": "..." },
"createdAt": "2026-07-18T09:00:00.000Z"
}
],
"nextCursor": "clx1a2b3c0001...",
"total": 42
}
}
GET /v1/phr/lhr/grouped
Same underlying FhirResource rows (capped at 500), grouped by hipId, each group annotated with hipName and lastSyncedAt from the DataSource table.
GET /v1/phr/lhr/sources
Lists the distinct HIPs this patient has data from, with a per-resource-type count (LongitudinalIndex).
GET /v1/phr/lhr/records/:id
Fetches one normalized resource. Ownership is enforced by matching patientAbhaAddress against the token-verified address — 404 if the id exists but belongs to someone else.
GET /v1/phr/lhr/records/:id/bundle
Returns the full decrypted FHIR document bundle that record belongs to (not just the one resource) — resolved via the record's bundleExchangeId back into the stored FhirBundleExchange row, so the UI can render the whole Composition and its section references. Falls back to wrapping the lone resource as a collection bundle for older records that predate bundle linkage.
POST /v1/phr/lhr/sync
Manually queues a background sync job for a patient (re-triggers the data-flow for their granted consents).
- Request body (
SyncTriggerSchema):{ "abhaAddress": "ravi.kumar@sbx", "consentId": "..." }(consentId optional — omitted, targets the first GRANTED consent). - Response:
202,"Sync job queued".
GET /v1/phr/lhr/self-fetch-status
A diagnostic endpoint (not a general product feature) that walks a patient's recent self-fetch (PATRQT) consents and reports exactly where each one stalled in the pipeline — REQUESTED-never-granted, GRANTED-but-no-artefact, artefact-but-no-data-request, requested-but-HIP-never-pushed, or pushed-but-decrypt-failed — plus raw recent inbound callback hits for support/debugging. Query: optional hipId filter.
POST /v1/phr/lhr/test-loopback
A test-only endpoint (auth required, but no requirePhrAbhaAddress) that locally drives the HIP→HIU data transfer for a granted self-fetch consent using seeded data, to validate the decrypt/store tail without depending on ABDM's real data-flow routing. Not part of the production consent flow.
HIU Subscription — Health Locker / Standing Subscription Management
In plain terms: a normal consent request is one-off — the HIU asks, the patient grants, data flows once (or on a schedule defined at grant time). A subscription is different: it's a standing arrangement where the patient pre-authorizes an HIU to be notified whenever new records show up at a given HIP, without a fresh consent dialog every time. This module manages that standing relationship (and the related "health locker" registration), from the facility's side.
Mounted at /v1/hiu-subscription (composed of /requests, /lockers, and root /:subscriptionId), all operator routes require X-HIP-ID.
POST /v1/hiu-subscription/requests — initiate a subscription
- Request body (
InitSubscriptionRequestSchema):
{
"subscription": {
"purpose": { "text": "Care Management", "code": "CAREMGT" },
"patient": { "id": "ravi.kumar@sbx" },
"hiu": { "id": "IN0310000702_1" },
"hips": [{ "id": "IN0310000741_1", "name": "City Hospital" }],
"categories": ["LINK", "DATA"],
"period": { "from": "2026-07-18T00:00:00.000Z", "to": "2027-07-18T00:00:00.000Z" }
}
}
Note: hips is required by this schema even though ABDM's own docs mark it optional — empirically, the sandbox silently 202s-then-drops a subscription request with an empty/omitted hips list (no on-init ever arrives), so this codebase enforces at least one entry.
- What happens: normalizes
hiu.id/hiu.nameto match the resolved HIU identity (a mismatch causes ABDM to reject withABDM-1040), fills inpurpose.refUriif missing (www.abdm.gov.in— a different format than consent-init's refUri), persists a pendingSubscriptionRequestrow, and submits to ABDM. - Response: the created (pending)
SubscriptionRequestrow.
GET /v1/hiu-subscription/requests, GET /v1/hiu-subscription/requests/combined
Lists this facility's subscription requests (and, for /combined, the resulting Subscription rows alongside them).
POST /v1/hiu-subscription/requests/:requestId/approve / /deny
Local-only operator actions on a pending request row (same "doesn't call ABDM" caveat as consent approve/deny — subscriptions are also patient-granted via ABDM, not HIP-approved).
GET /v1/hiu-subscription/requests/:requestId/subscription
Fetches the resulting Subscription row for a given request, once granted.
GET /v1/hiu-subscription/:subscriptionId, PUT /v1/hiu-subscription/:subscriptionId, POST /v1/hiu-subscription/:subscriptionId/enable, POST /v1/hiu-subscription/:subscriptionId/disable
CRUD/toggle on an established Subscription row (details JSON, enabled flag).
GET /v1/hiu-subscription/lockers, POST /v1/hiu-subscription/lockers, GET /v1/hiu-subscription/lockers/:lockerId
Manages HealthLocker registration — required for this facility's entity to be eligible for the subscription mechanism at all (an entity must be registered as HEALTH_LOCKER with ABDM/NHA approval).
Subscription Callbacks (inbound)
POST /api/v3/hiu/hiecm/subscription-requests/on-init — Inbound ABDM callback. Acknowledges the subscription-init submission and assigns subscriptionRequest.id.
{ "subscriptionRequest": { "id": "b2f8b2b0-..." }, "response": { "requestId": "our-request-id" } }
Matches the pending request by the stored requestId, stamps abdmSubscriptionId, emits subscription:initialized.
POST /api/v3/hiu/subscription-requests/hiu/notify — Inbound ABDM callback. Patient's approve/deny decision.
{ "notification": { "subscriptionRequestId": "b2f8b2b0-...", "status": "GRANTED" } }
On GRANTED: creates/enables the Subscription row and fires a SUBSCRIPTION_APPROVED notification. On DENIED/REVOKED/EXPIRED: disables any existing active Subscription row and fires the matching notification. Acknowledges back to ABDM (onNotify) with { "acknowledgement": { "status": "OK", "subscriptionId": "..." } }.
POST /api/v3/hiu/subscription/notify — Inbound ABDM callback. The actual ongoing subscription events — new care contexts becoming available.
{
"event": {
"id": "e1a2b3c0-...",
"published": "2026-07-18 09:03:26.994",
"category": "DATA",
"content": {
"patient": { "id": "ravi.kumar@sbx" },
"hip": { "id": "IN0310000741_1" },
"contexts": [
{
"hiType": "DiagnosticReport",
"careContexts": [
{ "patientReference": "ravi.kumar@sbx", "careContextReference": "ENC-2026-00456" }
]
}
]
}
}
}
category: LINK(a patient linked a new care context at a HIP): auto-initiates a fresh consent request (createConsentRequestOperation, pinning the exacthip+careContextsthis time, unlike a blind self-fetch) to pull the newly linked data.category: DATA(new data available at an already-linked HIP): looks at every GRANTED-and-not-expired consent scoped to that HIP and picks the one that actually covers this specific event — same HIU,PATRQTpurpose, and (viaconsentCoversSubscriptionEvent,src/modules/abdm/shared/consent-scope.ts) an artefact whose HIP/HI-type/care-context-references/erase-time genuinely include every(hiType, careContextReference)pair the event named. A consent merely scoped to the right HIP but covering a different HI type or an unrelated care context is not treated as a match — the check fails closed, since a subscription notification must never be allowed to broaden what a real consent actually granted. If a matching consent is found, firesrequestHealthInformationOperationdirectly; if none exists, initiates one.- Both branches are now awaited rather than fire-and-forget: the durable inbound-callback row is only marked processed once the resulting consent/health-info call has actually completed (or its retry has been queued), so a failure surfaces as a processing error on that row instead of being silently swallowed by a detached
.catch(). categoryaccepts any non-empty string, not justLINK/DATA— the schema used to reject an unrecognized category before the callback could even be logged or ACKed. Today the payload is still logged and ACKed regardless of its category, and only an unrecognized one (anything other thanLINK/DATA) is then recorded as a processing error, so a new ABDM category shows up as a visible, diagnosable failure on this endpoint rather than a callback that silently never arrived.
Note: published is a plain string, never parsed as a Date — ABDM sends it in a non-ISO, space-separated format ("2026-07-02 09:03:26.994") that would fail strict ISO-8601 validation.