OTJobs.au — Public API v1

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.

Rate limits

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.

Terms

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.

Endpoints

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.

Creating listings

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.

Other machine-readable surfaces

The API is the primary machine-readable source; these cover the same public data in other shapes.

Examples

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": [...]}'