Job Opportunities API

Check the data. Then trust it.

recipes

Client code

A working client in curl, Python, JavaScript and Go. No SDK, no dependencies, and every request re-sent against the live API before this page ships.

Last verified 2026-08-22 · Assumes: Authentication and pagination. · Markdown copy

1curl

The shortest path to a response, and the form to reach for when something is behaving strangely and you want to see the raw bytes.

In depthWhy it exists, what it is not, what people get wrong

Two habits make shell work with this API painless. Use --get with --data-urlencode rather than building a query string by hand — cursors and free-text values contain characters a shell will otherwise eat. And use -D - when debugging, because the X-RateLimit-* headers usually explain what the body does not.

Exact contractTypes, defaults, ranges, errors, edge cases
The patterns worth remembering
A=https://api.jobopportunitiesapi.org
H="Authorization: Bearer $JOA_KEY"

# Keyless, no header at all.
curl -s "$A/public/jobs?country=FR&limit=3" | jq '.data[].title'

# Authenticated, with parameters encoded properly.
curl -s -H "$H" --get "$A/v1/jobs" \
  --data-urlencode 'country=DE' \
  --data-urlencode 'category=Engineering' \
  --data-urlencode 'limit=5' | jq -r '.data[] | "\(.company)\t\(.title)"'

# Headers, when the body is not telling you enough.
curl -s -D - -o /dev/null -H "$H" "$A/v1/me"

# Streaming an export. -N disables buffering so lines arrive as they are written.
curl -sN -H "$H" "$A/v1/export?country=IE" | head -3 | jq -c '{id, title}'
Keyless, from a cold start
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=FR&limit=3' \
  | jq -r '.data[] | "\(.company) — \(.title)"'
{
  "data": [
    {
      "id": "a2c6366c-15aa-4674-a361-a166dd7bf8ab",
      "slug": "stagiaire-en-financement-de-projets-equipe-infrastructure-energy-resources-and-projects-h-f-temps-plein-juillet-a-decembre-2027-a2c6366c",
      "title": "Stagiaire en financement de projets - Equipe Infrastructure, Energy, Resources and Projects (H/F) -Temps plein - Juillet à décembre 2027",
      "company": "hoganlovells",
      "company_slug": "hoganlovells",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/hoganlovells.png",
      "category": "Finance",
      "category_confidence": 0.8,
      "country": "FR",
… 107 more lines
Real response, fetched from /public/jobs?country=FR&limit=3 when this page was built (11 September 2026, 02:27 UTC). Run the command yourself and you will get today's rows, not these.

2Python

A paging client in the standard library only — no requests, no httpx, nothing to install. About forty lines.

In depthWhy it exists, what it is not, what people get wrong

The house style in the examples repository is standard library only, and it is kept that way deliberately: an example with a dependency is an example that stops working when the dependency does, and it makes “does this API work” and “is my environment right” the same question.

The client below handles the three things a real one has to: it retries only the statuses that can succeed later, it treats the cursor as opaque, and it loops on has_more rather than on the length of the page.

Exact contractTypes, defaults, ranges, errors, edge cases
joa.py — a complete client
"""A minimal Job Opportunities API client. Standard library only.

    export JOA_KEY=...
    python3 joa.py
"""
import json, os, random, time, urllib.error, urllib.parse, urllib.request

API = "https://api.jobopportunitiesapi.org"
KEY = os.environ["JOA_KEY"]

# 429 = slow down. 503 = we were too slow this time. Everything else is a
# decision: 402 means the month's records are spent, 401/403 mean the
# credential is wrong, 422 means a value we sent is not one the API will guess at.
RETRYABLE = {429, 503}


def get(path, **params):
    url = f"{API}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(
        url, headers={"Authorization": f"Bearer {KEY}", "Accept": "application/json"}
    )
    for attempt in range(5):
        try:
            with urllib.request.urlopen(req, timeout=90) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code not in RETRYABLE or attempt == 4:
                raise RuntimeError(f"{e.code} {e.read().decode()[:300]}") from None
            # The API says how long. On a daily breach that can be hours.
            wait = int(e.headers.get("Retry-After") or 2 ** attempt)
            time.sleep(wait + random.uniform(0, 1))


def jobs(**filters):
    """Yield every row matching the filters, paging with the cursor."""
    cursor = None
    while True:
        params = {**filters, "limit": filters.get("limit", 200)}
        if cursor:
            # Opaque. Store it, send it, never parse it.
            params["cursor"] = cursor
        page = get("/v1/jobs", **params)
        yield from page["data"]
        # has_more, not len(data): a short page can still have more behind it.
        if not page.get("has_more") or not page.get("next_cursor"):
            return
        cursor = page["next_cursor"]


if __name__ == "__main__":
    print(get("/v1/me"))
    for n, job in enumerate(jobs(country="IE", category="Engineering"), 1):
        # field_sources before the field: a remote flag we inferred is not the
        # same fact as one the employer stated.
        stated = job["field_sources"]["remote"] == "published"
        print(f"{job['company']}\t{job['title']}\tremote_stated={stated}")
        if n >= 20:
            break

3JavaScript and TypeScript

fetch, an async generator for paging, and a reminder that a key belongs on your server rather than in a browser bundle.

In depthWhy it exists, what it is not, what people get wrong

For a keyless front end, call /public/* directly — CORS is open and there is nothing to protect. For anything keyed, the call goes on your server: anything in a browser bundle is public, and the record meter bills whoever holds the key.

Exact contractTypes, defaults, ranges, errors, edge cases
joa.ts — a typed client with an async generator
const API = 'https://api.jobopportunitiesapi.org';

export type FieldSource = 'published' | 'inferred' | 'absent';

export interface Job {
  id: string; slug: string; title: string;
  company: string; company_slug: string;
  country?: string; city?: string; location?: string;
  remote?: 'remote' | 'hybrid' | 'on_site';
  remote_inferred: boolean;            // always present, including when false
  category?: string; category_confidence: number | null;
  salary_min?: number; salary_currency?: string; salary_period?: string;
  salary_source?: 'structured' | 'parsed_description';
  posted_at?: string; first_seen_at?: string; last_verified_at: string;
  status: 'live' | 'closed'; closed_at: string | null; closed_reason: string | null;
  apply_url?: string; source: string; source_type: string;
  has_description: boolean;
  // Do NOT model this as a closed set of keys you switch on exhaustively —
  // new values are a compatible change.
  field_sources: Record<string, FieldSource>;
}

interface Page { data: Job[]; next_cursor: string | null; has_more: boolean }

async function request(path: string, params: Record<string, string>, key: string) {
  const res = await fetch(`${API}${path}?${new URLSearchParams(params)}`, {
    headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
  });
  if (res.status === 429 || res.status === 503) {
    const wait = Number(res.headers.get('Retry-After') ?? 5);
    await new Promise(r => setTimeout(r, wait * 1000 + Math.random() * 1000));
    return request(path, params, key);
  }
  if (!res.ok) throw new Error(`joa ${res.status}: ${await res.text()}`);
  return res.json() as Promise<Page>;
}

/** Every row matching the filters. Server-side only — the key is a secret. */
export async function* jobs(filters: Record<string, string>, key: string) {
  let cursor: string | null = null;
  for (;;) {
    const params = { ...filters, limit: filters.limit ?? '200' };
    if (cursor) params.cursor = cursor;   // opaque; URLSearchParams encodes it
    const page = await request('/v1/jobs', params, key);
    for (const job of page.data) yield job;
    if (!page.has_more || !page.next_cursor) return;
    cursor = page.next_cursor;
  }
}
…and the keyless version, which is safe in a browser
const params = new URLSearchParams({ country: 'NL', limit: '20' });
const res = await fetch(`https://api.jobopportunitiesapi.org/public/jobs?${params}`);
const { data } = await res.json();
console.table(data.map(j => ({ company: j.company, title: j.title, city: j.city })));

4Go

A struct that matches the row, a paging function, and the retry policy from the errors page.

In depthWhy it exists, what it is not, what people get wrong

Go is worth showing in full because its strictness surfaces the nullability rules that other languages let you ignore. The struct below uses pointers for the fields that are omitted when empty and plain values for the fifteen that are always present — which is exactly the split in the nullability table.

Exact contractTypes, defaults, ranges, errors, edge cases
joa.go — types and paging
package joa

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
)

const base = "https://api.jobopportunitiesapi.org"

// Required fields are values; everything the API omits when empty is a pointer.
// remote_inferred is deliberately NOT a pointer: it is always present, including
// when false, so that a stated value and an absent one are distinguishable.
type Job struct {
	ID             string            `json:"id"`
	Slug           string            `json:"slug"`
	Title          string            `json:"title"`
	Company        string            `json:"company"`
	CompanySlug    string            `json:"company_slug"`
	Country        *string           `json:"country"`
	City           *string           `json:"city"`
	Remote         *string           `json:"remote"`
	RemoteInferred bool              `json:"remote_inferred"`
	Category       *string           `json:"category"`
	SalaryMin      *float64          `json:"salary_min"`
	SalarySource   *string           `json:"salary_source"`
	PostedAt       *string           `json:"posted_at"`
	LastVerifiedAt string            `json:"last_verified_at"`
	Status         string            `json:"status"`
	ClosedAt       *string           `json:"closed_at"`
	ClosedReason   *string           `json:"closed_reason"`
	ApplyURL       *string           `json:"apply_url"`
	Source         string            `json:"source"`
	SourceType     string            `json:"source_type"`
	HasDescription bool              `json:"has_description"`
	// map, not a struct with fixed keys: new values are a compatible change.
	FieldSources   map[string]string `json:"field_sources"`
}

type page struct {
	Data       []Job   `json:"data"`
	NextCursor *string `json:"next_cursor"`
	HasMore    bool    `json:"has_more"`
}

// Jobs calls fn for every row matching the filters, paging with the cursor.
func Jobs(c *http.Client, key string, filters url.Values, fn func(Job) error) error {
	var cursor *string
	for {
		q := url.Values{}
		for k, v := range filters {
			q[k] = v
		}
		if q.Get("limit") == "" {
			q.Set("limit", "200")
		}
		if cursor != nil {
			q.Set("cursor", *cursor) // opaque: pass it back, never parse it
		}
		res, err := Get(c, key, "/v1/jobs?"+q.Encode()) // Get: see the errors page
		if err != nil {
			return err
		}
		var p page
		err = json.NewDecoder(res.Body).Decode(&p)
		res.Body.Close()
		if err != nil {
			return fmt.Errorf("joa: decode: %w", err)
		}
		for _, j := range p.Data {
			if err := fn(j); err != nil {
				return err
			}
		}
		if !p.HasMore || p.NextCursor == nil {
			return nil
		}
		cursor = p.NextCursor
	}
}

Get is the retrying helper from the retry policy section. Together the two files are a complete client in about a hundred lines.

This page was rendered 11 September 2026, 02:27 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.