Agent Skill · HealthSherpa

ichra-platform-integration

Build ICHRA platforms that integrate with HealthSherpa APIs for plan quoting, enrollment, payment, and policy management. Use when building or debugging an ICHRA platform integration, working with HealthSherpa enrollment or quoting APIs, or when the user mentions ICHRA, HRA, enrollment, quoting, plan selection, carrier submission, or policy status.

Provider: HealthSherpa Path in repo: SKILL.md

Skill body

ICHRA Platform Integration with HealthSherpa

Environments

Environment Base URL Notes
Production https://api.ichra.healthsherpa.com Primary. Use for all live integrations.
Staging https://api.ichra-staging.healthsherpa.com For testing only. May have intermittent backend issues.

All EnrollConnect and Quoting endpoints use these base URLs. Deeplinks use different hosts (see deeplink-enrollment.md).

Authentication

All requests require an x-api-key header. API keys are provided during onboarding.

x-api-key: your_api_key_here
Content-Type: application/json

All POST and PUT requests must set Content-Type: application/json (except multipart file uploads). There is no OAuth, no sessions, no HMAC request signing.

Security & Credentials

The integrator is responsible for the security of their own systems, API credentials, webhook endpoints, and any PII or PHI they process. This skill provides API shape guidance only; it does not exempt integrators from standard secure-development practices.

Credential handling

Server-side only

PII / PHI

Webhook endpoint

Defensive rendering

TLS

Errors and retries

Critical Rules

API Behavior Notes

Some response values are normalized differently from input. Code defensively:

Behavior Guidance
Response field casing differs from input — e.g., send phone_type: "cell", receive "Cell"; send hispanic_origin: "decline_to_answer", receive "decline" Normalize response values to lowercase/snake_case before comparing to stored input. Do not rely on exact round-trip equality for: phone_type, hispanic_origin, race_ethnicity, event_type, language_spoken, language_written.
event_type lossy mappingoffered_ichra maps internally to ichra_qsehra and comes back as that value instead of what you sent Store the original value you sent locally. Do not rely on the response event_type as your source of truth.
attestations empty in response — attestation values are accepted on create but not serialized back on GET Store attestation values locally after successful create/update.
List response uses primary_applicant not applicants — list items have primary_applicant: {member_id, first_name, last_name, date_of_birth, external_id} Use item.primary_applicant.first_name (not item.applicants.primary.first_name) for list display.
List pagination uses total_count — not total Read pagination.total_count (not pagination.total).
plan_hios_id and issuer_hios_id null in list — these fields may be null in GET /applications list responses Fetch individual application via GET /applications/:id for complete data.
Create/GET response nestingPOST /applications and GET /applications/:id nest app data under an application key, with application_id, errors, next_actions, payment_instructions at the top level Flatten: {...response.application, application_id: response.application_id, errors: response.errors, next_actions: response.next_actions, payment_instructions: response.payment_instructions}
Plan lookup response nestingGET /plans/:hios_id wraps plan data under a plan key Access plan data via response.plan, not directly on the response. e.g. response.plan.enrollment_requirements.attestations

Architecture

HealthSherpa = enrollment infrastructure (carrier submission, quoting, policy management). Your platform = user experience (employer admin, employee shopping, HRA administration).

Your platform → HealthSherpa ICHRA API → Carrier

Endpoints

Quoting & Plan Lookup

Method Path Purpose
POST /api/v1/quotes Quote plans with premiums and enrollment flags
GET /api/v1/plans/:hios_id?plan_year=2026 Plan details, enrollment flags, and optional attestation content

Add ?include=enrollment_requirements to plan lookup to get carrier-specific attestation text.

EnrollConnect (API Enrollment)

Use when api_enrollment: true. Full lifecycle control.

Method Path Purpose
POST /api/v1/applications Create enrollment application
PUT /api/v1/applications/:id Update application (full replace)
GET /api/v1/applications/:id Retrieve application with status, errors, and next_actions
GET /api/v1/applications List applications (filters + pagination)
POST /api/v1/applications/:id/submit Submit to carrier
POST /api/v1/applications/:id/cancel Request cancellation (async, 202)
POST /api/v1/applications/:id/terminate Request termination (async, 202)
POST /api/v1/applications/:id/supporting_documentation Upload SEP docs (max size is carrier-dependent — see Document Upload)
GET /api/v1/applications/:id/payment_redirect Get carrier payment page data

FIPS Code Lookup (External)

The quoting API requires a fip_code (FIPS county code). Resolve zip codes to FIPS codes using the public CMS Marketplace API:

GET https://marketplace-int.api.healthcare.gov/api/v1/counties/by/zip/{zipcode}

Response:

{
  "counties": [
    {"zipcode": "42003", "name": "McCracken County", "fips": "21145", "state": "KY"},
    {"zipcode": "42003", "name": "Graves County", "fips": "21083", "state": "KY"}
  ]
}

If multiple counties are returned, prompt the user to select one. Pass the fips value as fip_code in the quoting request. No API key required for this endpoint.

Use when deeplink_enrollment: true and api_enrollment: false. Redirects user to HealthSherpa UI.

Method Path Purpose
POST /public/ichra/off_ex Deeplink — prefills HealthSherpa enrollment UI. Returns 302.

Deeplink uses a flat schema that differs from EnrollConnect. See deeplink-enrollment.md for the schema mapping and 302 handling.

Enrollment Lifecycle

draft → pending_effectuation → effectuated
  ↓            ↓                    ↓
  ↓     submission_failed    cancelled / terminated
  ↓
sep_docs_required → sep_docs_under_review → pending_effectuation
Status Meaning
draft Created, not yet submitted
submission_failed Carrier submission failed (async job error). Check application errors.
sep_docs_required SEP documentation must be uploaded before carrier will process
sep_docs_under_review SEP docs uploaded, carrier is reviewing
pending_effectuation Submitted to carrier, awaiting confirmation
effectuated Carrier confirmed, coverage active
cancelled Prospective cancellation (some carriers do not support this)
terminated Retroactive or immediate termination

Minimum Viable Payload

The API does not support partial creates. POST /applications validates the full payload on create and rejects incomplete requests. Build the complete payload before calling create.

Required fields vary by carrier. Some carriers require SSN, race/ethnicity, hispanic origin, language, and other fields that are optional for others. The safest approach is to always send the full set below.

Always required (all carriers):

Section Fields
Top-level plan_hios_id, plan_year
applicants.primary first_name, last_name, date_of_birth, gender, email, phone, phone_type, signature
residential_address street_address_1, city, state, zip_code, fips_code
signatures signature_date
attestations electronic_signature_consent: true, agrees_issuer_attestations: true
special_enrollment_period event_type, event_date

Required by most carriers (send these to avoid carrier-specific 422s):

Section Fields Notes
applicants.primary ssn Some carriers require SSN. Others accept ITIN as alternative. Some require at least one of SSN/ITIN.
applicants.primary us_citizen Required when carrier asks citizenship question (most do).
applicants.primary resides_in_state Required when carrier asks residency question (most do).
applicants.primary uses_tobacco Required when carrier asks tobacco question (most do).
applicants.primary race_ethnicity Required by some carriers. Use "decline_to_answer" if not collected.
applicants.primary hispanic_origin Required by some carriers. Use "decline_to_answer" if not collected.
applicants.primary language_spoken, language_written Required for primary by some carriers. Default "english" if not collected.

Expected for all ICHRA enrollments (omitting these will degrade the enrollment experience):

Section Fields Why
hra offered_hra, type, amount, contribution_covers, start Core ICHRA data. Without this, HealthSherpa cannot track the HRA offer, calculate employer contributions, or support downstream reimbursement workflows. Always include for ICHRA enrollments.
hra.employer name, fein, phone, address: {street_address_1, city, state, zip_code} Employer identification. Without the employer name, fein, and address, HealthSherpa cannot associate the enrollment with the correct employer group. This breaks employer-level reporting, group management, and carrier coordination. Always include all three.
agent_of_record first_name, last_name, national_producer_number, email, phone Required for agent attribution. Always include on every enrollment — without it, the enrollment will not be properly associated with the agent/broker.
attestations broker_signature_attestation, agent_advised_consumer_of_product_features Agent compliance attestations. Recommended for all broker-assisted enrollments.

Desired Effective Date

desired_effective_date is optional. When omitted, the carrier determines the effective date automatically based on the SEP type and event date. This is the recommended approach.

When provided, the date must be one of the carrier’s valid effective dates for the given SEP reason and event date. The valid dates are carrier-specific and computed from the carrier’s enrollment rules. If the date is not valid, the API returns a 422 with either:

There is no way to query valid effective dates ahead of time. Omit this field unless your platform has a specific reason to override the carrier’s default.

Happy Path

A plan must be selected before enrollment begins. There is no valid “plan-less” enrollment. The enrollment form/page should only be reachable after plan selection and must always have plan_hios_id and plan_year populated.

  1. POST /quotes with applicants (age, smoker, relationship) and HRA data — get plans with premiums and enrollment flags
  2. Employee selects a plan (user clicks “Enroll” or “Select” on a specific plan card)
  3. GET /plans/:hios_id?plan_year=2026&include=enrollment_requirements — get carrier-specific attestation text
  4. Route based on flags (internal — not shown to user):

If api_enrollment: true (EnrollConnect):

  1. POST /applications — create with complete payload (see Minimum Viable Payload above)
  2. Check errors array — if empty, ready to submit. If contains supporting_documentation_required, upload docs first.
  3. POST /applications/:id/supporting_documentation with document_type: "sep" and file payload (if needed)
  4. Read payment_instructions from the application response to determine payment timing:
    • If payment_required_with_submission is trueGET /payment_redirect, redirect user to pay before submitting
    • Otherwise → proceed to submit first (step 9)
  5. POST /applications/:id/submit — submit to carrier (returns 202 Accepted)
  6. Handle post-submit payment:
    • If payment_redirect_supported is trueGET /payment_redirect, redirect user to carrier payment page
    • If pay_by_phone_supported is true → display payment_phone_number to user
    • If none of the above → carrier handles payment outside this flow (no action needed)
  7. Poll GET /applications/:id or use webhooks until pending_effectuation then effectuated
  8. If submission_failed — check errors, fix, re-submit

If deeplink_enrollment: true and api_enrollment: false (Deeplink):

  1. POST /public/ichra/off_ex — flat schema with _agent_id (required) and applicant data
  2. Capture Location header from 302 response — redirect user’s browser (do NOT follow server-side)
  3. User completes enrollment in HealthSherpa UI
  4. Track via webhooks (submission confirmation, then policy status changes)

Attestation Content

Call GET /plans/:hios_id?plan_year=2026&include=enrollment_requirements to retrieve carrier-specific legal text. This text must be used as the actual attestation content shown to the user — do NOT use generic labels like “I agree to the issuer attestations.” The carrier-specific text is what was filed with the DOI and must be rendered as presented.

Response includes enrollment_requirements.attestations with keys like:

Keys that are absent or null are not required for that carrier/state. Only render attestation checkboxes for keys that are present in the response.

Implementation pattern:

  1. Fetch enrollment_requirements when user selects a plan (step 3 of Happy Path)
  2. For each attestation key in the response, render a checkbox with the carrier’s content text as the label
  3. If the key has options, render a select/radio with those options
  4. Replace %{signature_name} placeholder with the applicant’s full legal name
  5. Only submit attestation fields that were present in the enrollment_requirements response
  6. If enrollment_requirements is empty or unavailable, fall back to generic labels as a last resort

Signatures (Common Mistake)

Signatures are split across two locations in the payload:

These are NOT under the same object. Missing either causes a 422 on submit.

Document Upload

Include document_type at the top level alongside the file. The API rejects uploads without it. The only accepted value is "sep".

JSON upload:

{
  "file": {
    "filename": "ichra_offering.pdf",
    "content_type": "application/pdf",
    "content_base64": "<base64-encoded>"
  },
  "document_type": "sep"
}

Multipart upload: Content-Type: multipart/form-data with file field and document_type field.

Maximum file size is carrier-dependent. Most carriers allow ~2 MB, but some are higher (currently Oscar 50 MB, HCSC 10 MB) and limits change over time. Do not assume a fixed cap — keep uploads small, and if a file is rejected for size, check the current limit for that carrier with your account manager.

Create/Detail Response Structure

The POST /applications and GET /applications/:id responses nest the application data under an application key. Metadata fields are at the top level:

{
  "application_id": "HSA000739499",
  "application": {
    "external_id": "your-tracking-id",
    "plan_hios_id": "13877AZ0070072",
    "plan_year": 2026,
    "policy_status": "draft",
    "applicants": { "primary": { ... }, "dependents": [...] },
    "residential_address": { ... },
    "attestations": { ... },
    "signatures": { ... },
    "special_enrollment_period": { ... },
    "hra": { ... },
    "policies": [],
    "submitted_at": null
  },
  "errors": [...],
  "next_actions": [...],
  "payment_instructions": { ... }
}

You MUST flatten this. A correct normalization:

const flat = {
  ...response.application,
  application_id: response.application_id,
  errors: response.errors,
  next_actions: response.next_actions,
  payment_instructions: response.payment_instructions,
};

Submit Response

POST /applications/:id/submit returns 202 Accepted with:

{
  "application_id": "HSA000000001",
  "external_id": "your-tracking-id",
  "plan_year": 2026,
  "plan_hios_id": "53901AZ1490005",
  "policy_status": "pending_effectuation",
  "payment_instructions": { ... },
  "submitted_at": "2026-06-15T14:30:00Z"
}

The 202 means the submission has been accepted and is being processed asynchronously. Poll GET /applications/:id or use webhooks to track status transitions from pending_effectuation to effectuated.

Listing Applications

GET /api/v1/applications?policy_status=effectuated&limit=25&offset=0
Parameter Type Description
policy_status enum draft, submission_failed, sep_docs_required, sep_docs_under_review, pending_effectuation, effectuated, cancelled, terminated
external_id string Filter by your tracking ID
plan_year integer Filter by plan year
issuer_hios_id string Filter by carrier
plan_hios_id string Filter by plan
employer_external_id string Filter by employer
updated_since datetime ISO 8601. Returns applications updated after this time.
limit integer Page size (default 25, max 100)
offset integer Offset for pagination (default 0)

Response structure:

{
  "applications": [
    {
      "application_id": "HSA000739499",
      "external_id": "your-tracking-id",
      "status": "draft",
      "plan_year": 2026,
      "plan_hios_id": "13877AZ0070072",
      "issuer_hios_id": "13877",
      "policy_status": "draft",
      "policy_effective_date": null,
      "state": "AZ",
      "primary_applicant": {
        "external_id": null,
        "member_id": "HSM001145562",
        "first_name": "Jane",
        "last_name": "Doe",
        "date_of_birth": "1990-05-15"
      },
      "created_at": "2026-04-28T13:56:56.119Z",
      "updated_at": "2026-04-28T13:56:56.838Z"
    }
  ],
  "pagination": {"total_count": 61, "limit": 25, "offset": 0}
}

Use application_id from each list item to link to the detail view (GET /applications/:id).

Note: list items use primary_applicant (flat object with member_id, first_name, last_name) — not the applicants.primary structure used in create/detail responses.

Applicant Matching on PUT

Post-Enrollment Changes

After an application has been submitted, partners can make changes to the enrollment via the API if the carrier supports it. This enables demographic corrections (e.g., email, phone, address) and, during Open Enrollment, plan changes.

Checking Support

The GET /applications/:id response includes:

Field Type Description
supports_changes boolean Whether the carrier supports any post-enrollment changes for this application
can_change_plan boolean Whether a plan change is currently allowed (requires Open Enrollment and carrier support)
can_report_change boolean Whether demographic changes can be submitted

Additionally, next_actions will include a change_plan action when plan changes are available.

Change Workflow

  1. GET /applications/:id — confirm supports_changes is true
  2. PUT /applications/:id — send the full application payload with changes applied. All identity fields (first_name, last_name, date_of_birth) must be included even if unchanged, as omitted fields are treated as null.
  3. POST /applications/:id/submit — resubmit to apply the changes at the carrier

Identity Protection

To prevent accidental identity swaps, the API blocks certain simultaneous changes on submitted applications:

Carriers Without Change Support

Some carriers do not support post-enrollment changes via the API. For these carriers, supports_changes will be false and update attempts on submitted applications will return a 422 error. Partners should direct members to contact the carrier directly for changes on these enrollments.

Error Format

All endpoints return errors in a single format:

{"errors": [{"code": "missing_required_field", "field": "applicants.primary.date_of_birth", "message": "Date of birth is required"}]}

Create (201) and GET (200) include errors even on success for informational items like supporting_documentation_required.

HATEOAS

Every application response includes next_actions. Use it to determine available operations:

"next_actions": [
  {"rel": "self", "href": "/api/v1/applications/HSA000000001", "method": "GET"},
  {"rel": "submit", "href": "/api/v1/applications/HSA000000001/submit", "method": "POST"}
]

The array is state-aware. After submission, submit disappears and cancel appears. Use rel values to drive your UI rather than hardcoding status-to-action mappings.

Enrollment Decision Path

api_enrollment and deeplink_enrollment are internal routing flags — they determine how your backend processes enrollment. NEVER expose these labels to end users. Users should see a single “Enroll” or “Select Plan” button. Your backend uses the flags to decide how to enroll.

api_enrollment deeplink_enrollment Backend Routing
true true Prefer EnrollConnect (full lifecycle). Fall back to deeplink.
true false EnrollConnect only.
false true Deeplink only. Redirect user to HealthSherpa UI.
false false Not enrollable through HealthSherpa. Hide or disable the enroll button.

These flags are determined per carrier and state. They appear on:

Always check these flags before routing. If api_enrollment is false for a plan, POST /applications will reject the request with a 422.

IMPORTANT: You MUST include off_ex: true in every quoting request. The API only supports off-exchange enrollment. Without this parameter, the API returns on-exchange plans which are not supported.

Carrier-Specific Behavior

Always check the errors array on create and the next_actions on GET to understand what’s available.

Rate Limits

429 response includes retry_after (seconds).

Category Limit
Quoting (POST /quotes) 3,000/min
Mutations (POST/PUT) 600/min
Reads (GET) 1,000/min

Reference Files

Read these as needed for detailed schemas and implementation guidance:

Skill frontmatter

metadata: {"author" => "healthsherpa", "version" => "1.0"}