Appearance
Job Board API
External integrations can ingest job data by authenticating against Laravel Passport and invoking the Job Board endpoints.
Authentication
Generate Passport signing keys (once per environment):
bash./vendor/bin/sail artisan passport:keys --forceCreate a client-credentials client for the integration:
bash./vendor/bin/sail artisan passport:client --client --name="Job Board Ingestion"The command prints a
client_idandclient_secret. Store them securely.Exchange the credentials for an access token (example for a local Sail environment):
bashcurl -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.Use the
access_tokenin theAuthorization: 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
| Method | Path | Description | Auth Requirements |
|---|---|---|---|
PUT | /api/job-board/job-postings/{uniqueSourceIdentifier} | Create, update, or expire a job posting | Bearer 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
expirationobject, the job posting is expired.
Response Codes
| Code | Description |
|---|---|
201 Created | New job posting created |
200 OK | Existing job posting updated or expired |
404 Not Found | Job posting not found (when expiring non-existent posting) |
422 Unprocessable Entity | Validation error or invalid state transition |
401 Unauthorized | Missing 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)
| Field | Type | Description |
|---|---|---|
title | string | Job title (max 255 chars) |
raw_description | string | HTML job description |
company | object | Company details (see below) |
company.name | string | Company name (max 255 chars) |
company.website_url | string | Valid URL |
application_email or application_url | string | At 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
| Field | Type | Required on Create | Description |
|---|---|---|---|
title | string | Yes | Job title (max 255 chars) |
raw_description | string | Yes | HTML job description |
employment_types | array | No | Employment types (see enum values below) |
location_types | array | No | Location types (see enum values below) |
location_country_code | string | No | ISO 3166-1 alpha-2 country code |
location_city | string | No | City name (max 255 chars) |
timezone_requirement_type | string | No | Type of timezone requirement |
timezone_requirement | string | No | Specific timezone (e.g., Europe/Warsaw) |
salary_min | integer | No | Minimum salary (>= 0) |
salary_max | integer | No | Maximum salary (must be >= salary_min) |
salary_currency_code | string | No | ISO 4217 currency code (e.g., USD, EUR) |
salary_period | string | No | Salary period (see enum values below) |
application_email | string | No* | Valid email address |
application_url | string | No* | Valid URL (max 2048 chars) |
metadata | object | No | Key-value pairs for custom data |
expiration_reason | string | No | Reason for expiration (max 255 chars) |
*On create, at least one of application_email or application_url is required.
Company Fields
| Field | Type | Required | Description |
|---|---|---|---|
company.name | string | Yes (on create) | Company name (max 255 chars) |
company.website_url | string | Yes (on create) | Valid URL (max 1024 chars) |
company.logo_path | string | No | Path to company logo (max 1024 chars) |
Expiration Fields
| Field | Type | Required | Description |
|---|---|---|---|
expiration | object | No | Presence triggers expiration |
expiration.reason | string | No | Reason for expiration (max 255 chars) |
Prohibited Fields
These fields are managed internally and cannot be set via the API:
markdown_descriptionmarkdown_redacted_descriptionlatitudelongitudepublished_atexpire_atis_featuredunique_source_identifier(set from URL parameter)
Sending any of these results in a 422 Unprocessable Entity response.
Enum Values
Employment Types
| Value | Description |
|---|---|
full-time | Full-time position |
part-time | Part-time position |
contract | Contract / freelance position |
internship | Internship |
Location Types
| Value | Description |
|---|---|
remote | Fully remote |
on-site | On-site only |
hybrid | Hybrid (remote + on-site) |
other | Other / unspecified |
Salary Periods
| Value | Description |
|---|---|
hourly | Hourly rate |
weekly | Weekly rate |
monthly | Monthly salary |
annually | Annual salary |
Notes
- Company Upsert: If
company.website_urlmatches an existing company, that company is updated. Otherwise, a new company is created. - Enrichment Workflow: After creation, job postings enter the
PendingEnrichmentstatus and are processed by the AI enrichment pipeline before becoming visible. - Idempotent Expiration: Expiring an already-expired job posting returns
200 OKwithout dispatching additional events. - Post-approval Updates: The API does not block updates to approved postings.