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.
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
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}'$ 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
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
"""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:
break3JavaScript 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
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;
}
}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
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.