Build on Planlined

REST APIs, webhooks, and OAuth so your platform can create cases, sync plans, and react to broker activity in real time.

Quickstart

Starting with data extracted by an assistant or another vendor? Use the vendor-neutral case integration kit for payload instructions, benefit IDs, compatibility checks, and source-file review workflows.

  1. Get an API key

    Sign in to Planlined and use the API keys section below to generate a sandbox key. Choose cases:write and webhooks:write scopes to follow this guide.

    Production keys are prefixed bart_sk_. Test keys are prefixed bart_sk_test_ and operate against an isolated sandbox organization.

  2. Create your first case

    Send a POST request with an idempotency key so retries are safe:

    # Create up to 50 cases in a single call
    curl -X POST https://app.trybart.com/api/cases/bulk \
      -H "Authorization: Bearer bart_sk_test_..." \
      -H "Content-Type: application/json" \
      -H "X-Idempotency-Key: $(uuidgen)" \
      -d '{
        "cases": [{
          "externalId": "partner-row-12345",
          "employerName": "Acme Manufacturing",
          "policyPeriod": "2026-01-01 to 2026-12-31",
          "planData": { "medical": [], "dental": [] },
          "metadata": { "partnerId": "ext-12345" }
        }]
      }'
    
    # Status codes:
    #   200  every case succeeded
    #   207  partial — at least one succeeded AND at least one failed (Multi-Status)
    #   200 with ok:false  every case failed (request itself was valid)
    # Each result row echoes externalId so you can reconcile against your
    # own primary keys. Subscribe to "case.bulk_imported" for a single
    # summary event per batch instead of N per-case deliveries.
  3. Listen for case events

    Subscribe a webhook endpoint so your system reacts when a case is created, updated, or its report is generated. The response includes a one-time HMAC signing secret — save it:

    curl -X POST https://app.trybart.com/api/webhooks/subscriptions \
      -H "Authorization: Bearer bart_sk_test_..." \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Acme case sync",
        "url": "https://your-app.example.com/hooks/bart",
        "eventTypes": ["case.created", "case.updated", "report.generated"]
      }'
  4. Verify the signature on each delivery

    Planlined signs every delivery with HMAC-SHA256 over {timestamp}.{rawBody}. Pick your language from the Webhooks section below and drop the snippet into your handler. The signature lives in X-Webhook-Signature; the timestamp in X-Webhook-Timestamp; reject anything older than 5 minutes to prevent replay attacks.

    Once your handler verifies, run a test from /developers/webhooks using the Test button on your subscription — you'll see the round-trip time and HTTP status your endpoint returned.

That's the whole loop. Your platform can now create cases via API, get notified the moment any change happens, and verify those notifications are genuinely from Planlined. Everything else — multiple cases per batch, idempotency for safe retries, plan data, reports, exports — layers on top of the same three primitives.

Your API keys

Authentication

All API requests are authenticated with a single header. Either form works:

Authorization: Bearer bart_sk_live_xxxxxxxxxxxxxxxx
# or
X-API-Key: bart_sk_live_xxxxxxxxxxxxxxxx

Keys carry scopes. A request that lacks a required scope returns 403 insufficient_scope.

Rate limits & quotas

Each key is provisioned with both a per-minute and per-day cap. Defaults for new keys:

Quota Default Maximum
Requests per minute 60 6,000
Requests per day 10,000 10,000,000
Bulk-case batch size n/a 50 cases per POST /api/cases/bulk
Case payload size n/a 9 MB per case after JSON serialization
Idempotency key TTL n/a 24 hours

Exceeding a key's rate limit returns 429 rate_limited with a Retry-After header. Need higher limits? Contact Planlined support after creating the key in the API keys section.

Idempotency

Any mutating endpoint (POST, PUT, PATCH) accepts an X-Idempotency-Key header. We cache the response for 24 hours, keyed by user + key. Retrying with the same key returns the original response and sets X-Idempotency-Replayed: true.

Scenario Response
New key 200 — handler runs once, response cached
Same key, same body, completed 200 — cached response, X-Idempotency-Replayed: true
Same key, different body 422 idempotency_mismatch
Same key, still processing 409 idempotency_conflict
Same key after prior failure Allowed — new attempt runs

Use any unique string up to 255 chars. UUID v4 is recommended. Keys expire after 24 hours.

Errors

Errors return JSON with two main fields: error (a machine-readable slug for branching) and message (a human-readable description). Some endpoints also include code (a screaming-snake-case constant for auth/security errors) or details (a Zod-shaped array of field-level validation errors).

{
  "success": false,
  "error": "validation_error",
  "message": "Invalid request parameters",
  "details": [ /* per-field validation errors when applicable */ ]
}

Auth errors additionally include a screaming code:

{
  "error": "Authentication required",
  "message": "No valid authentication credentials provided",
  "code": "AUTH_REQUIRED"
}

HTTP status codes

Status Meaning Typical error slug
200 / 201 Success —
400 Validation, missing input, or missing org context validation_error, invalid_idempotency_key, service_account_requires_org
401 Authentication required or token invalid (also see code: AUTH_REQUIRED) authentication_required, authentication_failed, invalid_api_key, api_key_revoked, api_key_expired
403 Authenticated but lacks the required scope or role insufficient_scope, admin_only, forbidden
404 Resource not found, or not owned by your org not_found, case_not_found, attachment_not_found, etc.
409 Conflict — same idempotency key still processing idempotency_conflict
422 Idempotency key reused with different body idempotency_mismatch
429 Rate limit exceeded — see Retry-After header rate_limited, rate_limit_exceeded
500 Internal server error — safe to retry with the same idempotency key —
503 Upstream dependency unavailable (rare, transient) database_unavailable

Resource-specific 404s use a typed suffix (e.g., case_not_found, job_not_found, portal_not_found) when the API can identify which resource was missing.

Any 5xx response with the same X-Idempotency-Key can be safely retried — you'll either get a cached success response or trigger a fresh attempt.

Pagination

Stable v1 collection endpoints use opaque cursor pagination. Send limit (default 50, maximum 100), then pass the returned pageInfo.nextCursor as cursor until pageInfo.hasMore is false. Do not inspect or modify the cursor, reuse it with different filters, or combine it with offset.

GET /api/v1/cases?limit=25

{
  "ok": true,
  "items": [ /* up to 25 items */ ],
  "total": 412,
  "pageInfo": {
    "limit": 25,
    "hasMore": true,
    "nextCursor": "eyJ2IjoxLCJleHAi..."
  }
}

Cursors expire after 24 hours and are bound to the authenticated principal, collection, filters, and sort order. Historical unversioned collection routes continue to accept offset pagination during the compatibility window.

Try it

Send a real request to the sandbox. Use any bart_sk_test_… key — data created with sandbox keys lives in an isolated organization and never reaches production.

Webhooks

Planlined sends signed POST requests when subscribed events fire. Each delivery includes:

X-Webhook-Signature: sha256=<hex_hmac>
X-Webhook-Timestamp: 1735660800
X-Webhook-Event: case.created
X-Webhook-Delivery-Id: evt_1735660800123_abc123
User-Agent: BART-Webhooks/1.0
Content-Type: application/json

{
  "id": "evt_1735660800123_abc123",
  "type": "case.created",
  "created_at": "2026-01-01T12:00:00Z",
  "data": {
    "caseId": "case-acme-2026",
    "employerName": "Acme Corporation"
  },
  "context": {
    "org_id": "org_xyz789",
    "user_id": "user_abc123"
  }
}

The full envelope — {id, type, created_at, data, context} — is consistent across every event type. The data field varies by event; context always carries org_id + user_id.

Verifying signatures

// Node.js
const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, timestamp, secret) {
  const signed = timestamp + '.' + rawBody;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(signed)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader),
    Buffer.from(expected)
  );
}

Retry policy

A delivery is attempted up to 6 times total: the initial attempt plus 5 retries with exponential backoff:

Attempt Delay
1 (initial) Immediate
2 30 seconds
3 1 minute
4 5 minutes
5 30 minutes
6 2 hours

After 6 attempts, the delivery is marked failed and stops retrying automatically. You can manually replay it from /developers/webhooks. Separately, if a subscription accumulates 10 consecutive failed deliveries, the subscription auto-pauses and stops receiving new events until you fix and resume it.

Success codes

Your endpoint should return one of 200 OK, 201 Created, 202 Accepted, or 204 No Content. Any other status triggers a retry. The response body is captured (up to 1KB) for debugging but otherwise ignored.

Available events

Event Description Status
case.created New case created (via UI, file upload, or API) Active
case.bulk_imported Fired once per POST /api/cases/bulk with summary stats Active
case.updated Case data modified Declared, not yet emitted
case.deleted Case removed Declared, not yet emitted
plan.linked Two plans linked together (e.g., current ↔ renewal) Active
plan.created / plan.updated / plan.deleted Plan-level lifecycle Declared, not yet emitted
report.generated Report build completed Active
report.viewed Shared report opened — includes viewer engagement metrics Active
export.completed PDF/Excel export job finished Active
parse.completed Document parsing succeeded Active
parse.failed Document parsing failed Active

Events tagged Declared, not yet emitted are valid subscription targets (the API will accept them) but no call sites currently dispatch them. Subscribing produces no deliveries until the corresponding code paths land.

Event payload reference

Each data shape below is grepped from the actual dispatchEvent call site. Wrap each in the standard envelope {id, type, created_at, data, context: {org_id, user_id}} when handling.

case.created

{
  "case_id": "case-1782328962115-83d35d6d",
  "employer_name": "Acme Corporation",
  "policy_period": "2026-01-01 to 2026-12-31",
  "plan_count": 12,
  "report_types": ["benefits_comparison"],
  "source": "create_from_files",            // or "bulk_import", "create_with_report"
  "files_parsed": ["medical-sbc.pdf"]       // only on create_from_files
}

case.bulk_imported

{
  "request_id": "req_abc123",
  "total": 25,
  "created": 23,
  "failed": 2,
  "case_ids": ["case-...", "case-..."],
  "external_ids": ["ext-A1", "ext-B2"],
  "latency_ms": 1842,
  "source": "bulk_import"
}

plan.linked

{
  "case_id": "case-1782328962115-83d35d6d",
  "link_id": "link_xyz789",
  "source_plan_id": "plan_current_001",
  "target_plan_id": "plan_renewal_001",
  "link_type": "renewal"                    // or "proposed", "alternate"
}

report.generated

{
  "case_id": "case-1782328962115-83d35d6d",
  "report_id": "report_abc123",
  "share_url": "https://app.trybart.com/share/abc123",
  "is_live": true,
  "expires_at": "2026-09-24T12:00:00Z",
  "source": "create_from_files"
}

report.viewed

Engagement-rich event for sales/CRM sync. Most fields can be absent on partial sessions.

{
  "report_id": "report_abc123",
  "report_title": "Acme 2026 Benefits Renewal",
  "share_url": "https://app.trybart.com/share/abc123",
  "session_id": "sess_xyz789",
  "time_on_page": 184,                      // seconds
  "scroll_depth": 87,                       // percent
  "modeler_opened": true,
  "pdf_downloaded": false,
  "device": "desktop",
  "browser": "Chrome",
  "location": "San Francisco, CA, US",
  "is_return_visit": false,
  "previous_visits": 0,
  "referrer_source": "direct"
}

export.completed

{
  "export_type": "pdf",                     // "pdf" | "excel" | "csv"
  "case_id": "case-1782328962115-83d35d6d",
  "filename": "acme-2026-renewal.pdf",
  "size_bytes": 482193,
  "duration_ms": 2147
}

parse.completed

{
  "request_id": "parse_req_abc123",
  "filename": "uhc-quote-2026.pdf",
  "document_type": "quote",                 // "quote" | "sbc" | "census" | etc.
  "plan_count": 81,
  "source": "ai",                           // "ai" | "uhc-parser" | "bcbs-tn-parser" | etc.
  "confidence": 0.94,
  "duration_ms": 4216
}

parse.failed

{
  "request_id": "parse_req_abc123",
  "filename": "corrupted-file.pdf",
  "error_type": "ParseError",
  "error_message": "Could not extract text from PDF",
  "duration_ms": 1024
}

OAuth 2.0 partner apps

If your platform lets your users connect their Planlined data, register an OAuth app and use the authorization-code flow. Your app exchanges an authorization code for access + refresh tokens scoped to the granting user's organization.

Heads up — the consent screen is currently a placeholder. The /oauth/authorize endpoint auto-approves for any signed-in Planlined user and immediately issues a code. A proper consent UI is on the roadmap. Until then, treat the authorize step as "Planlined asks the user to sign in, then returns to you."

Step 1. Register your app at /developers/apps/new. Capture client_id and client_secret.

Step 2. Redirect the user to the Planlined authorization URL. The user must have a Planlined account — if not signed in, they'll be sent through the Planlined login flow first.

https://app.trybart.com/api/external-apps/oauth/authorize
  ?client_id=bart_xxxxxxxxxxxx
  &redirect_uri=https://your-app.example.com/oauth/callback
  &response_type=code
  &scope=read:cases%20read:plans%20offline_access
  &state=<your-csrf-token>
  &code_challenge=<pkce-challenge>          # optional, recommended
  &code_challenge_method=S256               # or "plain"

The endpoint supports PKCE. For public clients (mobile, SPA) PKCE is strongly recommended. For confidential clients (server-side), client_secret alone is sufficient.

Step 3. After approval, Planlined redirects to your redirect_uri with ?code=…&state=…. Verify state matches what you sent. Then exchange the code:

curl -X POST https://app.trybart.com/api/external-apps/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<received-code>" \
  -d "client_id=bart_xxxxxxxxxxxx" \
  -d "client_secret=bartcs_xxxxxxxxxxxx" \
  -d "redirect_uri=https://your-app.example.com/oauth/callback" \
  -d "code_verifier=<pkce-verifier>"   # only if you used PKCE

Successful response (JSON):

{
  "access_token": "<opaque-token>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:cases read:plans offline_access",
  "refresh_token": "<opaque-token>"     # only when offline_access requested
}

OAuth-standard error responses follow RFC 6749: invalid_request, invalid_client, invalid_grant, unsupported_response_type, invalid_redirect_uri, server_error.

Step 4. Use the access token on subsequent API calls:

Authorization: Bearer <access_token>

Refresh. When the access token expires (1 hour), exchange the refresh token for a new pair:

curl -X POST https://app.trybart.com/api/external-apps/oauth/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=<your-refresh-token>" \
  -d "client_id=bart_xxxxxxxxxxxx" \
  -d "client_secret=bartcs_xxxxxxxxxxxx"

Revoke. To revoke a token (logout, user disconnects):

curl -X POST https://app.trybart.com/api/external-apps/oauth/revoke \
  -d "token=<access_or_refresh_token>" \
  -d "client_id=bart_xxxxxxxxxxxx" \
  -d "client_secret=bartcs_xxxxxxxxxxxx"

Rotating your client_secret on /developers/apps immediately revokes all outstanding access and refresh tokens issued under the old secret. Existing users will need to re-authorize.

Common scopes

cases:read Read case metadata and reports
cases:write Create and modify cases
plans:read / plans:write Read or modify plans within cases
exports:read Download generated reports and exports
webhooks:write Configure and manage webhook subscriptions
analytics:read Access benchmark and market data

The full scope catalog is documented per-endpoint in the interactive reference.

Sandbox

Any key prefixed bart_sk_test_ operates against an isolated sandbox organization. Data created with sandbox keys never appears in your production account and never triggers production webhooks. Use sandbox keys for end-to-end integration tests in your CI pipeline.

Plans & quotas

Every API key carries a per-minute and per-day request quota plus payload-size and batch-size limits. See the Rate limits & quotas table in Authentication for the defaults and ceilings.

Pricing tiers: not yet finalized for public release. If you're evaluating Planlined for integration use, talk to your account contact about quota uplifts, dedicated rate-limit pools, and SLA tiers. This section will be replaced with self-serve pricing once we publish it.

What's free today

What you should ask about

Versioning & breaking changes

/api/v1 is the stable namespace for partner integrations. Existing unversioned operations remain available for compatibility unless their individual lifecycle entry says otherwise.

Stable compatibility policy. Breaking request or response changes require a new major version. Additive optional fields and operations may be introduced within v1.

When removal is scheduled, deprecated operations identify the deprecation and removal dates with Deprecation and Sunset response headers and identify the replacement with a successor Link header. No Sunset date means removal is not scheduled.

Subscribe to the OpenAPI document in your CI pipeline and diff it against your last known-good build. The public changelog records additions, deprecations, replacements, and approved Sunset dates.

API reference