Free for reads, no authentication. Returns only currently live
occupational therapy job listings. Open CORS for browser and agent clients.
Responses are cached for 60 seconds; send If-None-Match with a
previously returned ETag to get a 304. Public reads
are rate limited per IP; exceeding the limit returns 429 with
Retry-After. JSON responses are UTF-8; timestamps are ISO 8601.
Creating listings is a separate, authenticated write surface: see Creating listings. New listings are drafts until the owner publishes them.
Every public read response advertises the current one-minute window:
X-RateLimit-Limit (requests allowed),
X-RateLimit-Remaining (requests left), and
X-RateLimit-Reset (unix epoch seconds when the window ends).
Use these to self-throttle before you are blocked; OPTIONS
preflights do not consume quota. Because successful responses are
cacheable, a response served from a shared cache may report another
request's snapshot — treat the values as advisory and always honour a
429.
Ingest requests are limited per key instead: 60 requests per minute and a
daily listing budget (200 by default). Exhausting either returns
429 with Retry-After; the same
X-RateLimit-* headers describe the minute window.
Data is provided as-is. You may use it to power search, agents, and aggregations that link back to otjobs.au as the source. Please do not re-host the listings as a competing directory. Listing content belongs to the hiring organisations.
GET /api/v1/jobs — paginated live listings.
Filters: category (area-of-practice code, see
/meta), state (nsw/qld/vic/...),
employment_type, q (free text),
updated_since (ISO date/datetime),
page, per_page (default 25, max 100).GET /api/v1/jobs/{id} — one listing; 404 if it is not live.GET /api/v1/meta — categories, states, employment types,
live counts, freshness. Call this first for vocabulary and volume.POST /api/v1/ingest/listings — authenticated batch create
(see below).Every listing includes job_posting_jsonld: schema.org
JobPosting structured data, identical to the markup embedded in the HTML
detail pages. Each listing also carries date_posted and
valid_through. The employer block carries
name, logo_url, and phone when the
employer supplied one; the phone is published because it is the listing's
public call route. Employer email addresses are never exposed.
Each listing also carries a metrics block with
apply_count (how many times the Apply button sent a candidate to
the employer, counted server-side, lifetime) and
last_apply_click_at. It is a lifetime total, not a period figure;
per-employer period totals are an internal report, not part of this API.
Applications by email never pass through the redirect, so email-only listings
report zero regardless of demand.
POST /api/v1/ingest/listings requires
Authorization: Bearer <key>. Keys are issued by the site
owner and carry ingest:write; a missing/unknown/revoked/expired
key returns 401, and a key without the scope returns
403.
The body is a batch envelope. Accepted fields use the same vocabulary as
the site's submission form: title, company,
location, location_area, state,
postcode, description,
application_url, source_url,
valid_through, employer_name, employer_email,
employer_phone, workplace_type, classification,
employment_type (list of codes), area_of_practice
(list of codes), the salary block (salary_min,
salary_max, salary_currency,
salary_period, salary_extras,
is_pro_rata), logo_base64, the boolean benefit
flags, and source_id (the source's stable id, required for
idempotent re-ingest).
valid_through is an optional ISO 8601 date or datetime. It
must be in the future and no more than 365 days away. When omitted, an
approved-on-create listing gets LISTING_DURATION_DAYS from the
site configuration; a draft keeps a NULL expiry until it is published.
employer_phone is optional and is published:
the detail page renders it as a tel: call button, JSON-LD
carries it as hiringOrganization.telephone, and the read API
exposes it as employer.phone. It must be dialable (a leading
+, digits, and the separators - . ( ); space is
stripped from the link), so free text is rejected with
INVALID_EMPLOYER_PHONE.
Salary is optional but must be coherent. salary_min and
salary_max are numbers of at most 8 integer digits and 2
decimals (the column limit); salary_period is one of
year, hour, month, week
or fortnight; salary_currency is AUD
or NZD (defaults to AUD when an amount is given);
salary_extras is free text up to 100 characters (for example
"+ super + salary packaging"); is_pro_rata is a
boolean. See /api/v1/meta for the current
vocabulary.
A salary amount always requires salary_period.
An amount without a period is rejected with MISSING_SALARY_PERIOD,
because structured data has no honest default unit and an hourly rate must
never be published as an annual salary. salary_max below
salary_min is INVALID_SALARY; unknown period,
currency or an over-long salary_extras are
INVALID_SALARY_PERIOD, INVALID_CURRENCY and
FIELD_TOO_LONG. Fortnightly rates are published as weekly in
schema.org, which has no fortnight unit.
Logos are uploaded as bytes, not fetched from a URL.
logo_base64 is optional: a base64 string (raw, or a
data:image/png;base64,... URL) of a JPEG, PNG or WebP image up
to 2MB. There is deliberately no logo_url field, so the server
never makes a caller-controlled request. The bytes are decoded with Pillow
before they are stored: the format is taken from the image itself, never
from a Content-Type or a filename, and an image above
6000×6000 pixels is refused so a decompression bomb cannot exhaust
memory. A bad logo rejects only its item: INVALID_LOGO (not a
decodable JPEG/PNG/WebP, or too many pixels) or LOGO_TOO_LARGE
(over 2MB).
Once stored, the existing image pipeline converts the logo to WebP (when
supported) and generates the card thumbnail; the read API exposes it as
employer.logo_url. Omitting logo_base64 leaves the
current logo alone. Re-ingesting the same bytes returns
unchanged without writing another file, and re-ingesting
different bytes replaces the logo and its thumbnail without leaving the old
files behind. A request body carrying many megabytes of logos is refused
with HTTP 400 before parsing; keep batches with large images
small (the request cap is 10MB).
New listings are drafts by default. The status is chosen
by the key, never by the payload: a status field in an item is
ignored, and the listing's source attribution is the key's
source. Re-submitting the same (source, source_id) updates a
draft but never changes its status, so owner decisions survive re-ingest.
A published or rejected listing is not edited at all: such an item comes
back with ok: true and action: "skipped",
reason_code: "LISTING_PUBLISHED" and a detail saying nothing was
applied. skipped is deliberately distinct from
unchanged: a genuine no-op means your values already matched,
while skipped means the API refused them. Corrections to a
live listing are made by the operator with the
correct_listing management command, not through this endpoint.
Drafts are absent from every public surface (listings, detail pages,
sitemap, feed, JSON-LD, this read API, alerts and search-engine queues) and
receive no slug until published.
Each item needs at least one contact route: source_url,
application_url or employer_email. An item without
one is rejected with reason_code
MISSING_APPLY_ROUTE. Items are also checked against the same
model validation the admin uses, so ingest cannot create a listing that the
admin would refuse to save.
curl -X POST "https://otjobs.au/api/v1/ingest/listings" -H "Authorization: Bearer $OTJOBS_API_KEY" -H "Content-Type: application/json" -d '{"listings": [{
"source_id": "wise-ot-2026-10-27-1",
"title": "Occupational Therapist — Community",
"company": "Wise OT Solutions",
"location": "Sydney",
"state": "nsw",
"description": "Community OT role...
",
"application_url": "https://example.com/apply",
"employment_type": ["full_time"],
"area_of_practice": ["ndis"],
"employer_phone": "+61 417 723 910",
"salary_min": 80,
"salary_max": 100,
"salary_period": "hour",
"is_pro_rata": true,
"logo_base64": "iVBORw0KGgo..."
}]}'
The response is HTTP 200 with a per-item verdict
(created, updated, unchanged,
skipped or rejected with a stable
reason_code) plus a summary and remaining daily
quota. A malformed envelope returns 422.
The API is the primary machine-readable source; these cover the same public data in other shapes.
<link rel="alternate" type="application/atom+xml">, and
legacy paths such as /feed/, /rss and
/atom.xml permanently redirect to it.
curl "https://otjobs.au/api/v1/meta"
curl "https://otjobs.au/api/v1/jobs?state=nsw&per_page=5"
curl "https://otjobs.au/api/v1/jobs?category=paediatric&state=vic"
curl "https://otjobs.au/api/v1/jobs?q=newcastle&updated_since=2026-10-01"
curl "https://otjobs.au/api/v1/jobs/123"
curl -X POST "https://otjobs.au/api/v1/ingest/listings" -H "Authorization: Bearer $OTJOBS_API_KEY" -H "Content-Type: application/json" -d '{"listings": [...]}'