ABDMABHATroubleshootingHealthcare Interoperability

ABDM Integration Troubleshooting: 41 Common V3 Errors and How to Fix Them (2026)

March 18, 202624 min readUpdated Oct 26, 2026
Written by
Gulshan Prajapati
Gulshan Prajapati

Software Development Expert

Writes about software development, scalable architecture, and practical problem-solving across modern digital products. Focuses on turning complex technical ideas into clear, real-world solutions.

ABDM Integration Troubleshooting: 41 Common V3 Errors and How to Fix Them (2026)

Most ABDM integration errors come from eight places: authentication and environment setup, ABHA and OTP calls, discovery and linking, consent, FHIR bundles, Fidelius encryption, callbacks, and infrastructure. This guide lists 41 errors we and other integrators have hit on the current V3 APIs, each with what you see, why it happens and the fix, plus a triage flowchart to find the right one fast.

It covers the three Ayushman Bharat Digital Mission milestones: M1 (ABHA), M2 (HIP) and M3 (HIU). Where the older version of this guide described pre-V3 behavior, the fixes below reflect V3. For the full integration path, start with our ABDM integration step-by-step guide; if you want a team to debug or finish the integration with you, our ABDM integration services do exactly that. Updated September 2026.

Find your ABDM error fast: triage by symptom

Start from what you see. A 401 or 403 is almost always setup; a failure at an OTP step is an ABHA problem; a 202 with no callback is a bridge URL problem; data that arrives but cannot be read is encryption or FHIR.

Watch: The ABDM Mistakes We Find in Live Hospitals (5:09). Certification proves each flow works once in the sandbox.

1. Authentication, environment and gateway errors

Every ABDM call carries a gateway session token and a few mandatory headers. Most first-week failures happen here.

Error 1: 401 Unauthorized, access token expired

What happens: Your API calls return 401 Unauthorized after working fine moments ago. The response body may say "Invalid or expired token".

Why it happens: ABDM session tokens are short-lived. If your application caches a token and reuses it after expiresIn has passed, every call fails until you fetch a new one.

How to fix it: Implement a token manager that tracks expiry and refreshes proactively, not reactively.

class ABDMTokenManager:
    def __init__(self, client_id, client_secret, base_url):
        self.client_id = client_id
        self.client_secret = client_secret
        self.base_url = base_url
        self.token = None
        self.expires_at = 0

    def get_token(self):
        import time, requests
        # Refresh 30 seconds before expiry to avoid race conditions
        if self.token and time.time() < (self.expires_at - 30):
            return self.token

        response = requests.post(
            f"{self.base_url}/gateway/v0.5/sessions",
            json={
                "clientId": self.client_id,
                "clientSecret": self.client_secret
            }
        )
        data = response.json()
        self.token = data["accessToken"]
        self.expires_at = time.time() + data["expiresIn"]
        return self.token

Key detail: refresh 30 seconds before expiry, not after. This eliminates the race condition where a token expires mid-request.

Error 2: 401 with a valid token: sandbox and production mixed

What happens: You get a " 401 Unauthorized despite having a valid token. Credentials are correct, token is fresh, but every call fails.

Why it happens: Sandbox and production use different base URLs and different credential sets. Mixing them, sandbox credentials against the production gateway, or vice versa, produces a 401 because the token was issued by a different authority.

How to fix it: Use environment-specific configuration and validate at startup.

# Environment configuration, keep these separate
ABDM_ENVIRONMENTS = {
    "sandbox": {
        "gateway_url": "https://dev.abdm.gov.in",
        "auth_url": "https://dev.abdm.gov.in/gateway/v0.5/sessions",
        "cm_id": "sbx"
    },
    "production": {
        "gateway_url": "https://apis.abdm.gov.in/api/hiecm",
        "auth_url": "https://apis.abdm.gov.in/api/hiecm/gateway/v3/sessions",
        "cm_id": "<production CM id from NHA>"
    }
}

# Validate at startup
def validate_config(env, client_id, client_secret):
    config = ABDM_ENVIRONMENTS[env]
    resp = requests.post(config["auth_url"], json={
        "clientId": client_id,
        "clientSecret": client_secret
    })
    if resp.status_code != 200:
        raise ValueError(
            f"Auth failed for {env}. Verify credentials match the {env} environment."
        )
    print(f"Validated: connected to ABDM {env}")

Sandbox calls also need X-CM-ID: sbx; omitting it or sending the production value gives unhelpful 4xx errors.

Error 3: Invalid client ID or secret

What happens: The /sessions endpoint returns 401 or 400 with a message like "Invalid clientId/clientSecret" even though you copied them from the portal.

Why it happens: When copying credentials from the ABDM sandbox portal, it is common to accidentally include trailing whitespace, newlines, or invisible Unicode characters. Some developers also confuse the sandbox login password with the API client secret, they are different credentials.

How to fix it: Strip whitespace from credentials and verify the format.

# Always strip credentials
client_id = os.environ.get("ABDM_CLIENT_ID", "").strip()
client_secret = os.environ.get("ABDM_CLIENT_SECRET", "").strip()

# Validate format before calling
assert len(client_id) > 0, "ABDM_CLIENT_ID is empty"
assert len(client_secret) > 0, "ABDM_CLIENT_SECRET is empty"
assert "\n" not in client_secret, "Client secret contains newline"
assert " " not in client_id, "Client ID contains spaces"

Error 4: Timestamp rejected or intermittent 401 from clock skew

What happens: Your API requests are rejected with errors related to timestamp validation, even though the payload is otherwise correct.

Why it happens: ABDM requires ISO 8601 timestamps in a specific format with timezone information. The gateway validates that the request timestamp is close to its own clock. Using a local timezone without an offset, or a timestamp that drifts due to server clock skew, causes rejections.

How to fix it: Always use UTC with the exact format the gateway expects.

from datetime import datetime, timezone

# Correct: ISO 8601 with UTC timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
# Result: "2026-03-18T14:30:45.123Z"

# Wrong: No timezone info
# datetime.now().isoformat()  # "2026-03-18T20:00:45.123", REJECTED

# Wrong: Local timezone offset
# "2026-03-18T20:00:45+05:30", May be rejected by some endpoints

Also, ensure your server's system clock is synchronized via NTP. A clock drift of more than a few minutes will cause persistent timestamp rejections.

In our testing a virtual machine whose time service had stopped drifted far enough to cause intermittent 401s. Keep NTP running on every host that calls ABDM.

Error 5: 403 Access Denied when requesting the session token

What happens: The call for a session token returns 403 even with correct credentials.

Why it happens: The path is wrong. The session token comes from /gateway/v0.5/sessions; guessing a v3 path such as /api/hiecm/gateway/v3/sessions for the token is denied.

How to fix it: Call the v0.5 sessions endpoint for the token, then use the V3 paths for everything else.

POST https://dev.abdm.gov.in/gateway/v0.5/sessions
{ "clientId": "YOUR_CLIENT_ID", "clientSecret": "YOUR_CLIENT_SECRET" }

Error 6: 403 from a VPN, proxy or foreign network

What happens: Calls that work from one network return 403 or are blocked from another, often before reaching the API.

Why it happens: ABDM endpoints sit behind a web application firewall. In our testing a VPN exit outside India was blocked with 403, and some developer machines could not reach the sandbox at all while servers in India could.

How to fix it: Call ABDM from a server with an Indian network egress, turn off VPNs during testing, and do not treat a network 403 as a credential problem (do not rotate secrets or lock the connection because of it).

Error 7: ABDM gateway not responding: timeouts and 5xx

What happens: Calls hang, time out or return 502, 503 or 504; callbacks stop arriving for a while.

Why it happens: The sandbox is shared and has maintenance windows and slow periods. Production can also be briefly unavailable. Retrying immediately in a loop makes it worse and can trigger duplicate-request errors.

How to fix it: Retry with exponential backoff and a new REQUEST-ID only for calls that are safe to repeat, set sensible client timeouts, show the pending state to users, and check the ABDM sandbox support portal before debugging your own code for hours.

2. ABHA creation and OTP verification errors (M1)

M1 errors cluster around OTP delivery, encryption of Aadhaar and OTP values, the two-step login and demographic matching.

Error 8: OTP not received

What happens: The OTP request returns a transaction ID, but the user never receives the SMS.

Why it happens: The Aadhaar OTP goes to the mobile number linked with Aadhaar, not the number the user typed. Telecom delays and do-not-disturb settings also block delivery. Resending quickly several times can get the number temporarily blocked.

How to fix it: Tell the user before sending that the OTP goes to their Aadhaar-linked mobile, offer a resend only after a wait, and verify a different communication mobile with its own OTP. There is no fixed sandbox OTP you can rely on; test with real test numbers you control.

Error 9: Aadhaar-linked mobile and communication mobile differ

What happens: The ABHA creation flow fails during OTP verification with an error like "Mobile number does not match" or "Invalid OTP" even when the user enters the correct OTP.

Why it happens: The mobile number provided in the ABHA creation request does not match the mobile number linked to the user's Aadhaar card. ABDM validates this match as a security measure. The OTP is sent to the Aadhaar-linked number, so if the user is checking a different phone, they will never see it.

How to fix it: Before initiating ABHA creation, inform the user that the OTP will be sent to their Aadhaar-linked mobile number. Add a pre-check step in your UI.

// Frontend guidance before OTP flow
const startAbhaCreation = () => {
  showModal({
    title: "Verify Your Mobile Number",
    message: "The OTP will be sent to the mobile number linked to your Aadhaar card. " +
             "Please ensure you have access to that phone. " +
             "If your Aadhaar-linked number has changed, please update it at your nearest Aadhaar center first.",
    onConfirm: () => initiateOtpGeneration()
  });
};

In V3, when the communication mobile differs from the Aadhaar-linked mobile, a second OTP verifies the communication mobile; build that step into the flow rather than treating it as an error.

Error 10: "OTP expired" immediately after sending

What happens: Verification fails with an "OTP expired" or invalid-OTP message even though the user entered the OTP within seconds.

Why it happens: The scope sent in the verify call does not exactly match the scope used when the OTP was requested (for example abha-login with mobile-verify versus aadhaar-verify).

How to fix it: Store the scope with the transaction ID when you request the OTP and send exactly the same scope array when verifying.

Error 11: Entered demographic details not matched

What happens: ABHA verification or profile update fails with messages such as "Demographic verification failed" or "entered demographic details not matched". The name, date of birth, or gender does not match.

Why it happens: The demographic details (name, DOB, gender) sent in the API must exactly match what is recorded in the Aadhaar database. Common mismatches include: name spelling variations ("Rajesh" vs "RAJESH" vs "Rajesh Kumar"), date format differences, and gender field using different encodings ("M" vs "Male" vs "male").

How to fix it: Normalize all demographic fields before sending.

def normalize_demographics(name, dob, gender):
    """Normalize demographic fields to match Aadhaar records."""
    # Name: uppercase, remove extra spaces
    normalized_name = " ".join(name.upper().split())

    # DOB: ensure DD-MM-YYYY format as per Aadhaar
    from datetime import datetime
    if isinstance(dob, str):
        for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%m/%d/%Y"]:
            try:
                parsed = datetime.strptime(dob, fmt)
                dob = parsed.strftime("%d-%m-%Y")
                break
            except ValueError:
                continue

    # Gender: single character uppercase
    gender_map = {
        "male": "M", "m": "M",
        "female": "F", "f": "F",
        "other": "O", "o": "O",
        "transgender": "T", "t": "T"
    }
    normalized_gender = gender_map.get(gender.lower(), gender.upper()[0])

    return normalized_name, dob, normalized_gender

The same rule applies to the demographic validation and ABHA profile update APIs and to the link-token request in M2: the name must match Aadhaar exactly, middle name included.

Error 12: OTP or Aadhaar rejected in V3: RSA encryption missing or wrong

What happens: An OTP or Aadhaar number that worked in older API versions is rejected by V3 endpoints.

Why it happens: V3 expects Aadhaar numbers, OTP values and similar fields encrypted with ABDM's RSA public key using RSA-OAEP with SHA-1, then base64 encoded. Plain text, the wrong key, or a different padding is rejected, and a value placed in the query string instead of the JSON body is rejected by the server too.

How to fix it: Fetch ABDM's public certificate for the environment you are calling and encrypt with RSA/ECB/OAEPWithSHA-1AndMGF1Padding.

from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes, serialization
import base64, requests

# Sandbox ABHA public certificate (production has its own host)
pem = requests.get("https://abhasbx.abdm.gov.in/abha/api/v3/profile/public/certificate").text
key = serialization.load_pem_public_key(pem.encode())

def rsa_encrypt(value: str) -> str:
    ct = key.encrypt(value.encode("utf-8"),
                     padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None))
    return base64.b64encode(ct).decode()

Error 13: Profile, card or QR call fails after login: T-token used instead of X-token

What happens: Login verification succeeds, but profile, ABHA card or QR calls return 401.

Why it happens: V3 login has two steps. /v3/profile/login/verify returns a short-lived T-token and the account list; /v3/profile/login/verify/user with that T-token and the chosen ABHA number returns the X-token. Profile, card and QR calls need the X-token.

How to fix it: Complete the second step, then send X-token: Bearer {x-token} on profile calls. Keep the gateway token in Authorization as usual.

Error 14: 406 when downloading the ABHA card, 404 on the QR

What happens: The ABHA card download returns 406 Not Acceptable, or the QR call returns 404.

Why it happens: The card endpoint rejects requests that carry an Accept header, which many HTTP clients add by default. The QR path is camelCase.

How to fix it: Remove the Accept header on /v3/profile/account/abha-card, and call /v3/profile/account/qrCode (not qr-code).

Error 15: "You are blocked, try after 24 hours" (ABDM-1027)

What happens: Calls for one test patient suddenly fail with a message that the user is blocked for 24 hours.

Why it happens: Repeating the same OTP or token request for the same ABHA many times in a short period triggers a temporary block. It happens most in automated test runs.

How to fix it: Rotate test ABHAs in automated suites, add waits between retries, and mark the test as skipped rather than failed when the block appears.

Error 16: Driving license document rejected during ABHA creation

What happens: ABHA creation with a driving license as the identity document fails at the document upload or verification step.

Why it happens: The document image is unreadable (blurred, cropped, poorly lit or at an angle), in an unsupported format, or larger than the endpoint accepts, or the details on it do not match the details entered.

How to fix it: Capture a flat, well-lit, full-frame image, convert it to JPEG, keep it compact, and check the entered details against the document before submitting. Check the current format and size rules in the ABDM sandbox documentation for the enrolment-by-document API.

3. Discovery and care context linking errors (M2)

For the full HIP design, see building an ABDM HIP from scratch.

Error 17: Discovery answered in the HTTP response and ignored

What happens: The patient searches for records at your facility in a PHR app, your discover endpoint returns the matched records in its response body, and nothing appears in the app.

Why it happens: In V3, discovery is asynchronous. ABDM calls your discover endpoint and expects an immediate 202; the result must be sent separately to the on-discover API with the same transaction ID. A result in the HTTP response body is ignored.

How to fix it: Acknowledge with 202, run matching in the background, then post the result.

@app.post("/user-initiated-linking/v3/patient/care-context/discover")
async def discover(request: Request, background: BackgroundTasks):
    body = await request.json()
    background.add_task(match_and_reply, body, request.headers.get("REQUEST-ID"))
    return Response(status_code=202)

async def match_and_reply(body, request_id):
    patient = match_patient(body["patient"])  # strong identifier + name, gender, YOB
    await abdm_post("/api/hiecm/user-initiated-linking/v3/patient/care-context/on-discover", {
        "transactionId": body["transactionId"],
        "patient": patient_payload(patient),   # only this facility's unlinked care contexts
        "response": {"requestId": request_id},
    })

Error 18: "Duplicate discovery request"

What happens: A second discovery for the same patient is rejected as a duplicate.

Why it happens: A discovery for the same patient and facility is already in progress, often because a test retried quickly or the first on-discover was never sent.

How to fix it: Always answer every discovery (including with no match), and when you can, match on your own patient ID so repeated searches resolve to the same record.

Error 19: One bridge URL, many facilities

What happens: You have registered one bridge URL for your software, but requests intended for different facilities all arrive at the same endpoint. You cannot distinguish which HIP/HIU the request is targeting.

Why it happens: ABDM V3 uses a single bridge URL per registered software application. If your application serves multiple facilities (hospitals, labs, clinics), all discovery and linking requests come through the same URL. The facility distinction is carried in the X-HIP-ID or X-HIU-ID HTTP headers.

How to fix it: Implement header-based routing in your bridge server.

@app.middleware("http")
async def route_by_facility(request: Request, call_next):
    hip_id = request.headers.get("X-HIP-ID")
    hiu_id = request.headers.get("X-HIU-ID")
    facility_id = hip_id or hiu_id

    if facility_id:
        # Attach facility context to request state
        request.state.facility_id = facility_id
        request.state.facility_config = get_facility_config(facility_id)
    else:
        # Log warning, missing facility header
        logger.warning(f"No X-HIP-ID or X-HIU-ID header in request to {request.url.path}")

    return await call_next(request)

def get_facility_config(facility_id):
    """Return database connection and configuration for a specific facility."""
    facilities = {
        "IN0000000001": {"name": "City Hospital", "db": "city_hospital_db"},
        "IN0000000002": {"name": "Metro Lab", "db": "metro_lab_db"},
    }
    return facilities.get(facility_id, {})

Error 20: Link token request rejected: name mismatch

What happens: The generate-token call for HIP-initiated linking is rejected, or the token never arrives.

Why it happens: The demographic details, above all the name, do not match the patient's ABHA exactly. Initials, a missing middle name or a spelling difference are enough.

How to fix it: Send the name exactly as held on the ABHA (as verified in M1), the ABHA number as a number without dashes, gender and year of birth. Store the token when it arrives on /hip/v3/links/link/token and reuse it.

Error 21: Duplicate or lost care contexts when linking

What happens: Your HIP-initiated linking creates duplicate care contexts, or fails on retry because the same linking request was already processed.

Why it happens: Network timeouts and retries can cause the same linking request to arrive multiple times. If your system does not check for existing links before creating new ones, you end up with duplicate care contexts. Conversely, if your system throws an error on duplicate attempts, legitimate retries after a network failure will break.

How to fix it: Implement idempotent linking with a deduplication check.

async def handle_hip_initiated_linking(patient_id, care_contexts, link_token):
    # Generate idempotency key from the request parameters
    import hashlib
    idempotency_key = hashlib.sha256(
        f"{patient_id}:{link_token}:{sorted([c['referenceNumber'] for c in care_contexts])}".encode()
    ).hexdigest()

    # Check if this exact request was already processed
    existing = await db.fetch_one(
        "SELECT id, status FROM link_requests WHERE idempotency_key = :key",
        {"key": idempotency_key}
    )

    if existing:
        if existing["status"] == "SUCCESS":
            return {"status": "SUCCESS", "message": "Already linked"}
        elif existing["status"] == "PROCESSING":
            return {"status": "PROCESSING", "message": "Link in progress"}
        # If previous attempt failed, allow retry

    # Record this attempt
    await db.execute(
        "INSERT INTO link_requests (idempotency_key, patient_id, status) VALUES (:key, :pid, 'PROCESSING')",
        {"key": idempotency_key, "pid": patient_id}
    )

    try:
        result = await create_care_context_links(patient_id, care_contexts)
        await db.execute(
            "UPDATE link_requests SET status = 'SUCCESS' WHERE idempotency_key = :key",
            {"key": idempotency_key}
        )
        return result
    except Exception as e:
        await db.execute(
            "UPDATE link_requests SET status = 'FAILED' WHERE idempotency_key = :key",
            {"key": idempotency_key}
        )
        raise

Two more rules from production: a linked care context cannot be unlinked, so check the reference and display text before sending; and although the link API accepts several care contexts per call, we saw batched calls lose records, so we link one care context per call.

Error 22: Patient-initiated link OTP never arrives

What happens: The patient taps Link in a PHR app, but never receives the OTP that confirms the link.

Why it happens: For patient-initiated linking, the HIP sends the OTP itself, by SMS. Without an approved SMS provider and template, the OTP is never delivered and the related NHA test cases fail.

How to fix it: Set up an SMS provider with an approved template early, before M2 testing, and log each OTP send with the link reference so failures are visible.

4. Consent flow errors

Consent errors are hard because three parties change state asynchronously. For the HIU side in full, see building an ABDM HIU from scratch.

Error 23: Consent granted on one side, unknown on the other

What happens: The HIU sees a consent as granted, but the HIP never received the notification (or the reverse).

Why it happens: Consent notifications are delivered to callbacks. If the endpoint was down, slow or returned an error, the notification can be lost, and ABDM does not resend a lost consent notification to the HIP.

How to fix it: Make callbacks durable (persist, answer 202, process later), monitor them, and on the HIU side use consent/v3/request/status to recover a missed decision. On the HIP side, a lost notification usually means the patient must grant a new consent, so prevention matters more than recovery.

Error 24: 202 Accepted, then the request shows as errored

What happens: A consent or data request returns 202, but later shows an error state with little detail.

Why it happens: 202 only means the gateway accepted the request. Validation and processing happen afterwards, and failures are reported asynchronously.

How to fix it: Log every outgoing request with its request ID and every callback with the request ID it answers, so you can match them. Read the error object in callbacks, checking first that it is not null.

Error 25: Expired or revoked consent data not purged

What happens: Data received under a consent is still stored after the consent was revoked or expired.

Why it happens: Revocation arrives as a notification, and expiry is also time-based through dataEraseAt. Systems that only react to notifications keep data too long.

How to fix it: On REVOKED, delete the data and the decryption keys for that consent immediately. Run a daily job that deletes data past dataEraseAt, and test it with a past-dated record. Keeping data after revocation is one of the most common M3 certification failures.

Error 26: Consent notification not reaching the HIP

What happens: The patient grants consent, but the HIP never starts a data transfer.

Why it happens: The notification is sent to /consent/v3/request/hip/notify under the HIP's bridge URL. A wrong bridge URL, a wrong path, or a server that was down loses it.

How to fix it: Check the registered bridge URL, implement the V3 path, answer 202 at once, and acknowledge through consent/v3/request/hip/on-notify. See the callback flowchart below.

Error 27: Your on-* reply is silently ignored

What happens: You send an on-discover, on-init, on-notify or similar reply, get 202, but the other side never moves on.

Why it happens: In V3 the request ID inside the reply body (for example response.requestId) must equal the REQUEST-ID of the callback you are answering. A mismatch is accepted but not matched.

How to fix it: Store the incoming REQUEST-ID with the callback and copy it into the reply. Log both.

Error 28: Consent request rejected: hip null or dates in the future

What happens: consent/v3/request/init fails validation.

Why it happens: Two common causes: sending "hip": null to mean all facilities (the field must be left out entirely), and a dateRange.to in the future.

How to fix it: Build the request object conditionally so hip is absent when not targeting one facility, and set dateRange.to to the current time.

Error 29: Data request rejected after consent granted (ABDM-1063)

What happens: The health information request fails although the consent was granted.

Why it happens: The date range in the data request differs from the range the patient granted, even slightly.

How to fix it: Use the granted date range from the consent artefact verbatim in data-flow/v3/health-information/request.

5. FHIR bundle and data transfer errors

Bundles must follow the NRCeS profiles for India. Our ABDM FHIR bundles guide covers each HI type.

Error 30: FHIR bundle not displaying in the PHR app

What happens: You successfully transfer a FHIR bundle (get a 200 response from the data push endpoint), but the patient's PHR app shows no records, or shows them in a garbled format.

Why it happens: PHR apps parse FHIR bundles strictly. Common issues include: (1) Missing required fields like Bundle.identifier, (2) Resources referenced in the Bundle not being included (broken references), (3) Date formats not matching FHIR R4 specifications, (4) Narrative text missing from resources, which some PHR apps use for display. This is one of the most reported issues in the ABDM Wrapper community (Issue #134).

How to fix it: Validate your FHIR bundle structure before sending it.

def validate_fhir_bundle(bundle):
    """Validate FHIR bundle structure for ABDM compliance."""
    errors = []

    # Check bundle type
    if bundle.get("resourceType") != "Bundle":
        errors.append("resourceType must be 'Bundle'")

    if bundle.get("type") not in ["collection", "document"]:
        errors.append("Bundle.type must be 'collection' or 'document'")

    # Check identifier uniqueness
    identifier = bundle.get("identifier", {})
    if not identifier.get("value"):
        errors.append("Bundle.identifier.value is required and must be unique")

    # Check all references resolve
    entries = bundle.get("entry", [])
    resource_ids = set()
    for entry in entries:
        resource = entry.get("resource", {})
        rid = resource.get("id")
        if rid:
            resource_ids.add(f"{resource['resourceType']}/{rid}")

    for entry in entries:
        resource = entry.get("resource", {})
        # Check common reference fields
        for ref_field in ["subject", "encounter", "performer", "author"]:
            ref = resource.get(ref_field, {})
            if isinstance(ref, dict) and "reference" in ref:
                ref_target = ref["reference"]
                if ref_target not in resource_ids and not ref_target.startswith("urn:"):
                    errors.append(f"Broken reference: {ref_target} in {resource.get('resourceType')}")

    # Check Composition resource exists (required for document bundles)
    resource_types = [e.get("resource", {}).get("resourceType") for e in entries]
    if bundle.get("type") == "document" and "Composition" not in resource_types:
        errors.append("Document bundles must have a Composition resource as first entry")

    return errors

One more cause we see often: the PDF is placed in the wrong resource type. Prescription bundles take the PDF as Binary; immunization records take it as DocumentReference. The wrong choice can validate in some tools and render blank in apps. See NA fields and NRCeS compliance fixes.

Error 31: Duplicate bundle identifier rejected

What happens: Your data transfer succeeds the first time but fails on subsequent transfers for the same patient, with errors about duplicate bundle identifiers.

Why it happens: Each FHIR bundle must have a globally unique Bundle.identifier.value. If you are using a static identifier or one derived from the patient ID alone (without including the care context or timestamp), subsequent bundles for the same patient will have the same identifier and be rejected as duplicates.

How to fix it: Generate unique identifiers for every bundle.

import uuid
from datetime import datetime

def create_bundle_identifier(hip_id, care_context_ref):
    """Generate a unique bundle identifier."""
    return {
        "system": f"https://{hip_id}.abdm.gov.in",
        "value": f"{hip_id}-{care_context_ref}-{uuid.uuid4()}"
    }

# Usage in bundle creation
bundle = {
    "resourceType": "Bundle",
    "id": str(uuid.uuid4()),
    "type": "collection",
    "identifier": create_bundle_identifier("IN0000000001", "visit-2026-03-18-001"),
    "timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.000Z"),
    "entry": []
}

Error 32: OP consultation shows no medications

What happens: The OP consultation bundle validates, but the PHR app shows an incomplete consultation without medicines.

Why it happens: Medicines were prescribed but sent in a separate prescription bundle or in a section the app does not read for OP consultations. Rendering of missing sections differs between PHR apps; the problem is reported in the ABDM wrapper repository (issue #154).

How to fix it: When medicines were prescribed during the consultation, include them as MedicationRequest entries in the OP consultation's medications section as the NRCeS profile describes, and check the result in a PHR app, not only in a validator. Do not add placeholder medication entries when none were prescribed.

Error 33: Large transfers time out

What happens: Pushing many care contexts for one patient times out or fails.

Why it happens: Sending every bundle in one large push exceeds practical size and time limits.

How to fix it: Split the transfer into pages: the push body has pageNumber and pageCount. Send care contexts across pages, and mark the transfer complete only after the last page. On the HIU side, wait for all pages before notifying.

Error 34: FHIR date format errors

What happens: Your FHIR bundle fails validation or is accepted but rendered incorrectly in the PHR app. Dates show as "Invalid Date" or are completely missing.

Why it happens: FHIR R4 has strict date and dateTime formats. The common mistakes: using DD-MM-YYYY instead of YYYY-MM-DD, omitting the timezone for dateTime fields, or using JavaScript's Date.toISOString() which includes milliseconds that some parsers choke on.

How to fix it: Use consistent date formatting throughout your FHIR resources.

from datetime import datetime, timezone, date

def format_fhir_date(d):
    """Format a date for FHIR (date type, no time component)."""
    if isinstance(d, str):
        # Try common input formats
        for fmt in ["%d-%m-%Y", "%d/%m/%Y", "%Y-%m-%d", "%m/%d/%Y"]:
            try:
                d = datetime.strptime(d, fmt).date()
                break
            except ValueError:
                continue
    if isinstance(d, (date, datetime)):
        return d.strftime("%Y-%m-%d")  # FHIR date format
    raise ValueError(f"Cannot parse date: {d}")

def format_fhir_datetime(dt):
    """Format a datetime for FHIR (dateTime type, includes time + timezone)."""
    if isinstance(dt, str):
        dt = datetime.fromisoformat(dt.replace("Z", "+00:00"))
    if isinstance(dt, datetime):
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=timezone.utc)
        return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
    raise ValueError(f"Cannot parse datetime: {dt}")

# Examples:
# format_fhir_date("18-03-2026")       => "2026-03-18"
# format_fhir_datetime(datetime.now())  => "2026-03-18T14:30:00+0000"

6. Fidelius encryption errors

ABDM encrypts every record end to end with Fidelius: ECDH on Curve25519, HKDF-SHA256 and AES-256-GCM. Our Fidelius encryption guide has a working implementation.

Error 35: Key exchange fails: wrong curve or key format

What happens: Encryption or decryption fails with invalid key errors, or the other side cannot decrypt what you sent.

Why it happens: ABDM's Fidelius uses Curve25519 in short Weierstrass form, with public keys exchanged X.509 (DER) encoded and base64. Using P-256, a standard X25519 (Montgomery) library, or raw key bytes produces keys the other side cannot use.

How to fix it: Use a library that supports custom curve parameters (for example BouncyCastle), generate a fresh key pair per data request, send the X.509 encoded public key in keyMaterial.dhPublicKey.keyValue, and test a round trip against the open-source Fidelius CLI.

Error 36: Decryption produces garbage: nonce split wrong

What happens: Decryption runs but produces unreadable output or an authentication tag error.

Why it happens: The two 32-byte nonces must be XORed, and the result split: the first 20 bytes are the HKDF salt, the last 12 bytes are the AES-GCM IV. Using the first 12 bytes as the IV, or the whole XOR as the salt, breaks decryption.

How to fix it: Follow the split exactly and derive the AES key with HKDF-SHA256 from the ECDH shared secret.

Error 37: Serialization changes the payload before encryption

What happens: Encryption succeeds, the encrypted payload is accepted, but the receiver cannot decrypt or parse the data. The decrypted output is garbled or throws a JSON parse error.

Why it happens: The FHIR bundle must be serialized to a specific JSON string format before encryption. Common mistakes: (1) Using pretty-printed JSON with indentation instead of compact JSON, (2) Including BOM (Byte Order Mark) characters, (3) Using a different character encoding (Latin-1 instead of UTF-8), (4) Adding trailing newlines or whitespace that become part of the encrypted payload.

How to fix it: Ensure consistent JSON serialization before encryption.

import json

def serialize_for_encryption(fhir_bundle):
    """Serialize FHIR bundle to the exact format required before encryption."""
    # Compact JSON, no extra whitespace
    serialized = json.dumps(
        fhir_bundle,
        separators=(",", ":"),  # No spaces after separators
        ensure_ascii=False,       # Allow Unicode characters
        sort_keys=False           # Preserve original key order
    )

    # Encode to UTF-8 bytes, no BOM, no trailing newline
    data_bytes = serialized.encode("utf-8")

    # Verify roundtrip
    assert json.loads(data_bytes.decode("utf-8")) == fhir_bundle, "Serialization roundtrip failed"

    return data_bytes

7. Callback and bridge URL errors

ABDM answers almost everything through callbacks to your bridge URL. When they fail, you see a 202 and then nothing. Our callback debugging guide goes deeper.

Error 38: Callbacks never arrive

What happens: You receive 202 responses, but your callback endpoints are never called.

Why it happens: Common causes: the server is not publicly reachable; the registered bridge URL is stale or still propagating; a TLS problem; a tunnel that has silently stopped forwarding; or the endpoint path is a pre-V3 path.

How to fix it: Check in the order shown in the flowchart. In development use a tunnel, and probe it with a request of realistic size before each test run; in our testing a tunnel that looked healthy dropped larger requests. Bridge URL updates took 5 to 15 minutes to apply, and there is one bridge URL per client ID, so two environments cannot share one client ID.

Error 39: Callback handler is slow or returns 500

What happens: Callbacks arrive, but the flow still fails or NHA test cases report missing acknowledgements.

Why it happens: The handler does heavy work (FHIR building, encryption, database writes) before answering, or throws and returns 500.

How to fix it: Persist the callback, answer HTTP 202 immediately, and process in a worker. Wrap handlers so an exception never produces a 500 to ABDM.

8. Infrastructure errors

Error 40: CORS errors when calling ABDM from the browser

What happens: Your web application's API calls to ABDM fail with "Access-Control-Allow-Origin" errors in the browser console. The requests work fine from Postman or curl, but fail from your React/Angular frontend.

Why it happens: ABDM gateway does not set CORS headers to allow browser-based cross-origin requests. This is by design, ABDM APIs are meant to be called server-to-server, not directly from a browser. Your client secret would also be exposed in the frontend code, which is a security violation.

How to fix it: Route all ABDM API calls through your backend server.

# Your backend acts as a proxy, frontend never talks to ABDM directly

# Frontend (React example)
const discoverPatient = async (patientData) => {
  // Call YOUR backend, not ABDM directly
  const response = await fetch("/api/abdm/discover", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(patientData)
  });
  return response.json();
};

# Backend proxy endpoint
@app.post("/api/abdm/discover")
async def proxy_discover(request: Request):
    body = await request.json()
    token = token_manager.get_token()  # Server-side token management

    response = requests.post(
        f"{ABDM_BASE_URL}/api/hiecm/...",
        headers={
            "Authorization": f"Bearer {token}",
            "X-HIP-ID": get_facility_id(request)
        },
        json=body
    )
    return response.json()

Error 41: Bridge URL update blocked

What happens: You try to update your bridge URL via the ABDM API, but the request is blocked by a Web Application Firewall (WAF) with a 403 or timeout.

Why it happens: In the sandbox environment, bridge URL updates via API may be blocked by CloudFront WAF rules. This is a known infrastructure issue that intermittently affects developers.

How to fix it: Update your bridge URL through the ABDM sandbox portal UI instead of the API.

  1. Log in to sandbox.abdm.gov.in
  2. Navigate to your registered application settings
  3. Update the Bridge URL field directly
  4. Save, then allow 5 to 15 minutes before testing callbacks

For a detailed walkthrough of the ABDM setup process, including bridge URL registration, see our step-by-step ABDM integration guide.

Quick reference: all 41 ABDM errors at a glance

#ErrorRoot causeQuick fix
1401 token expiredToken reused past expiresInRefresh before expiry
2401 with valid tokenSandbox and production mixed, X-CM-ID missingPer-environment config, X-CM-ID sbx
3Invalid client ID or secretWhitespace or wrong credentialStrip and validate at startup
4Timestamp rejected, intermittent 401Wrong format or clock driftUTC ISO 8601, NTP on
5403 on session tokenWrong token pathUse /gateway/v0.5/sessions
6403 from some networksVPN or foreign egress blockedCall from an Indian server, no VPN
7Gateway not respondingSandbox slow or downBackoff, timeouts, check support portal
8OTP not receivedGoes to Aadhaar-linked mobile, DNDTell the user, wait before resend
9Mobiles differCommunication mobile not verifiedSecond OTP for that mobile
10OTP expired at onceScope differs between request and verifyReuse the exact scope
11Demographic details not matchedName or DOB not as on AadhaarSend exact Aadhaar values
12OTP or Aadhaar rejected in V3Missing RSA-OAEP SHA-1 encryptionEncrypt with ABDM public certificate
13Profile call 401 after loginT-token instead of X-tokenComplete verify/user, send X-token
14406 on ABHA cardAccept header sentRemove Accept; QR path qrCode
15Blocked for 24 hoursRepeated requests for one ABHARotate test ABHAs, add waits
16Driving license rejectedUnreadable image or wrong detailsClean JPEG, check details
17Discovery result ignoredV3 discovery is asynchronous202, then on-discover
18Duplicate discoveryEarlier discovery unansweredAlways answer; match on patient ID
19Many facilities, one URLSingle bridge URL per clientRoute by X-HIP-ID or X-HIU-ID
20Link token rejectedName not exactSend name exactly as on ABHA
21Duplicate or lost care contextsRetries, batched linksIdempotency key, one per call
22Link OTP never arrivesNo SMS providerApproved SMS provider and template
23Consent state differsLost notify, not resentDurable callbacks, status API on HIU
24202 then erroredAsync failureLog request IDs, read error safely
25Data kept after revokePurge not implementedDelete on REVOKED and at dataEraseAt
26HIP gets no consent notifyWrong URL or path, server downV3 path, 202, on-notify
27on-* reply ignoredRequest ID mismatchEcho the callback REQUEST-ID
28Consent init rejectedhip null or future dateOmit hip, dateRange.to = now
29Data request rejectedDate range changed (ABDM-1063)Use granted range verbatim
30Bundle not shown in PHRStructure or PDF resource typeValidate against NRCeS, Binary vs DocumentReference
31Duplicate bundle IDStatic identifierUUID per bundle
32No medications shownMedicines not in OP consultationInclude prescribed medicines
33Large transfer times outEverything in one pushPages with pageNumber and pageCount
34Date format errorsNon-FHIR datesFHIR date and dateTime formats
35Key exchange failsWrong curve or key formatCurve25519 Weierstrass, X.509 keys
36Garbage after decryptionNonce split wrongSalt = first 20 bytes, IV = last 12
37Payload changed before encryptionPretty JSON, BOM, encodingCompact UTF-8 JSON
38Callbacks never arriveReachability, stale URL, tunnelCheck in order; wait 5 to 15 min
39Callback handler slow or 500Work before ACK202 first, worker after
40CORS errorsBrowser calling ABDMBackend proxy
41Bridge URL update blockedFirewall on the API routeUpdate through the portal

Debugging habits that save the most time

  1. Log every request and callback with its request ID. It is the only way to match an asynchronous answer to its question, and the first thing ABDM sandbox support asks for.
  2. Test every flow end to end in the sandbox, including the patient side in a PHR app, before touching production.
  3. Validate FHIR bundles independently against the NRCeS profiles; a bundle accepted by the gateway can still render blank in an app.
  4. Monitor callback health: uptime, response time and error rate on every callback path, with an alert when callbacks stop.
  5. Keep the bridge URL documented and probed, and allow 5 to 15 minutes after any change.
  6. Keep a public error reference close: our ABDM V3 error catalog and V3 Postman collection are free to use.

When to contact ABDM sandbox support

Raise a ticket on the ABDM sandbox support portal when the triage and callback checks above pass and the problem persists, or when the gateway is not responding for everyone. Include the environment, client ID (never the secret), the API path, the request ID, the timestamp and the full error body. For certification-stage questions, see our ABDM certification process guide and V3 migration guide.

Stuck on an ABDM error that is not listed here, or preparing for NHA testing? Our ABDM integration team debugs live integrations and takes products through M1, M2 and M3. Talk to our team with your request IDs and we will tell you where it is failing.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

How do I troubleshoot ABDM integration errors?

Start from the symptom. A 401 or 403 is usually authentication or environment setup; a failure at an OTP step is an ABHA problem; a 202 with no callback is a bridge URL or callback problem; data that arrives but cannot be read is Fidelius encryption or the FHIR bundle. Log the request ID of every call and callback so you can match them.

Why is the ABDM gateway not responding?

The sandbox is shared and has slow periods and maintenance windows, and calls from some networks (for example VPNs outside India) are blocked. Retry with backoff and new request IDs, set timeouts, call from a server with an Indian network egress, and check the ABDM sandbox support portal before debugging your own code at length.

Why do I keep getting 401 Unauthorized from ABDM?

The usual causes are an expired session token, sandbox and production credentials or URLs mixed up, a missing X-CM-ID header, clock drift on the server, and, for ABHA profile calls, sending the T-token instead of the X-token.

Why am I not receiving ABDM callbacks?

Check that the bridge URL is publicly reachable over HTTPS, that the registered URL is correct and was updated at least 5 to 15 minutes ago, that your handler answers HTTP 202 immediately, and that you implemented the V3 callback paths. Tunnels used in development can silently drop requests, and a lost consent notification is not resent.

What does 'entered demographic details not matched' mean in ABDM?

The name, date of birth or gender you sent does not exactly match the details held for the patient's Aadhaar or ABHA. Send the values exactly as recorded, including the full name with middle name, and normalize formats before sending.

Why does ABDM data not decrypt?

Most often the key exchange uses the wrong curve or key format. ABDM's Fidelius uses Curve25519 in short Weierstrass form with X.509 encoded public keys; the XOR of the two nonces gives the HKDF salt (first 20 bytes) and the AES-GCM IV (last 12 bytes). Test a full round trip against a reference implementation.

Is ABDM discovery synchronous or asynchronous in V3?

Asynchronous. Your discover endpoint must answer HTTP 202 immediately and send the matched care contexts separately to the on-discover API with the same transaction ID. A result returned in the HTTP response body is ignored.

How do I log in to the ABDM sandbox?

Register and log in on the ABDM sandbox portal at sandbox.abdm.gov.in. After approval you receive a client ID and secret, which your software uses to get a gateway session token from /gateway/v0.5/sessions for every API call. The sandbox login password and the API client secret are different credentials.
Share