Skip to content

Job Board API

External integrations can ingest job data by authenticating against Laravel Passport and invoking the Job Board endpoints.

Authentication

  1. Generate Passport signing keys (once per environment):

    bash
    ./vendor/bin/sail artisan passport:keys --force
  2. Create a client-credentials client for the integration:

    bash
    ./vendor/bin/sail artisan passport:client --client --name="Job Board Ingestion"

    The command prints a client_id and client_secret. Store them securely.

  3. Exchange the credentials for an access token (example for a local Sail environment):

    bash
    curl -X POST http://localhost:8000/oauth/token \
      -H 'Content-Type: application/x-www-form-urlencoded' \
      -d 'grant_type=client_credentials' \
      -d 'client_id=<CLIENT_ID>' \
      -d 'client_secret=<CLIENT_SECRET>' \
      -d 'scope=job-postings:write'

    The response contains access_token, token_type, and expiry information.

  4. Use the access_token in the Authorization: Bearer … header for all Job Board API calls.

The job-postings:write scope is required; client tokens without it are rejected by the API middleware.

Endpoints

MethodPathDescriptionAuth Requirements
PUT/api/job-board/job-postings/{uniqueSourceIdentifier}Create, update, or expire a job postingBearer token with job-postings:write scope

PUT /api/job-board/job-postings/{uniqueSourceIdentifier}

Unified endpoint for managing job postings. The uniqueSourceIdentifier is a unique string identifier from the external source (e.g., scraper) used to identify and deduplicate job postings.

Why a Unified Endpoint?

  • Idempotent ingestion: callers can safely retry create/update/expire requests for the same identifier.
  • Simpler integrations: a single URL and payload shape covers create, update, and expiration flows.
  • Consistent validation: all mutations share the same authentication, validation, and audit trail.

Behavior

  • Create: If no job posting exists with the given uniqueSourceIdentifier, a new one is created.
  • Update: If a job posting exists, it is updated with the provided fields (partial updates supported).
  • Expire: If the payload contains an expiration object, the job posting is expired.

Response Codes

CodeDescription
201 CreatedNew job posting created
200 OKExisting job posting updated or expired
404 Not FoundJob posting not found (when expiring non-existent posting)
422 Unprocessable EntityValidation error or invalid state transition
401 UnauthorizedMissing or invalid authentication

Creating a Job Posting

When a job posting with the given uniqueSourceIdentifier does not exist, a new one is created.

Example Request

bash
curl -X PUT "https://example.com/api/job-board/job-postings/scraper-linkedin-12345" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Senior UX Designer",
    "raw_description": "<p>We are looking for a Senior UX Designer to join our team...</p>",
    "employment_types": ["full-time"],
    "location_types": ["remote", "hybrid"],
    "location_country_code": "US",
    "location_city": "San Francisco",
    "salary_min": 120000,
    "salary_max": 180000,
    "salary_currency_code": "USD",
    "salary_period": "annually",
    "application_url": "https://company.com/careers/apply/123",
    "metadata": {
      "source": "linkedin",
      "external_id": "linkedin-job-789",
      "scraped_at": "2026-01-31T15:00:00Z"
    },
    "company": {
      "name": "Acme Design Co",
      "website_url": "https://acmedesign.com",
      "logo_path": "logos/acme-design.png"
    }
  }'

Example Response (201 Created)

json
{
  "data": {
    "uuid": "01234567-89ab-cdef-0123-456789abcdef"
  }
}

Required Fields (Create Only)

FieldTypeDescription
titlestringJob title (max 255 chars)
raw_descriptionstringHTML job description
companyobjectCompany details (see below)
company.namestringCompany name (max 255 chars)
company.website_urlstringValid URL
application_email or application_urlstringAt least one is required

Updating a Job Posting

When a job posting with the given uniqueSourceIdentifier exists, it is updated with the provided fields. Only the fields included in the request are updated (partial updates).

Example Request

bash
curl -X PUT "https://example.com/api/job-board/job-postings/scraper-linkedin-12345" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Senior UX Designer (Updated)",
    "salary_max": 200000
  }'

Example Response (200 OK)

json
{
  "data": {
    "uuid": "01234567-89ab-cdef-0123-456789abcdef"
  }
}

Expiring a Job Posting

To expire a job posting, include an expiration object in the payload. The job posting must exist.

Example Request

bash
curl -X PUT "https://example.com/api/job-board/job-postings/scraper-linkedin-12345" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "expiration": {
      "reason": "Position filled"
    }
  }'

Example Request (No Reason)

bash
curl -X PUT "https://example.com/api/job-board/job-postings/scraper-linkedin-12345" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "expiration": {}
  }'

Example Response (200 OK)

json
{
  "data": {
    "uuid": "01234567-89ab-cdef-0123-456789abcdef"
  }
}

Error: Job Posting Not Found (404)

json
{
  "message": "Job posting not found."
}

Error: Invalid State Transition (422)

If the job posting is in a state that cannot transition to expired (e.g., rejected):

json
{
  "message": "Invalid JobPosting state transition from rejected to expired."
}

Field Reference

Job Posting Fields

FieldTypeRequired on CreateDescription
titlestringYesJob title (max 255 chars)
raw_descriptionstringYesHTML job description
employment_typesarrayNoEmployment types (see enum values below)
location_typesarrayNoLocation types (see enum values below)
location_country_codestringNoISO 3166-1 alpha-2 country code
location_citystringNoCity name (max 255 chars)
timezone_requirement_typestringNoType of timezone requirement
timezone_requirementstringNoSpecific timezone (e.g., Europe/Warsaw)
salary_minintegerNoMinimum salary (>= 0)
salary_maxintegerNoMaximum salary (must be >= salary_min)
salary_currency_codestringNoISO 4217 currency code (e.g., USD, EUR)
salary_periodstringNoSalary period (see enum values below)
application_emailstringNo*Valid email address
application_urlstringNo*Valid URL (max 2048 chars)
metadataobjectNoKey-value pairs for custom data
expiration_reasonstringNoReason for expiration (max 255 chars)

*On create, at least one of application_email or application_url is required.

Company Fields

FieldTypeRequiredDescription
company.namestringYes (on create)Company name (max 255 chars)
company.website_urlstringYes (on create)Valid URL (max 1024 chars)
company.logo_pathstringNoPath to company logo (max 1024 chars)

Expiration Fields

FieldTypeRequiredDescription
expirationobjectNoPresence triggers expiration
expiration.reasonstringNoReason for expiration (max 255 chars)

Prohibited Fields

These fields are managed internally and cannot be set via the API:

  • markdown_description
  • markdown_redacted_description
  • latitude
  • longitude
  • published_at
  • expire_at
  • is_featured
  • unique_source_identifier (set from URL parameter)

Sending any of these results in a 422 Unprocessable Entity response.


Enum Values

Employment Types

ValueDescription
full-timeFull-time position
part-timePart-time position
contractContract / freelance position
internshipInternship

Location Types

ValueDescription
remoteFully remote
on-siteOn-site only
hybridHybrid (remote + on-site)
otherOther / unspecified

Salary Periods

ValueDescription
hourlyHourly rate
weeklyWeekly rate
monthlyMonthly salary
annuallyAnnual salary

Notes

  • Company Upsert: If company.website_url matches an existing company, that company is updated. Otherwise, a new company is created.
  • Enrichment Workflow: After creation, job postings enter the PendingEnrichment status and are processed by the AI enrichment pipeline before becoming visible.
  • Idempotent Expiration: Expiring an already-expired job posting returns 200 OK without dispatching additional events.
  • Post-approval Updates: The API does not block updates to approved postings.