@extends('layouts.app') @section('title', 'healthcareers API: send your jobs | healthcareers.app') @section('description', 'How a Health System plan organization sends its jobs to healthcareers.app with the healthcareers API: keys, endpoints, job fields, states and errors.') @section('robots', 'noindex, follow') @php $base = url('/api/v1'); $rate = (int) config('sync.api_rate_per_minute', 600); @endphp @section('content')

The healthcareers API

For organizations on the Job Sync Health System plan. Your own system sends your jobs to healthcareers.app as they open, change and close. Each job goes up as your organization's own post, in your words: we fill in details like the category and shift, but we don't rewrite your description. It's the same pipeline as our applicant tracking system connections: jobs with a problem wait for a fix instead of going up wrong, and if your plan has a limit on live jobs, the rest wait for a free slot.

Everything is JSON over HTTPS at {{ $base }}.

Authentication

Owners and admins of a verified organization create keys under My organization → Job Sync → API keys. A key looks like hc_live_ followed by 40 letters and digits. It's shown once, when you create it; we keep only a scrambled copy. Send it with every request:

Authorization: Bearer hc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Use one key per system, so you can revoke one without stopping the others. A revoked key stops working straight away. Keys also stop working while your organization isn't verified, isn't on the Health System plan, or its API connection is paused.

Rate limit

{{ $rate }} requests a minute per key. Every answer has X-RateLimit-Limit and X-RateLimit-Remaining headers. Over the limit you get 429 with Retry-After (seconds to wait). Sending a whole list of jobs one by one at the start is fine: just wait when you get a 429.

A job

Only external_id, title, description and url are required. Send everything you have; the more you send, the less we have to guess.

FieldWhat it is
external_idYour own ID for the job (a requisition number, for example). 1 to 180 letters, digits, dots, dashes, underscores or tildes. It must stay the same for the life of the job.
titleThe job title, 2 to 250 characters.
descriptionThe full description, HTML or plain text, 30 to 30,000 characters. HTML is turned into plain text with its lists and paragraphs kept.
urlThe job's page on your site, https://.
apply_urlWhere candidates apply, if not the same page. https://.
departmentOptional, up to 160 characters.
locationAn object with any of city, region (state or province, like VA), country (like US), remote (true or false), text (free text, like "Fairfax, VA").
employment_typeLike Full-time, Part-time, PRN, Contract.
payAn object with any of min, max (numbers), unit (hour, year...), currency (3 letters, like USD), text (free text, like "$38 - $52 an hour").
date_posted, valid_throughOptional dates, like 2026-10-01 or 2026-10-01T09:00:00Z.

Create or update a job

PUT /api/v1/jobs/{external_id}. The simplest way to keep a job in step: send it whenever it changes (or every time, it doesn't matter). If nothing changed, nothing happens and you get the same answer.

How long a job stays up. A job stays up until you close it with DELETE, or until 90 days after you last sent it. To keep a job up longer, send it again with PUT at least every 90 days. Sending an unchanged job is free: it isn't prepared or reviewed again, it just counts as sent.

curl -X PUT {{ $base }}/jobs/REQ-1042 \
  -H "Authorization: Bearer $HC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Registered Nurse - Med/Surg Nights",
    "description": "<p>Join our 32-bed Medical/Surgical unit...</p>",
    "url": "https://careers.example-health.org/jobs/REQ-1042",
    "department": "Nursing",
    "location": {"city": "Fairfax", "region": "VA", "country": "US", "remote": false},
    "employment_type": "Full-time",
    "pay": {"min": 38, "max": 52, "unit": "hour", "currency": "USD"}
  }'

201 when the job is new, 200 when it was already there:

{
  "job": {
    "external_id": "REQ-1042",
    "title": "Registered Nurse - Med/Surg Nights",
    "description": "<p>Join our 32-bed Medical/Surgical unit...</p>",
    "url": "https://careers.example-health.org/jobs/REQ-1042",
    "apply_url": null,
    "department": "Nursing",
    "location": {"city": "Fairfax", "region": "VA", "country": "US"},
    "employment_type": "Full-time",
    "pay": {"min": 38, "max": 52, "unit": "hour", "currency": "USD"},
    "date_posted": null,
    "valid_through": null,
    "status": {
      "state": "pending",
      "label": "Being prepared",
      "reason": null,
      "post_id": null,
      "url": null
    },
    "received_at": "2026-10-03T14:05:11+00:00"
  }
}

A new job is usually live within a minute or two. Check it with GET (below): status.url is the post's address on healthcareers.app once it's live.

Create a job (POST)

POST /api/v1/jobs, with external_id in the body. If you already sent a job with that external_id, it's updated, exactly as with PUT, and the answer says so in note. So retrying a POST is safe.

curl -X POST {{ $base }}/jobs \
  -H "Authorization: Bearer $HC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"external_id": "REQ-1043", "title": "Respiratory Therapist", "description": "...", "url": "https://careers.example-health.org/jobs/REQ-1043"}'
{
  "job": { "external_id": "REQ-1043", ..., "status": {"state": "pending", "label": "Being prepared", ...} },
  "note": "A job with this external_id already existed, so it was updated (as with PUT)."
}

Close a job

DELETE /api/v1/jobs/{external_id}. The post comes down straight away. Closing a closed job again is fine. To reopen it, send it again with PUT.

curl -X DELETE {{ $base }}/jobs/REQ-1042 -H "Authorization: Bearer $HC_API_KEY"
{
  "job": { "external_id": "REQ-1042", ..., "status": {"state": "closed", "label": "Closed", "reason": "Closed by the employer (API)", "post_id": 5120, "url": null} }
}

An external_id we've never had gets 404.

See your jobs

GET /api/v1/jobs/{external_id} for one job, with its description. GET /api/v1/jobs for all of them, newest first, 50 a page (?per_page= up to 100, ?page=2...), without descriptions. Filter by state with ?state=attn, for example.

curl "{{ $base }}/jobs?state=attn" -H "Authorization: Bearer $HC_API_KEY"
{
  "data": [
    {
      "external_id": "REQ-0977",
      "title": "Medical Assistant",
      ...,
      "status": {"state": "attn", "label": "Needs a fix", "reason": "No pay given: add a pay range", "post_id": null, "url": null},
      "received_at": "2026-10-01T09:12:40+00:00"
    }
  ],
  "meta": {"page": 1, "per_page": 50, "total": 1, "last_page": 1},
  "links": {"next": null, "prev": null}
}

What the states mean

statelabelWhat it means
pendingBeing preparedReceived. We're filling in its details; it's usually live within a minute or two.
liveLiveUp on healthcareers.app as your post. status.url is its address.
heldWaiting for reviewSomething in it needs a person to look (a pay or equal-opportunity question, for example). We review within one business day.
attnNeeds a fixIt can't go up as it is. status.reason says why; fix it in your system and send it again.
overWaiting for a free slotAll the live-job slots in your plan are in use. It goes up by itself when one frees up (when you close a job).
linkedSame as a job already on the boardSomeone on your team already posted this job by hand. We keep that post and don't double it.
skippedNot postedIt doesn't read as a healthcare job opening (or isn't in English). status.reason says why.
closedClosedYou closed it, you hadn't sent it for 90 days, or the connection was disconnected.

Errors

Every error has the same shape, with a code for your code and a message for people:

{"error": {"code": "invalid_key", "message": "That API key isn't valid. Check it was copied in full, or create a new one."}}
HTTPcodeWhat to do
400bad_jsonThe body isn't a JSON object. Check the quoting.
401missing_key, invalid_key, revoked_keySend a valid key in the Authorization header.
403not_allowed, plan, no_connection, pausedThe organization isn't verified, isn't on the Health System plan, its API connection was removed, or it's paused. The message says which. Contact us.
404not_foundNo job with that external_id, or no such address.
409too_many_jobsThe connection already has more open jobs than it can keep: 500, or 5 for each job slot in your plan if that's more. Closed and skipped jobs don't count. Close (DELETE) filled jobs, then send new ones again.
422invalidSome fields need fixing. fields lists each one with its message.
429rate_limitedWait Retry-After seconds and try again.
5xxOur side. Try again in a minute; PUT and DELETE are safe to repeat.
{
  "error": {
    "code": "invalid",
    "message": "url must be a full https:// address of the job's page, up to 990 characters.",
    "fields": {
      "url": ["url must be a full https:// address of the job's page, up to 990 characters."],
      "pay.currency": ["pay.currency must be a 3-letter currency code, like USD."]
    }
  }
}

Help

Questions, or need the Health System plan? Contact us.

@endsection