# Careers API — embed your jobs on your own website

Everything a company's web developer needs to list Offish jobs on the company
site and send applicants into the Offish pipeline. No API key: these endpoints
are public and read-only (except the apply form) and serve only jobs that are
**Open** and **Published** in Offish, for a tenant whose **career site is
enabled** (Recruitment → Career site & job boards).

Interactive reference (Swagger): `https://api.offish.works/api/docs` → tag
*Careers (public)*. Base URL for everything below: `https://api.offish.works`.

Your `{slug}` is shown on the Career site & job boards tab (it is the last
segment of your career page URL `https://offish.works/careers/{slug}`).

## Endpoints

| Method | Path | Returns |
| --- | --- | --- |
| GET | `/public/careers/{slug}` | company info + list of open, published jobs |
| GET | `/public/careers/{slug}/jobs/{jobSlug}` | one job with the full description (sanitised HTML) |
| POST | `/public/careers/{slug}/jobs/{jobSlug}/apply` | submit an application (multipart form) |
| GET | `/public/careers/{slug}/linkedin.xml` | LinkedIn Jobs XML feed (404 until enabled) |
| GET | `/public/careers/{slug}/indeed.xml` | Indeed XML feed (404 until enabled) |

Responses are JSON, UTF-8. `404` means the slug is unknown, the career site is
disabled, or the job is not public. Listing and job endpoints send
`Cache-Control: public, max-age=60`; the feeds `max-age=300` with an `ETag`.

### Rate limits

Per client IP, sliding 10-minute window: **600** reads of the listing / job
endpoints and **30** applications. Over the limit → `429` with `Retry-After`.
Cache the listing on your side (a minute is plenty) rather than calling it on
every page view.

## GET `/public/careers/{slug}`

```json
{
  "company": {
    "name": "BlockShoals Technologies Inc.",
    "tagline": "We build payment rails for SEA",
    "about": "…",
    "websiteUrl": "https://blockshoals.com",
    "slug": "blockshoals"
  },
  "jobs": [
    {
      "slug": "senior-backend-engineer-k3f2",
      "title": "Senior Backend Engineer",
      "employmentType": "REGULAR",
      "workplaceType": "HYBRID",
      "city": "Makati",
      "state": "Metro Manila",
      "country": "PH",
      "experienceLevel": "MID_SENIOR_LEVEL",
      "salary": "PHP 90,000–120,000 per month",
      "publishedAt": "2026-10-01T02:14:09.000Z",
      "excerpt": "You will own the ledger service and its…"
    }
  ]
}
```

| Field | Values / notes |
| --- | --- |
| `employmentType` | `REGULAR`, `PROBATIONARY`, `CONTRACTUAL` |
| `workplaceType` | `ON_SITE`, `HYBRID`, `REMOTE` |
| `experienceLevel` | `INTERNSHIP`, `ENTRY_LEVEL`, `ASSOCIATE`, `MID_SENIOR_LEVEL`, `DIRECTOR`, `EXECUTIVE`, or `null` |
| `salary` | preformatted text, or `null` when the company hides salary |
| `excerpt` | first 180 characters of the description, plain text |
| `publishedAt` | ISO-8601; jobs are ordered newest first |

Link each job to its canonical page:
`https://offish.works/careers/{slug}/jobs/{jobSlug}` — or to the apply form
directly: `…/jobs/{jobSlug}/apply`. Append `?source=website` (any short word,
max 40 chars) and the application is attributed to that source in the
recruiter's pipeline. `utm_source`, `utm_medium`, `utm_campaign` and
`tracking` are carried through too.

## GET `/public/careers/{slug}/jobs/{jobSlug}`

Same `company` block (without `about`) plus:

```json
{
  "job": {
    "slug": "senior-backend-engineer-k3f2",
    "title": "Senior Backend Engineer",
    "employmentType": "REGULAR",
    "workplaceType": "HYBRID",
    "city": "Makati", "state": "Metro Manila", "country": "PH",
    "experienceLevel": "MID_SENIOR_LEVEL",
    "salary": "PHP 90,000–120,000 per month",
    "publishedAt": "2026-10-01T02:14:09.000Z",
    "excerpt": "…",
    "descriptionHtml": "<p>You will own…</p><ul><li>…</li></ul>",
    "closed": false
  }
}
```

`descriptionHtml` is sanitised to `p b strong i em u br ul ol li` — safe to
inject as HTML. `closed: true` means the job was filled or closed after
publishing; show it as closed and hide the apply button.

## POST `/public/careers/{slug}/jobs/{jobSlug}/apply`

`multipart/form-data` (also accepts `application/x-www-form-urlencoded`
without a file).

| Field | Required | Limits |
| --- | --- | --- |
| `firstName`, `lastName` | yes | ≤ 80 chars |
| `email` | yes | valid email; one candidate record per email per company |
| `middleName` | no | ≤ 80 |
| `mobile` | no | ≤ 40 |
| `addressLine` | no | ≤ 200 |
| `currentEmployer` | no | ≤ 160 |
| `expectedSalary` | no | number, monthly |
| `coverLetter` | no | ≤ 3000 chars |
| `resume` | no | file, ≤ 10 MB: PDF, Word, text |
| `consent` | no | `true` when the applicant ticked your privacy notice |
| `source`, `tracking`, `utm_source`, `utm_medium`, `utm_campaign`, `landedAt` | no | attribution, ≤ 120 chars each |
| `website` | **leave empty** | honeypot — bots that fill it get a fake success and nothing is stored |

Response `201`:

```json
{ "ok": true, "id": "9f0e…", "message": "Application received." }
```

A second application from the same email to the same job is accepted and
noted on the existing application (`message` says so) rather than duplicated.
Validation errors come back as `400` with a `message` array.

## Minimal embed (vanilla JS)

```html
<div id="jobs"></div>
<script>
(async () => {
  const slug = 'blockshoals';
  const res = await fetch(`https://api.offish.works/public/careers/${slug}`);
  if (!res.ok) return;
  const { jobs } = await res.json();
  document.getElementById('jobs').innerHTML = jobs.length
    ? jobs.map(j => `
        <a class="job" href="https://offish.works/careers/${slug}/jobs/${j.slug}?source=website">
          <h3>${j.title}</h3>
          <p>${[j.city, j.workplaceType.replace('_', '-').toLowerCase()].filter(Boolean).join(' · ')}${j.salary ? ' · ' + j.salary : ''}</p>
        </a>`).join('')
    : '<p>No openings right now — check back soon.</p>';
})();
</script>
```

Server-side rendering (Next.js, WordPress PHP, a static-site build) works the
same way: fetch the listing at build/request time and render it; link out to
the Offish job or apply page, or post the apply form from your own page to the
apply endpoint.

## Browser cross-origin access (CORS)

The listing, job and apply endpoints allow requests from any origin
(`Access-Control-Allow-Origin: *`, no credentials), so the snippet above runs
directly in the visitor's browser on your domain. The rest of the Offish API
stays restricted to the Offish apps.

## Custom career domain

Point `careers.yourcompany.com` at the Offish career page? Not self-service
yet — ask Offish support; it is a Cloudflare custom-hostname setup on our side.
