This tutorial walks through a complete integration of Techmap's Job Posting API: from the first request to a scheduled sync job that keeps a local database of postings up to date and feeds a job board. The examples use curl, Python and JavaScript and stick to the parameters and fields documented in the API overview and on the developer page.
If you are still deciding whether an API is the right tool at all, start with Understanding Job APIs: A Comprehensive Guide. If you are new to calling REST APIs from JavaScript, Getting Started with APIs in JavaScript covers the basics.
What you will build
By the end you will have:
- An API key on the free Basic plan.
- A working search request with filters.
- A paginated fetch that handles large result sets.
- A local table of postings, keyed and deduplicated.
- A daily incremental sync with retries and backoff.
- A job board page that renders postings, including schema.org structured data.
The API covers job postings from the last 3 months, collected from 200+ sources (ATS career pages, job boards, aggregators and employment offices) in 250 countries and territories. Each request returns up to 10 postings.
Step 0: Define what you actually need
Before writing code, write down the query that describes your use case. It determines your filters, your request volume and whether the API is the right product. Typical integrations look like this:
| Use case | Typical filters | Endpoint |
|---|---|---|
| Job board backfill | countryCode, title or occupation, industry, isDuplicate=false | /search |
| Hiring trend analysis | countryCode, industry, monthly dateCreated | /count |
| Company monitoring / sales leads | company, isDirect=true | /search |
| Skill demand mapping | skills, countryCode | /count |
| Local or regional job feed | geoPointLat, geoPointLng, geoDistance | /search |
The difference matters for cost: /count returns a number for one request, while fetching postings via /search costs one request per 10 postings.
Step 1: Get an API key (free tier)
The Job Posting API is distributed through RapidAPI.
- Create an account at rapidapi.com/auth/sign-up. No credit card is required.
- Open the Job Postings API pricing page and subscribe to the Basic plan. It is free and includes 100 requests, i.e. 1,000 job postings per month.
- Copy your key from the RapidAPI dashboard.
Send the key in the X-RapidAPI-Key header of every request (the X-RapidAPI-Host header is optional), or as the URL parameter rapidapi-key. Keep it out of client-side code and version control; load it from an environment variable.
Requests above the included quota are not blocked but billed as overage. The paid plans are Pro ($29/month, 30,000 postings), Ultra ($99/month, 100,000 postings) and Mega ($299/month, 300,000 postings). Current prices and overage rates are on the pricing page.
Step 2: Make your first request
All endpoints live on the host https://daily-international-job-postings.p.rapidapi.com. The main endpoint is /api/v2/jobs/search. Here is the first page of US postings created this month ($(date +%Y-%m) inserts the current month, e.g. 2026-10; the API covers the last three months):
curl -X GET \
"https://daily-international-job-postings.p.rapidapi.com/api/v2/jobs/search?dateCreated=$(date +%Y-%m)&countryCode=us&page=1" \
-H "X-RapidAPI-Key: YOUR_RAPIDAPI_KEY"
The same request in Python:
import os
from datetime import date
import requests
BASE = "https://daily-international-job-postings.p.rapidapi.com/api/v2/jobs"
HEADERS = {"X-RapidAPI-Key": os.environ["RAPIDAPI_KEY"]} # YOUR_RAPIDAPI_KEY
resp = requests.get(f"{BASE}/search", headers=HEADERS, params={
"dateCreated": date.today().strftime("%Y-%m"), # current month
"countryCode": "us",
"page": 1,
}, timeout=30)
resp.raise_for_status()
data = resp.json()
print("Total matching jobs:", data["totalCount"])
for job in data["result"]:
print(job["title"], "-", job.get("company", ""), "-", job.get("city", ""))
One detail to remember from the start: if you omit dateCreated, the API defaults to today minus 2 days. Always set it explicitly in production code.
Step 3: Narrow the results with filters
The search endpoint accepts more than 35 filters. The ones you will use most:
- Time:
dateCreated(YYYY-MMfor a month,YYYY-MM-DDfor a day), or a range withdateCreatedMinanddateCreatedMax(cannot be combined withdateCreated). - Location:
countryCode(ISO 3166-1 alpha-2),city,state,postCode, a radius search withgeoPointLat,geoPointLngandgeoDistance(e.g.50mior25km), or a bounding box. - Role:
title,occupation(normalized stem, e.g.programmer),skills,industry,department,careerLevel. - Conditions:
workPlace(remote,hybrid,onsite, ...),workType,contractType,hasSalary,minSalary. - Quality and origin:
isDuplicate=false,isActive=true,isDirect=true,isRecruiter=false,portal,source,language.
Text parameters (title, skills, company, industry, occupation) support boolean operators: comma-separated values are OR, a %2B prefix (URL-encoded +) means AND, a - prefix means NOT, and double quotes match an exact phrase. Senior data engineers in Germany who know both Python and SQL, excluding data scientists:
curl -G "https://daily-international-job-postings.p.rapidapi.com/api/v2/jobs/search" \
-H "X-RapidAPI-Key: YOUR_RAPIDAPI_KEY" \
--data-urlencode 'title="data engineer",-scientist' \
-d "skills=%2BPython,%2BSQL" \
-d "countryCode=de" -d "careerLevel=senior" -d "dateCreated=$(date +%Y-%m)" -d "isDuplicate=false"
Always write the AND prefix as %2B, as in the API reference. A raw + in a query string decodes to a space, so the API would receive " Python, SQL" and run an OR search instead.
Two habits save time and money:
- Size the query first.
/api/v2/jobs/counttakes the same filters and returns onlytotalCount. Like every call it counts as one request, but it tells you whether a sync will need 5 requests or 5,000. - Discover valid values. On the Pro plan and above,
/api/v2/meta/jobs/distinct?field=industrylists all values of a field (with job counts) over the last 30 days, which is handy for filter dropdowns./api/v2/meta/jobs/statisticsreturns the distribution per value.
Step 4: Paginate through the results
/search returns 10 postings per page. The response envelope contains totalCount, page and pageSize, plus the result array. There are two strategies.
Page-based with page=1, 2, ... works for result sets up to roughly 10,000 records (about 1,000 pages). Use it for targeted queries.
Cursor-based is for anything larger: send cursor=true on the first request, then pass the nextCursor value from each response. Stop when nextCursor is absent. A cursor cannot be combined with page.
One trap with page-based paging: combined with sort=newest, newly found postings shift the pages while you page through them, so postings can show up twice or be skipped. The API reference recommends the cursor for complete, stable paging - use it for every sync.
def fetch_all(params):
"""Yield every posting matching params, using cursor pagination."""
cursor = "true"
while cursor:
data = get_with_retry(f"{BASE}/search", {**params, "cursor": cursor})
yield from data.get("result", [])
cursor = data.get("nextCursor") # absent on the last page
get_with_retry is defined in Step 7. Using a generator means you can write postings to the database as they arrive instead of holding everything in memory.
Step 5: Parse the response
A trimmed response looks like this:
{
"api": "Techmap.io Job Posting API",
"page": 1,
"pageSize": 10,
"totalCount": 48231,
"result": [
{
"title": "Senior Data Engineer",
"company": "Example Corp",
"city": "Austin",
"countryCode": "us",
"dateCreated": "2026-09-10T09:00:00.000Z",
"dateActive": "2026-10-10T09:00:00.000Z",
"language": "en",
"workPlace": ["Hybrid"],
"careerLevel": ["Senior"],
"hasSalary": true,
"minSalary": 140000,
"skills": ["Python", "Spark", "dbt", "Airflow"],
"isDirect": true,
"isDuplicate": false,
"portal": "linkedin",
"source": "linkedin_us",
"jsonLD": {
"@context": "https://schema.org",
"@type": "JobPosting",
"identifier": "66f4d794a315a1138e6bfac2",
"title": "Senior Data Engineer",
"description": "## About the Role\n\nWe are looking for ...",
"url": "https://example.com/jobs/456",
"datePosted": "2026-09-10"
}
}
]
}
The fields you need for most integrations:
jsonLD.identifier: the ID of the posting record in Techmap's database. Use it as the upsert key per record. It is not a job ID across sources: the same job published on several boards arrives as several records with different identifiers - that's whatisDuplicateis for.jsonLD.description: the full posting text as Markdown.jsonLD.url: the link where the posting was found, i.e. your "Apply" link.jsonLD.hiringOrganization: company name, website and a cached logo URL.dateCreatedanddateActive: when the company created the posting, and its assumed expiry (from schema.orgvalidThrough, otherwisedateCreatedplus 30 days).isDuplicate: whether a posting with the same company, city, country and title was already seen within 3 days. The API doesn't say which posting it duplicates.
Many fields are sparse. state, postCode, department, workPlace, careerLevel and salary fields are only filled when the original posting contains the information, and array fields can hold "N/A". Code defensively with .get() or optional chaining and don't build required columns on optional fields. All fields with their coverage are listed in the field reference.
Step 6: Store and deduplicate
A minimal schema keeps the filterable fields as columns and the full posting as JSON, so you can add columns later without refetching. SQLite is enough for a prototype; the same upsert works in PostgreSQL with ON CONFLICT.
import json
import sqlite3
db = sqlite3.connect("jobs.db")
db.execute("""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, -- jsonLD.identifier
title TEXT,
company TEXT,
city TEXT,
country_code TEXT,
date_created TEXT,
date_active TEXT,
apply_url TEXT,
raw TEXT -- full posting as JSON
)""")
def upsert(job):
ld = job.get("jsonLD") or {}
db.execute("""
INSERT INTO jobs (id, title, company, city, country_code,
date_created, date_active, apply_url, raw)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title, date_active = excluded.date_active,
raw = excluded.raw
""", (ld.get("identifier"), job.get("title"), job.get("company"),
job.get("city"), job.get("countryCode"), job.get("dateCreated"),
job.get("dateActive"), ld.get("url"), json.dumps(job)))
Deduplication happens on two levels:
- Same posting fetched twice (overlapping sync windows, retries): the primary key on
jsonLD.identifierturns the second insert into an update. - Same job published on several portals: request with
isDuplicate=falseso the API leaves out postings already flagged as duplicates. If you want to keep them, for example to count how widely an employer advertises, group them yourself by company, city, country and title and treat the one with the smallestdateCreatedas the first publication.
If you merge postings from other providers or your own direct listings into the same table, you need your own rule on top, typically normalized company plus title plus city within a time window, mirroring the API's 3-day logic.
Step 7: Handle errors and rate limits
The status codes you should plan for:
| Status | Meaning | What to do |
|---|---|---|
400 | Invalid parameter value, e.g. a malformed date | Read message in the body, it names the parameter. Don't retry. |
401 | Missing or invalid API key | Check the header and that you call the RapidAPI host. Don't retry. |
403 | Not subscribed, or endpoint not in your plan | Body contains message, requiredPlan and pricingUrl. Don't retry. |
429 | Too many requests per second for your plan | Back off exponentially and retry. |
500, 503 | Server error or search backend temporarily unavailable | Back off exponentially and retry. |
There is no monthly hard limit, so 429 only means "slow down", not "quota exhausted". Rate limits per plan and the quota headers RapidAPI returns are documented under Pagination, rate limits & quota; log those headers so you notice before overage becomes expensive.
import time
RETRYABLE = {429, 500, 503}
def get_with_retry(url, params, max_attempts=5):
for attempt in range(max_attempts):
resp = requests.get(url, headers=HEADERS, params=params, timeout=30)
if resp.status_code == 200:
return resp.json()
if resp.status_code not in RETRYABLE:
# 400/401/403: a retry will not help, surface the API's message
raise RuntimeError(f"{resp.status_code}: {resp.text}")
time.sleep(min(2 ** attempt, 60)) # 1, 2, 4, 8, ... seconds
raise RuntimeError(f"Giving up after {max_attempts} attempts: {resp.status_code}")
Network timeouts deserve the same backoff treatment; wrap requests.exceptions.Timeout and ConnectionError in the same loop if your environment is flaky.
Step 8: Schedule an incremental daily sync
A one-off backfill is easy. Keeping the table current is the real integration work. The pattern:
- Run once a day (cron, a scheduled cloud function, Airflow, or a workflow tool).
- Query a short, overlapping date window with
dateCreatedMinanddateCreatedMax. - Upsert every posting by ID.
- Remove or hide postings whose
dateActivelies in the past.
Why overlap? dateCreated is the date the company created the posting, not the date Techmap found it. New postings are searchable within minutes of being found, but a posting created three days ago can still be discovered today. The API's own default reflects that: without dateCreated it queries the current day minus 2 days, treating the most recent days as not yet complete. A 3-day window catches these late arrivals; the upsert makes the overlap harmless.
A 200 response is always complete (API changelog, v2.7.2), so when you re-query a whole window, postings of that window that are missing from the response can safely be treated as removed. The trade-off is cost, because re-fetched postings count as requests again, so keep the window as short as your freshness requirement allows.
from datetime import date, timedelta
def daily_sync(country="us", window_days=3):
today = date.today()
params = {
"countryCode": country,
"dateCreatedMin": (today - timedelta(days=window_days)).isoformat(),
"dateCreatedMax": today.isoformat(),
"isDuplicate": "false",
"title": "developer,engineer", # your use-case filters
}
expected = get_with_retry(f"{BASE}/count", params).get("totalCount", 0)
print(f"{expected} postings in window, ~{expected // 10 + 1} requests")
n = 0
for job in fetch_all(params):
upsert(job)
n += 1
db.execute("DELETE FROM jobs WHERE date_active < ?", (today.isoformat(),))
db.commit()
print(f"Upserted {n} postings")
if __name__ == "__main__":
daily_sync()
Schedule it with cron, for example at 06:00 every day:
0 6 * * * cd /opt/jobsync && RAPIDAPI_KEY=YOUR_RAPIDAPI_KEY python sync.py >> sync.log 2>&1
The /count call up front acts as a budget check: if the window suddenly contains ten times more postings than usual, your filters have probably changed, and you can abort before burning requests. If you prefer no-code orchestration, Techmap also offers integrations for n8n, Zapier and Pipedream, plus an MCP server for AI assistants.
Step 9: Display the jobs on your job board
Serve your job board from your own database, never by calling the API on each page view. That keeps pages fast, your API usage predictable, and your key on the server.
For each posting you need a list card (title, company, city, date) and a detail page. Three points are specific to job data:
- Render the Markdown description.
jsonLD.descriptionis Markdown, so convert it to HTML with a Markdown library and sanitize the output, as it originates from third-party pages. - Embed the structured data.
jsonLDis a schema.orgJobPostingobject that follows Google's job posting structured data format, so you can embed it as-is for search engines. Validate a few pages with Google's Rich Results Test before going live. - Link to the source. Use
jsonLD.urlas the apply link so candidates land on the original posting.
A minimal server-side renderer in JavaScript (Node.js 18+), using the marked and sanitize-html packages:
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";
const esc = (s = "") =>
String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
export function renderJobPage(job) {
const ld = job.jsonLD ?? {};
const body = sanitizeHtml(marked.parse(ld.description ?? ""));
// Escape "<" so the JSON cannot close the script tag early
const structuredData = JSON.stringify(ld).replace(/</g, "\\u003c");
return `
<article>
<h1>${esc(job.title)}</h1>
<p>${esc(job.company ?? "")} · ${esc(job.city ?? "")} · ${esc(job.dateCreated?.slice(0, 10))}</p>
<div class="description">${body}</div>
<a href="${esc(ld.url)}" rel="nofollow noopener" target="_blank">Apply on the original site</a>
</article>
<script type="application/ld+json">${structuredData}</script>`;
}
For filter dropdowns, query your own table (SELECT DISTINCT industry ...) or, on Pro and above, seed them from the /meta/jobs/distinct endpoint. If your job board runs on a hosted platform that imports RSS (jBoard, NiceBoard and similar), you can skip most of this tutorial: the RSS API takes the key as a URL parameter, so the feed URL can be pasted straight into the importer.
Step 10: Know when to switch to bulk data feeds
The API is built for filtered, real-time access. Some integrations outgrow it:
- You need every posting of a country. Paginating a whole country through 10-posting pages is slow and expensive: a query with 100,000 matches needs 10,000 requests, which takes close to three hours at the Basic plan's 1 request per second. The Job Data Feeds deliver one gzip-compressed JSON Lines file per country and day via AWS Data Exchange, priced by the country's posting volume at about $1 per 1,000 new postings a month (from $9 per country and month). That is about the API's rate, but without any pagination, and 3- or 12-month contracts lower it further - for whole countries the feed is usually cheaper and simpler.
- You need more than 3 months of history. The API and the feeds cover the last 3 months. Historical datasets go back to January 2020, from $49 per country as a one-time purchase, depending on the size of the archive.
- You run a data warehouse or ML pipeline. Bulk ETL from S3 fits those stacks better than HTTP pagination.
The feeds use the same underlying data, but the file format differs from the API response (for example the title is in name and the company in a company object). The field schema is described in the Job Data Overview, and How to Use Job Data Feeds: A Step-by-Step Guide is the companion to this tutorial. A free Luxembourg dataset lets you test the file workflow before subscribing. If you run a job board and are weighing API against feeds, The Planner's Guide to Job Board Backfill covers the trade-offs.
Checklist
- Key stored in an environment variable, never in frontend code.
dateCreatedor a date range always set explicitly.- Query sized with
/countbefore large fetches. - Cursor pagination for anything above ~10,000 results.
- Upsert keyed on
jsonLD.identifier,isDuplicate=falseon requests. - Retries with exponential backoff for
429,500and503only. - Daily sync with an overlapping window and expiry cleanup via
dateActive. - Job pages served from your database, Markdown sanitized,
jsonLDembedded.
Full parameter tables, response schema and more code examples are in the Job API Overview and the developer resources. To start, subscribe to the free Basic plan from the Job Posting API page.
