## Why It Matters

Kit's hosted [career portal](/docs/setting-up-your-career-portal) and [embeddable widget](/docs/embedding-your-career-portal) cover most needs. But if you want full control over design — a bespoke careers site, a custom landing page per role, or a job board that matches your product — the **Public Jobs API** lets you read your published jobs and submit applications straight into your Kit pipeline, while Kit keeps owning screening, stages, interviews, and candidate communication.

There is also an official one-click **Next.js template** and a **TypeScript SDK** so you can ship a custom job site in minutes.

## API Keys

Create a key pair under **Hiring → Career Portal → Public API Keys**. Each pair has:

- **Publishable key (`pk_…`)** — safe to ship in a browser. It can read published jobs and submit applications, nothing else. It never exposes candidate data. You can restrict it to specific origins and protect it with your own Cloudflare Turnstile widget.
- **Secret key (`sk_…`)** — for server-side use only (e.g. a Next.js Server Action). It skips the browser origin/Turnstile checks. **Never expose it in client-side code.** Neither key can read candidate PII.

The secret key is shown only once, when created or rotated. Rotate it any time from the key's settings page; the previous secret stops working immediately.

Authenticate every request with a bearer header:

```
Authorization: Bearer sk_your_secret_key
```

## Endpoints

Base URL: `https://app.startupkit.app` (or your career custom domain).

### List published jobs

```
GET /api/public/v1/jobs?department=&location=&employment_type=&remote=&page=&per_page=
```

Returns only **published** roles for your account.

```json
{
  "data": [
    {
      "id": "JdK2hQ8…",
      "title": "Senior Rails Developer",
      "department": "Engineering",
      "location": "Remote",
      "employment_type": "full_time",
      "remote": true,
      "published_at": "2026-06-01T12:00:00Z",
      "url": "https://careers.yourco.com/JdK2hQ8…",
      "salary": { "min": 120000, "max": 160000, "currency": "USD", "period": "YEAR" }
    }
  ],
  "pagination": { "current_page": 1, "total_pages": 3, "total_count": 42, "per_page": 20 }
}
```

The `id` is the job's public token — use it for the detail and apply endpoints.

### Get a job + its application form

```
GET /api/public/v1/jobs/:public_token
```

Returns the job plus an `application_form` describing exactly which fields and questions to render, the consent disclosure to show, accepted resume types/size, and whether Turnstile is required.

```json
{
  "id": "JdK2hQ8…",
  "title": "Senior Rails Developer",
  "description_html": "<p>We're hiring…</p>",
  "accepting_applications": true,
  "stages": [{ "name": "Application Review", "type": "application_form" }],
  "application_form": {
    "fields": [
      { "name": "cover_letter", "type": "textarea", "label": "Cover letter", "required": false }
    ],
    "questions": [
      { "key": "why_us", "type": "text", "prompt": "Why do you want to join?", "required": true, "max_length": 2000 }
    ],
    "consent_disclosure_html": "<p>By applying you agree…</p>",
    "resume": {
      "content_types": ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
      "max_byte_size": 10485760
    },
    "turnstile": { "required": false, "sitekey": null }
  }
}
```

### Upload a resume (presigned)

Resumes upload directly to storage, so they never pass through your server (avoiding serverless body-size limits).

```
POST /api/public/v1/direct_uploads
{ "blob": { "filename": "cv.pdf", "byte_size": 102400, "checksum": "<base64 MD5>", "content_type": "application/pdf" } }
```

```json
{
  "signed_id": "eyJf…",
  "direct_upload": { "url": "https://…s3…", "headers": { "Content-Type": "application/pdf", "Content-MD5": "…" } }
}
```

`PUT` the file bytes to `direct_upload.url` with the returned `headers`, then pass the `signed_id` as `resume_signed_id` when you submit the application.

### Submit an application

```
POST /api/public/v1/jobs/:public_token/applications
{
  "application": {
    "email": "candidate@example.com",
    "first_name": "Ada",
    "last_name": "Lovelace",
    "phone": "+1 555 0100",
    "responses": { "cover_letter": "…", "why_us": "…" },
    "resume_signed_id": "eyJf…"
  },
  "turnstile_token": "<token>"
}
```

Returns `201` with a minimal, PII-free confirmation:

```json
{ "id": "app_9fQ…", "status": "submitted", "job": "JdK2hQ8…", "submitted_at": "2026-06-11T09:30:00Z" }
```

`turnstile_token` is only needed for browser (`pk_`) submissions when the key has Turnstile configured; server-side (`sk_`) calls skip it.

## Errors

Errors return a consistent envelope:

```json
{ "error": { "code": "validation_failed", "message": "Email can't be blank", "fields": { "email": ["can't be blank"] } } }
```

| Status | Code | Meaning |
|---|---|---|
| 401 | `invalid_key` | Missing or invalid API key |
| 403 | `origin_not_allowed` | Browser origin not in the key's allowlist |
| 404 | `not_found` | Job not found or not published |
| 409 | `already_applied` | This email already applied to this job |
| 422 | `validation_failed` | Invalid application fields (see `fields`) |
| 422 | `turnstile_failed` | Turnstile verification failed |
| 422 | `invalid_content_type` / `file_too_large` | Rejected resume upload |

## Status Updates via Webhooks

To track applications after submission, configure [outbound webhooks](/docs/webhooks). Relevant events include `application.submitted`, `application.advanced`, and `application.rejected`, plus `job_posting.published/paused/closed`. Application payloads include both the numeric `id` and the API `prefix_id` (`app_…`), and the job's `public_token`, so you can correlate webhook events with API records.

## SDK & Next.js Template

- **TypeScript SDK** — `npm install @startupkit-app/jobs`. A typed, zero-dependency client with `listJobs`, `getJob`, `uploadFile`, and `apply`.
- **Next.js template** — a one-click [Deploy to Vercel](https://vercel.com) job board you can fork and customize. It wires up the secret key, dynamic application forms, presigned resume upload, and SEO/JSON-LD out of the box.

Both consume the contract above, so you can also build against any framework using plain HTTP.

## Rate Limits

Application submissions are limited to 10/hour per IP and upload requests to 30/hour per IP, alongside the global API rate limits. Browser keys are additionally protected by their origin allowlist and optional Turnstile.