# 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.

**What this covers:** curl; Python; JavaScript and TypeScript; Go.

**Assumed knowledge:** [Authentication](./api-authentication.md) and [pagination](./api-pagination.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/recipes/languages  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

> **There is no SDK, and that is the recommendation** — This is a read-only JSON API with one header. A client is thirty lines in any language, and thirty lines you own beats a dependency you have to wait on. The programs below are complete — copy one and change the filters.

---

<a id="recipe-curl"></a>

## 1. curl

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.

### 1.1 In depth

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.

### 1.2 Exact contract

The patterns worth remembering

```bash
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}'
```

> **Quote your cursors** — `next_cursor` contains characters that are unsafe unquoted in a shell and unsafe unencoded in a URL. `--data-urlencode "cursor=$cursor"` handles both.

### 1.3 Worked examples

Keyless, from a cold start

```console
$ curl -s 'https://api.jobopportunitiesapi.org/public/jobs?country=FR&limit=3' \
  | jq -r '.data[] | "\(.company) — \(.title)"'
{
  "data": [
    {
      "id": "d531cd51-a696-420f-b0de-cef3cb8453c9",
      "slug": "vendeurs-f-h-cdi-35h-cdi-16h-cdi-25h-annecy-epagny-d531cd51",
      "title": "Vendeurs (f/h) - CDI 35h/ CDI 16h/ CDI 25h - Annecy Epagny",
      "company": "H&M Group",
      "company_slug": "h-m-group",
      "company_logo": "https://supabase-erioun.tzekos.eu/storage/v1/object/public/company-logos/logos/h-m.png",
      "category_confidence": null,
      "country": "FR",
      "city": "Epagny",
… 106 more lines
```

_Real response, fetched from `/public/jobs?country=FR&limit=3` when this file was built (8 September 2026, 16:22 UTC)._

**See also**

- [Python](./recipes-languages.md#recipe-python)
- [next_cursor is opaque — this is the rule that bites](./api-pagination.md#cursor-opacity)

<a id="recipe-python"></a>

## 2. Python

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

### 2.1 In depth

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.

### 2.2 Exact contract

joa.py — a complete client

```python
"""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
```

**See also**

- [JavaScript and TypeScript](./recipes-languages.md#recipe-javascript)
- [Backing off correctly](./api-rate-limits.md#backoff)
- [field_sources — per-field provenance](./ledger-provenance.md#field-sources)

<a id="recipe-javascript"></a>

## 3. JavaScript and TypeScript

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

### 3.1 In depth

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.

### 3.2 Exact contract

joa.ts — a typed client with an async generator

```typescript
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

```javascript
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 })));
```

**See also**

- [CORS and calling from a browser](./api-overview.md#cors-and-browsers)
- [A job board](./recipes-build.md#recipe-job-board)
- [Nullability, in one table](./api-fields.md#nullability)

<a id="recipe-go"></a>

## 4. Go

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

### 4.1 In depth

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](./api-fields.md#nullability).

### 4.2 Exact contract

joa.go — types and paging

```go
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](./api-errors.md#retry-policy). Together the two files are a complete client in about a hundred lines.

**See also**

- [A retry policy that is correct](./api-errors.md#retry-policy)
- [Nullability, in one table](./api-fields.md#nullability)
- [A bulk export, done properly](./recipes-sync.md#recipe-bulk-export)

---

## Where to go next

This file is part of **Recipes**. Others in the same group:

- [Keeping data current](./recipes-sync.md) — Four jobs you will end up writing: a daily country pull, an incremental sync, closure detection, and a bulk export — with the record cost of each.
- [Things to build](./recipes-build.md) — Three complete builds — a job board, an employer watcher, a CRM enrichment job — and an honest list of which integrations exist today.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
