StartupKit
EN

Public Jobs API

Build your own job site (e.g. with Next.js on Vercel) on top of your Kit hiring pipeline. List published roles and receive applications through a public REST API, an official TypeScript SDK, and a one-click Next.js template.

Why It Matters

Kit’s hosted career portal and embeddable widget 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’s also an official TypeScript SDK and a one-click Next.js template so you can ship a custom job site in minutes, or skip both and call the REST endpoints below directly with any HTTP client.

For a working customer example, see Fourthwall’s custom careers site. The overview also covers Kit’s Next.js starter.

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, and it never exposes candidate data. Before it can submit applications or request presigned uploads, you must configure at least one bot defence (an origin allowlist or your own Cloudflare Turnstile widget), otherwise those requests are rejected with 403 bot_protection_required. Reading jobs works without it.
  • 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://startupkit.app or https://app.startupkit.app (the SDK default; both serve the same API), or your career custom domain.

List published jobs

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

Returns only published roles for your account.

{
  "data": [
    {
      "id": "JdK2hQ8…",
      "title": "Senior Rails Developer",
      "department": "Engineering",
      "location": "Poznań, Poland",
      "employment_type": "full_time",
      "remote": true,
      "location_display": "Poznań, Poland · Remote (Poland, EU)",
      "city": "Poznań",
      "region": null,
      "country_code": "PL",
      "remote_regions": ["PL", "EU"],
      "published_at": "2026-06-01T12:00:00Z",
      "url": "https://careers.yourco.com/JdK2hQ8…",
      "apply_url": "https://careers.yourco.com/JdK2hQ8…/apply",
      "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. The employment_type value is one of full_time, part_time, b2b, contract, or internship. You can pass the same value to the list endpoint to filter published roles.

Location and remote roles

Show location_display. It is the finished place label: the location, the word “Remote”, and where remote candidates can be based, each said once (Poznań, Poland · Remote (Poland, EU), Berlin, Remote). Pass locale=de, fr, es, or pl to get it in that language; the default is English.

Don’t join location and remote yourself. location is free text exactly as the recruiter typed it and may already say “Remote”, which is how a board ends up printing it twice.

The structured fields are for filters, sorting, and Google for Jobs markup:

Field Description
city, region The role’s office or city, when known. Otherwise null.
country_code ISO 3166-1 alpha-2 country of that place (PL), or null.
remote true when the role can be done remotely.
remote_regions Where remote candidates can be based: ISO 3166-1 alpha-2 codes, plus EU for any EU member state. Empty when the role is not remote or names no restriction.

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, whether a resume is required plus its accepted types/size, and whether Turnstile is required.

{
  "id": "JdK2hQ8…",
  "title": "Senior Rails Developer",
  "description_html": "<p>We're hiring…</p>",
  "accepting_applications": true,
  "stages": [
    { "name": "Application Review", "type": "application_form" },
    {
      "name": "Work sample",
      "type": "code_assignment",
      "compensation": { "amount": 250, "currency": "USD" }
    }
  ],
  "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": {
      "required": false,
      "content_types": ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
      "max_byte_size": 10485760
    },
    "turnstile": { "required": false, "sitekey": null }
  }
}

When a hiring stage offers one-time compensation, that stage includes a compensation object with a numeric amount in whole currency units and the configured currency code. The field is omitted for unpaid stages. It describes compensation for completing that stage, not the role’s salary, and never exposes the candidate’s payment method or payment status. The list endpoint stays compact and does not include stages; fetch the job detail when you need process or compensation information.

required flags are enforced server-side. When resume.required is true, an application without a resume_signed_id is rejected with 422 validation_failed; the same happens for any unanswered field or question marked required: true. If you render the form dynamically from this schema, honor these flags so candidates don’t hit a rejection after filling everything in.

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" } }
{
  "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": "[email protected]",
    "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:

{ "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.

Talent Pool

Not everyone who likes your company fits an open role today. The talent pool captures those people (email, LinkedIn, optionally a CV) so you can invite them when the right role opens. Entries land unverified: Kit emails a confirmation link, and nobody joins your pool through this endpoint until they click it. (Admins can also import CVs directly, which is a separate door with its own rules.)

A talent-pool signup is someone asking you to keep their details on file with no job to apply for, so it needs explicit consent: a real ticked checkbox, not a disclosure you show them. Kit rejects a public signup that omits it. An admin import records a lawful-basis attestation instead, because nobody is there to tick anything.

Read the intake form

GET /api/public/v1/talent_pool
{
  "accepting_signups": true,
  "consent": {
    "required": true,
    "disclosure_html": "<p>Keep my details on file for 24 months…</p>",
    "retention_months": 24,
    "privacy_policy_url": "https://yourco.com/privacy"
  },
  "fields": [
    { "name": "email", "required": true },
    { "name": "linkedin_url", "required": false },
    { "name": "resume_signed_id", "required": false }
  ],
  "resume": {
    "required": false,
    "content_types": ["application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
    "max_byte_size": 10485760
  },
  "turnstile": { "required": false, "sitekey": null }
}

Render consent.disclosure_html as the label of a checkbox that starts unchecked. It’s your own consent text; edit it under Hiring → Settings → Consent, together with retention_months, which controls when Kit anonymizes entries nobody renewed.

Join the talent pool

POST /api/public/v1/talent_pool/entries
{
  "talent_pool_entry": {
    "email": "[email protected]",
    "linkedin_url": "https://linkedin.com/in/ada",
    "resume_signed_id": "eyJf…",
    "consent": true,
    "consent_ip_address": "203.0.113.7"
  },
  "turnstile_token": "<token>"
}

Returns 201 with a PII-free confirmation:

{ "id": "tpe_9fQ…", "status": "pending_verification", "submitted_at": "2026-06-11T09:30:00Z" }

CVs use the same presigned upload as applications: request a signed_id, PUT the bytes, then pass it as resume_signed_id.

Recording who consented

Kit stores a consent receipt with every entry: the exact text shown, the timestamp, and the IP address of the person who accepted. That last field is where server-side integrations quietly go wrong.

Submitting from a browser with a pk_ key, Kit observes the IP itself and ignores any consent_ip_address you send. A key that ships in page JavaScript must not be able to write its own receipt.

Submitting from your server with an sk_ key, the IP Kit sees is your server’s, not the applicant’s. Send the real one as consent_ip_address; in a Vercel Server Action, that’s the first hop of x-forwarded-for:

import { headers } from "next/headers";

const forwarded = (await headers()).get("x-forwarded-for");
const consentIp = forwarded?.split(",")[0]?.trim();

This value is asserted by you, not proven: Kit stores what you send once it parses as an IP address, and rejects anything else with 422 invalid_consent_ip. Omit it if you don’t know. Kit then records no IP rather than a receipt naming the wrong machine.

Job Analytics

Kit’s Hiring → Analytics dashboard (views, unique visitors, traffic sources, UTM campaigns, countries, devices, landing pages and the view → apply funnel) fills in from your own site too. Your site reports what visitors do; Kit records it exactly as it records visits to the hosted career portal, so every chart and the per-job analytics tab work unchanged.

Until your site sends events, the dashboard shows a reminder whenever one of your API keys has been used in the selected period.

Events come from the visitor’s browser with a publishable (pk_) key. Kit reads the visitor’s (masked) IP, device and country from that request, so relaying events through your server would record every visitor as your server; secret keys are refused with publishable_key_required. If the key has an origin allowlist, add your site’s origin to it.

Send events

POST /api/public/v1/events

{
  "events": [
    {
      "name": "job.viewed",
      "job": "abc123",
      "url": "https://careers.acme.com/jobs/abc123?utm_source=linkedin",
      "referrer": "https://www.linkedin.com/",
      "time": "2026-09-22T10:00:00.000Z"
    }
  ]
}
Event When job
job_board.viewed The jobs list page is shown Not required
job.viewed A job page is shown Required
application.started The visitor first interacts with the apply form Required
application.submitted An application was accepted Required
talent_pool.joined A talent-pool signup was accepted Not required
  • job is the job’s id (public token) from GET /jobs.
  • url is the page the event happened on (location.href) and referrer is document.referrer. The first event of a visit sets its landing page, referring domain and utm_* campaign. A url on a host other than the request’s Origin is ignored, and a referrer from your own site counts as direct traffic.
  • time is optional. Kit keeps it only if it falls within the last minute, otherwise it records the time the event arrived.
  • Send up to 20 events per request. Crawlers are dropped silently.

The response is 202 Accepted with a per-event verdict, so one mistyped token doesn’t discard the rest of the batch:

{ "accepted": 1, "rejected": [{ "index": 1, "code": "unknown_job" }] }

Per-event codes are unknown_event, job_required and unknown_job.

The TypeScript SDK (0.5 and later) wraps all of this in createTracker. It batches events, flushes them when the tab closes, does nothing during server rendering, and never throws:

import { createTracker } from "@startupkit-app/jobs";

const tracker = createTracker({ publishableKey: process.env.NEXT_PUBLIC_STARTUPKIT_PUBLISHABLE_KEY });

tracker.jobBoardViewed();
tracker.jobViewed(job.id);
tracker.applicationStarted(job.id);
tracker.applicationSubmitted(job.id);
tracker.talentPoolJoined();

Errors

Errors return a consistent envelope:

{ "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 bot_protection_required Publishable (pk_) key has no origin allowlist or Turnstile configured
403 origin_not_allowed Browser origin not in the key’s allowlist
403 publishable_key_required Analytics events sent with a secret (sk_) key
404 not_found Job not found or not published
409 already_applied This email already applied to this job
409 already_in_talent_pool This email is already in the talent pool
422 validation_failed Invalid application fields, including a missing required resume or an unanswered required field or question (see fields)
422 consent_required Talent-pool signup arrived without an accepted consent checkbox
422 invalid_consent_ip consent_ip_address is not a valid IP address
422 turnstile_failed Turnstile verification failed
422 too_many_events More than 20 analytics events in one request
422 invalid_content_type / file_too_large / invalid_byte_size Rejected resume upload

Status Updates via Webhooks

To track applications after submission, configure outbound 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

Two official, open-source starting points sit on top of the REST contract above. Use either, or skip both and call the endpoints directly with any HTTP client.

  • TypeScript SDK: @startupkit-app/jobs. A typed, zero-dependency client (native fetch, ESM + CJS, Node ≥ 20.19) that runs in Node, browsers, and edge runtimes. Install with npm install @startupkit-app/jobs, then:

    import { createClient } from "@startupkit-app/jobs";
    
    const kit = createClient({ secretKey: process.env.KIT_SECRET_KEY });
    
    const page = await kit.listJobs({ department: "Engineering", remote: true });
    const job = await kit.getJob(page.data[0].id);
    const { signed_id } = await kit.uploadFile(resumeFile);
    await kit.apply(job.id, { email: "[email protected]", resume_signed_id: signed_id });
    

    Pass publishableKey (pk_…) instead of secretKey in browser code. Other methods: allJobs() async-iterates every page, createUpload() gives lower-level control over the presigned upload, and getTalentPool() / joinTalentPool() cover the talent-pool intake above, and createTracker() sends job analytics. Non-2xx responses throw KitApiError (with .code and .fields); failures that never reach the API throw KitNetworkError. The client defaults to the https://app.startupkit.app base URL; pass baseUrl to override it for a custom career domain.

  • Next.js template: nextjs-job-board. A production-ready careers site (Next.js App Router, Server Components + Server Actions, ISR with tag-based revalidation) you can fork or one-click deploy. It renders the application form dynamically from the API schema, does direct-to-storage presigned resume uploads, emits schema.org JobPosting JSON-LD (Google for Jobs ready), and optionally revalidates instantly via webhooks. Set one environment variable, STARTUPKIT_SECRET_KEY (your sk_… key), and deploy:

    Deploy with Vercel

    Live demo: nextjs-job-board-orcin.vercel.app. Optional environment variables: STARTUPKIT_BASE_URL (defaults to https://app.startupkit.app), NEXT_PUBLIC_TURNSTILE_SITE_KEY, KIT_WEBHOOK_SIGNING_SECRET, and NEXT_PUBLIC_COMPANY_NAME.

Both consume the contract above, so you can also build against any framework using plain HTTP. A typical custom job site wires up four calls: list jobs, fetch a job with its form, request a presigned upload for the resume, and submit the application. Because these are plain JSON over HTTPS, the same integration works from a server (sk_ key) or the browser (pk_ key with a bot defence configured).

External Job Page Convention

To receive visitors from Kit’s job-board links, your site must serve job details at {careers_homepage}/jobs/{public_token}. Use the API’s job id (the public token), not the numeric database ID or a title-only slug. For a homepage at https://example.com/careers, the detail URL is https://example.com/careers/jobs/senior-engineer-abc123. If you deploy the Next.js template under /careers, configure Next.js basePath accordingly.

Enable the external homepage and Show job pages on your external careers site in Career Portal settings only after those pages work. Existing accounts keep Kit job pages until they opt in.

Ordinary public Kit job URLs follow the external job-page setting when the job is public and accepting applications. This includes shared links, job-board links, and the job url returned by the public API. Add destination=kit to explicitly render Kit’s job page. Turning the option off makes those same public URLs show Kit’s page again. Embeds, JSON responses, and Kit application routes stay on Kit. Homepage URL query parameters and fragments are not copied into the external job path.

Use the API’s apply_url for Kit’s application form; it always stays on Kit. For older API responses without this field, append /apply only when url uses an active Kit careers domain or a valid Kit /careers/:slug/:public_token route; preserve its query parameters. If url points to an unverified company domain, update the API integration and use apply_url instead of constructing an application link. Kit forwards utm_source, utm_medium, utm_campaign, utm_content, utm_term, and an explicitly requested locale. Preserve campaign parameters when linking from the external job page to your application form so attribution survives. Use destination=kit for a link back to Kit’s job description. You can also use your own API-backed application page. The template uses its own /jobs/{public_token}/apply page.

Older links ending in #apply remain compatible: the browser opens Kit’s dedicated application route before following an external redirect. Other ordinary HTML visits continue to the external job page. With JavaScript disabled, the handoff page offers both View job and Apply here. HEAD requests redirect directly to the external job page. Use apply_url or /{public_token}/apply for new application links. Signed-in candidates view job descriptions inside their candidate portal, with a link back to their applications.

Keep this route stable when redesigning your site. A missing public token should return 404; a closed role should clearly explain that applications are closed. Test a real job-board link and its application flow before enabling redirects. Redirect destinations must use HTTPS on a verified company domain; Kit never fetches the external site to validate it.

Rate Limits

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

Analytics events are limited to 60 requests/minute and 600/hour per IP, and 10,000 requests/hour per key. They are exempt from the per-key API limit, which would otherwise cap a busy site shared by all its visitors.

Talent-pool signups are limited to 100/hour per key. Browser (pk_) signups carry an additional 5/hour per IP; server (sk_) signups don’t, since every one of them reaches Kit from the same egress address and a per-IP ceiling would cap your whole site rather than one person.

Type to search...