# RolesAPI - full documentation Site: https://rolesapi.com API base: https://api.rolesapi.com Auth: Authorization: Bearer rk_... (signup at https://rolesapi.com/signup, 100 free credits, no card) --- # Authentication RolesAPI authenticates every request with a Bearer rk_ API key. Learn where to create keys, how to store them, and how to rotate them safely. RolesAPI authenticates every request with an API key passed as a Bearer token. Keys are prefixed `rk_` and are tied to your account, plan, and rate limit. ```bash curl "https://api.rolesapi.com/v1/me" \ -H "Authorization: Bearer rk_live_your_key" ``` ## Where do I create a key? In the [dashboard](https://rolesapi.com), open **API keys** and click **Create key**. Give it a name that describes where it will live (`production-worker`, `local-dev`). The full secret is shown exactly once, at creation time — after that, only the prefix is visible. If you lose a key, create a new one and revoke the old one; secrets are never redisplayed. ## How should I store keys? - Keep keys in environment variables or a secrets manager, never in source control. - Use one key per environment or service, so a leak has a small blast radius and revocation does not take down everything at once. - Never use an `rk_` key in browser or mobile code that ships to users. Call RolesAPI from your backend and proxy what the client needs. - Scrub keys from logs. The `Authorization` header should never be written to application logs or error trackers. ## What does an authentication failure look like? A missing header returns `401 missing_api_key`: ```json { "error": { "code": "missing_api_key", "message": "No API key provided. Pass it as: Authorization: Bearer rk_...", "request_id": "req_2c91d0aa" } } ``` A revoked, malformed, or unknown key returns `401 invalid_api_key`: ```json { "error": { "code": "invalid_api_key", "message": "The API key provided is invalid or has been revoked.", "request_id": "req_5b7e13f2" } } ``` Both are permanent for that key — do not retry with the same credentials. The full code list is in [Errors](/errors). ## How do I rotate a key? Rotation is a create-then-revoke sequence with zero downtime: 1. Create a new key in the dashboard. 2. Deploy the new key to your services. 3. Confirm traffic on the new key — `GET /v1/usage` shows which requests each key made. 4. Revoke the old key. Revocation is immediate; in-flight requests already authenticated are unaffected, and any later use returns `invalid_api_key`. Rotate on a schedule that matches your security policy, and immediately if you suspect a key has been exposed. --- # RolesAPI documentation RolesAPI is a REST API for Indeed job-postings data. Fetch role details, search postings, and export results with one Bearer-authenticated call. RolesAPI is a REST API for Indeed job-postings data. You send one HTTP request with a Bearer key; you get back a normalized JSON role object — title, company, location, salary, benefits, description — inside a consistent envelope. No browser automation, no parsing, no infrastructure to run. All requests go to a single base URL: ``` https://api.rolesapi.com ``` ## What does a call look like? ```bash curl "https://api.rolesapi.com/v1/roles/by-url?url=https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "location": "Austin, TX", "remote": false, "employment_type": "Full-time", "salary": { "min": 140000, "max": 175000, "currency": "USD", "period": "year", "source": "employer" }, "benefits": ["Health insurance", "401(k) matching", "Paid time off"], "description": "Acme Logistics is hiring a Senior Backend Engineer to build the services that power our fulfillment network...", "posted_at": "2026-06-28", "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718", "country": "us" }, "request_id": "req_8f3a2b1c" } ``` That call costs 1 credit. Every plan includes 100 free credits — no card required. ## Where should I start? - [Quickstart](/quickstart) — make your first call in under five minutes. - [Authentication](/authentication) — create and manage `rk_` API keys. - [Errors](/errors) — the error envelope and every error code. - [Rate limits](/rate-limits) — per-plan requests-per-minute and how to handle 429s. ## API reference - [Roles](/api/roles) — fetch a single role by URL or job key, plus batch retrieval. - [Search](/api/search) — keyword and location search, with optional full details. - [Listings](/api/listings) — curated lists: posted today, posted this week, remote. - [Jobs](/api/jobs) — poll async jobs and page through results. - [Webhooks](/api/webhooks) — get notified when async jobs finish. - [Account](/api/account) — plan, credits remaining, and usage records. ## Guides and recipes - [Async jobs](/async-jobs) — how batch and multi-page work runs in the background. - [Output formats](/output-formats) — JSON, CSV, and NDJSON on any results endpoint. - [Field projection](/field-projection) — trim responses to just the fields you need. - [Search then detail](/recipes/search-then-detail) — a runnable Python pipeline. - [Export to Google Sheets](/recipes/export-to-sheets) — three ways to land jobs in a sheet. - [AI agents](/ai-agents) — connect Claude, Cursor, and other agents over MCP. ## Coverage RolesAPI covers 60+ Indeed country editions. Pass `country=de`, `country=gb`, or any other edition code on role and search endpoints; the default is `us`. --- # Quickstart Make your first RolesAPI call in under five minutes — sign up, create an rk_ key, and fetch a full Indeed job posting as normalized JSON. This page takes you from zero to a full job-postings response in under five minutes. You need nothing but a terminal with `curl`. ## 1. Sign up and get 100 free credits Create an account at [rolesapi.com](https://rolesapi.com). The free plan includes 100 credits, one time, with no card required. One credit buys one role detail or one search page. ## 2. Create an API key In the dashboard, open **API keys** and create a key. Keys start with `rk_` and are shown once — copy it somewhere safe. See [Authentication](/authentication) for rotation and storage practices. ## 3. Make your first call Fetch a role straight from an Indeed viewjob URL: ```bash curl "https://api.rolesapi.com/v1/roles/by-url?url=https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718" \ -H "Authorization: Bearer rk_live_your_key" ``` The response: ```json { "data": { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "location": "Austin, TX", "remote": false, "employment_type": "Full-time", "salary": { "min": 140000, "max": 175000, "currency": "USD", "period": "year", "source": "employer" }, "benefits": ["Health insurance", "401(k) matching", "Paid time off"], "description": "Acme Logistics is hiring a Senior Backend Engineer to build the services that power our fulfillment network...", "posted_at": "2026-06-28", "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718", "country": "us" }, "request_id": "req_8f3a2b1c" } ``` That call used 1 credit. You have 99 left. ## How do I read the envelope? Every successful response has the same shape: | Field | Meaning | | --- | --- | | `data` | The object (or array) you asked for. | | `meta` | Present on list responses: `total`, `count`, `limit`, `offset`, `has_more`. See [Pagination](/pagination). | | `request_id` | A unique id for this request. Include it when contacting support. | Errors use a parallel envelope — `{ "error": { "code", "message", "request_id" } }` — documented in [Errors](/errors). ## What should I try next? - Search postings by keyword and location: [POST /v1/search](/api/search). - Pull up to 500 roles in one request: [POST /v1/roles/batch](/api/roles) and [Async jobs](/async-jobs). - Get CSV instead of JSON with `?format=csv`: [Output formats](/output-formats). - Trim responses with `?fields=title,company.name,salary.min`: [Field projection](/field-projection). - Connect an AI agent over MCP: [AI agents](/ai-agents). --- # Async jobs Batch retrieval, deep searches, and search-with-details run as async jobs. The 202 response, polling, progress, results, webhooks, and credit charging. Async jobs are how RolesAPI handles work too large for one request-response cycle. Instead of holding a connection open while hundreds of roles are processed, the API returns a job id immediately; you poll it (or receive a webhook) and pull results when it completes. ## Which endpoints run async? | Endpoint | When it runs async | | --- | --- | | `POST /v1/roles/batch` | Always — up to 500 roles per request. | | `POST /v1/search` | Only when `max_pages` is greater than 3. At 3 pages or fewer it responds synchronously. | | `POST /v1/search/with-details` | Always. | | `POST /v1/listings/posted-today`, `/posted-this-week`, `/remote` | Always. | ## What does the 202 response look like? An async submission returns HTTP 202 with a job object: ```json { "data": { "job_id": "5f0f6c2e-4b3a-4e9c-9c74-31d1f2a30c11", "status": "queued" }, "request_id": "req_3f0a77b1" } ``` Poll the job for `progress`: `progress.total` is the number of units the job will process (roles for batch, pages for a deep search). ## How do I poll a job? ```bash curl "https://api.rolesapi.com/v1/jobs/job_01hxyz" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": { "id": "5f0f6c2e-4b3a-4e9c-9c74-31d1f2a30c11", "type": "batch_detail", "status": "running", "progress": { "done": 45, "total": 120 } }, "request_id": "req_88b1c2d0" } ``` Poll every few seconds with backoff — polls count against your [rate limit](/rate-limits). The status lifecycle: | Status | Meaning | | --- | --- | | `queued` | Accepted, waiting for a worker. | | `running` | In progress; `progress.done` advances. | | `succeeded` | Finished; results are ready at `/results`. | | `timed_out` / `aborted` | Stopped before finishing; partial results, if any, remain available. | | `failed` | Could not complete; the job object includes an error. Partial results, if any, remain available. | ## How do I get the results? ```bash curl "https://api.rolesapi.com/v1/jobs/job_01hxyz/results?limit=100&offset=0" \ -H "Authorization: Bearer rk_live_your_key" ``` Results are a paginated list of full role objects in the standard envelope — see [Pagination](/pagination). Add `?format=csv` or `?format=ndjson` to export the whole set in one response — see [Output formats](/output-formats). ## Can I skip polling entirely? Yes. Register a webhook once with `POST /v1/webhooks` and RolesAPI will POST a signed `job.succeeded` (or `job.failed`) event to your URL whenever a job finishes: ```bash curl -X POST "https://api.rolesapi.com/v1/webhooks" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/rolesapi", "events": ["job.succeeded", "job.failed"]}' ``` See the [Webhooks guide](/webhooks-guide) for payloads and signature verification. ## How are credits charged? Credits are charged per processed role (or per search page), as the job runs — not at submission. A 120-role batch that completes costs 120 credits. If a job fails partway, you are charged only for the units actually processed, and their results remain retrievable. See [POST /v1/search](/api/search) for worked with-details math. --- # Errors Every RolesAPI error uses one envelope and one of nine stable codes. Full table of codes, HTTP statuses, meanings, and what to do about each. Every RolesAPI error, on every endpoint, uses one envelope and one of nine stable machine-readable codes. Branch on `error.code`, not on the message text — messages can change, codes will not. ```json { "error": { "code": "not_found", "message": "No role exists for job_key 'a1b2c3d4e5f60718' in country 'us'.", "request_id": "req_9d41c7be" } } ``` `request_id` also appears on successful responses. Include it in any support email to nikhil@landkit.pro — it lets us find the exact request. ## What are the error codes? | Code | HTTP status | Meaning | What to do | | --- | --- | --- | --- | | `missing_api_key` | 401 | No `Authorization` header was sent. | Send `Authorization: Bearer rk_...`. See [Authentication](/authentication). | | `invalid_api_key` | 401 | The key is malformed, unknown, or revoked. | Check for truncation; create a new key if the old one was revoked. Do not retry with the same key. | | `out_of_credits` | 402 | Your credit balance is zero. | Top up or upgrade in the dashboard, then retry. | | `forbidden` | 403 | The key is valid but not allowed to perform this action. | Check that you are using the right account's key. | | `not_found` | 404 | The role, job, or webhook does not exist (or the posting is no longer live). | Verify the id or URL. For roles, confirm the `country` parameter matches the posting's edition. | | `invalid_request` | 422 | A parameter is missing, out of range, or the wrong type. | Read `error.message` — it names the offending field. Fix the request; do not retry unchanged. | | `rate_limited` | 429 | You exceeded your plan's requests-per-minute. | Wait for the `Retry-After` header, then retry. See [Rate limits](/rate-limits). | | `internal_error` | 500 | Something failed on our side. | Retry idempotent GETs with backoff. If it persists, contact support with the `request_id`. | | `upstream_timeout` | 504 | The source did not respond in time. | Retry with backoff; transient by nature. For large workloads, prefer [async jobs](/async-jobs). | ## Which errors are safe to retry? - **429 `rate_limited`** — always retryable. Respect the `Retry-After` header (seconds); do not hammer before it elapses. - **500 and 504** — safe to retry for idempotent GET requests, with exponential backoff and a retry cap. For POSTs that create async jobs, check `GET /v1/jobs` before re-submitting so you do not create a duplicate job. - **4xx other than 429** — not retryable as-is. `401`, `402`, `403`, `404`, and `422` indicate something about the request or account must change first. A reasonable default policy: retry 429/500/504 up to three times, waiting `Retry-After` when present and otherwise 1s, 2s, 4s. ## Do failed requests consume credits? No. Credits are charged only for successfully processed work — a role detail delivered or a search page returned. Error responses, including rate limits and timeouts, are free. --- # Field projection Trim any RolesAPI response to the fields you need with ?fields= — comma lists, dot paths into nested objects, and array selectors [N] and [*]. Field projection trims a response down to only the fields you name, with the `?fields=` query parameter. Smaller payloads mean faster parsing and less to store — especially when you only need a title, a company name, and a salary from a full role object. ## What is the syntax? | Form | Meaning | Example | | --- | --- | --- | | Comma list | Select several top-level fields | `fields=title,location,posted_at` | | Dot descent | Select a field inside a nested object | `fields=company.name,salary.min` | | `[N]` | Select one array element by index (0-based) | `fields=benefits[0]` | | `[*]` | Select all array elements, then optionally descend | `fields=benefits[*]` | Forms combine freely in one parameter: `fields=title,company.name,salary.min,benefits[0]`. ## What does it look like in practice? Full object trimmed to three scalar paths: ```bash curl "https://api.rolesapi.com/v1/roles/a1b2c3d4e5f60718?fields=title,company.name,salary.min" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": { "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "salary": { "min": 140000 } }, "request_id": "req_1e6b0c44" } ``` Note the shape is preserved: `company.name` returns a `company` object containing only `name`, not a flattened key. Array selection: ```bash curl "https://api.rolesapi.com/v1/roles/a1b2c3d4e5f60718?fields=title,benefits[0]" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": { "title": "Senior Backend Engineer", "benefits": ["Health insurance"] }, "request_id": "req_6a2d84f9" } ``` On list-shaped responses, such as `GET /v1/jobs/{id}/results`, the projection applies to each element of `data`, so `fields=job_key,title,company.name` returns an array of slim objects. ## What does projection apply to? Projection shapes the `data` block only. The `meta` block and `request_id` are always returned in full, so pagination keeps working regardless of what you project. Requesting a path that does not exist on the object is not an error — the path is simply absent from the output. Projection also composes with [output formats](/output-formats): with `?format=csv`, the projected paths become exactly the CSV columns. Projection changes the size of the response, not its price — a projected role detail still costs 1 credit. --- # Output formats Get RolesAPI responses as JSON, CSV, or NDJSON with the format query parameter or the Accept header. CSV flattening rules and a streaming example. RolesAPI returns JSON by default, and can return CSV or NDJSON from results endpoints — useful for spreadsheets and stream processing without a conversion step on your side. ## How do I choose a format? Two ways, in order of precedence: 1. **Query parameter** — `?format=json|csv|ndjson`. If present, it always wins. 2. **`Accept` header** — `application/json`, `text/csv`, or `application/x-ndjson`. Used only when `format` is absent. ```bash # Query parameter (takes precedence) curl "https://api.rolesapi.com/v1/jobs/job_01hxyz/results?format=csv" \ -H "Authorization: Bearer rk_live_your_key" # Accept header (used when ?format is absent) curl "https://api.rolesapi.com/v1/jobs/job_01hxyz/results" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Accept: text/csv" ``` If both are present and disagree, the query parameter is honored and the header is ignored. ## How is JSON structured? The standard envelope: `data`, `meta` on lists, `request_id`. See [Quickstart](/quickstart) and [Pagination](/pagination). ## How does CSV flattening work? CSV output contains only the rows — no envelope. Nested role objects flatten by three rules: 1. **Nested objects become dot-path columns.** `company.name`, `salary.min`, `salary.currency`. 2. **Arrays are joined with `|`.** `benefits` becomes `Health insurance|401(k) matching|Paid time off`. 3. **Standard CSV quoting.** Values containing commas, quotes, or newlines are wrapped in double quotes, with inner quotes doubled (RFC 4180). The Acme Logistics role as a CSV row: ``` job_key,title,company.name,location,remote,employment_type,salary.min,salary.max,salary.currency,salary.period,benefits,posted_at,country a1b2c3d4e5f60718,Senior Backend Engineer,Acme Logistics,4.1,"Austin, TX",false,Full-time,140000,175000,USD,year,Health insurance|401(k) matching|Paid time off,2026-06-28,us ``` The header row is always present, and the column set is stable across rows of one response. Combine with [field projection](/field-projection) to control exactly which columns appear. ## How does NDJSON work? One JSON object per line, no envelope, no trailing commas — ideal for piping into stream processors or bulk-loading into a warehouse. Each line is one role: ```bash curl -s "https://api.rolesapi.com/v1/jobs/job_01hxyz/results?format=ndjson" \ -H "Authorization: Bearer rk_live_your_key" | while IFS= read -r line; do echo "$line" | jq -r '[.job_key, .title, .company.name] | @tsv' done ``` ``` a1b2c3d4e5f60718 Senior Backend Engineer Acme Logistics ``` Because NDJSON is line-delimited, you can begin processing the first role before the response finishes — no need to buffer the whole body as with a JSON array. ## Where are formats available? CSV and NDJSON apply to results-shaped endpoints, primarily `GET /v1/jobs/{id}/results`. Single-object endpoints such as `GET /v1/roles/{job_key}` and control-plane endpoints (`/v1/jobs`, `/v1/webhooks`, `/v1/me`) return JSON. --- # Pagination List endpoints paginate with limit and offset and report position in the meta envelope. How has_more works, with curl and Python loop examples. List responses in RolesAPI paginate with `limit` and `offset` query parameters and describe their position in the `meta` block of the envelope. The two endpoints you will page most are `GET /v1/jobs/{id}/results` and `GET /v1/usage`; `GET /v1/jobs` pages the same way. ## What is in the meta block? ```json { "data": [ { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer" } ], "meta": { "total": 412, "count": 100, "limit": 100, "offset": 0, "has_more": true }, "request_id": "req_7c2f90de" } ``` | Field | Meaning | | --- | --- | | `total` | Total items matching the request, across all pages. | | `count` | Items in `data` on this page. | | `limit` | The page size you asked for (or the default). | | `offset` | How many items were skipped before this page. | | `has_more` | `true` if `offset + count < total` — there is at least one more page. | ## How do I fetch every page? Loop until `has_more` is `false`, advancing `offset` by `count` each time. With curl and `jq`: ```bash OFFSET=0 while : ; do PAGE=$(curl -s "https://api.rolesapi.com/v1/jobs/job_01hxyz/results?limit=100&offset=$OFFSET" \ -H "Authorization: Bearer rk_live_your_key") echo "$PAGE" | jq -c '.data[]' HAS_MORE=$(echo "$PAGE" | jq '.meta.has_more') [ "$HAS_MORE" = "true" ] || break OFFSET=$((OFFSET + $(echo "$PAGE" | jq '.meta.count'))) done ``` With Python and httpx: ```python import httpx BASE = "https://api.rolesapi.com" HEADERS = {"Authorization": "Bearer rk_live_your_key"} def all_results(job_id: str, limit: int = 100): offset = 0 while True: r = httpx.get( f"{BASE}/v1/jobs/{job_id}/results", params={"limit": limit, "offset": offset}, headers=HEADERS, ) r.raise_for_status() body = r.json() yield from body["data"] if not body["meta"]["has_more"]: break offset += body["meta"]["count"] for role in all_results("job_01hxyz"): print(role["title"], "-", role["company"]["name"]) ``` ## Anything else to know? - Each page request is one API call against your [rate limit](/rate-limits), but paging through results consumes no additional credits — you paid when the job processed the roles. - Advance by `meta.count`, not by `limit` — the last page is usually short. - If you want the whole result set in one artifact instead of pages, request `?format=csv` or `?format=ndjson` on the results endpoint. See [Output formats](/output-formats). - `GET /v1/usage` supports `since` (ISO 8601 timestamp) alongside `limit` to bound the window before paging. See [Account](/api/account). --- # Rate limits RolesAPI rate limits by plan — free 20 rpm, monthly 200 rpm, annual 300 rpm. How 429s look, how Retry-After works, and how to go high-volume. RolesAPI limits requests per minute (rpm) by plan. Limits apply per account, across all keys. Credits and rate limits are independent: credits meter how much data you consume, rpm meters how fast you can ask for it. ## What is my limit? | Plan | Requests per minute | Credits included | | --- | --- | --- | | Free | 20 | 100 (one-time) | | Monthly ($5/mo) | 200 | 1,000/mo | | Annual ($54/yr) | 300 | 12,000/yr | ## What happens when I exceed it? The request is rejected with HTTP 429 and a `Retry-After` header giving the number of seconds to wait: ``` HTTP/1.1 429 Too Many Requests Retry-After: 12 ``` ```json { "error": { "code": "rate_limited", "message": "Rate limit exceeded: 20 requests per minute on the free plan. Retry after 12 seconds.", "request_id": "req_4a8e91c3" } } ``` Rate-limited requests consume no credits. ## How should my client behave? - **Respect `Retry-After`.** Sleep for the given seconds before retrying. Do not busy-loop; repeated requests inside the window are also rejected and prolong the wait. - **Smooth your bursts.** The window is per minute, but sending your whole minute's budget in the first second risks tripping the limit when polling or webhook handlers add their own calls. A simple client-side throttle (e.g. one request every `60 / rpm` seconds) keeps you comfortably inside the limit. - **Budget for polling.** Job-status polls count against the same limit as data calls. Poll every few seconds, not in a tight loop — or use a [webhook](/webhooks-guide) and do not poll at all. ## What if I need more throughput than my rpm allows? Do not fan out requests — hand the work to the API. [Async jobs](/async-jobs) are the high-volume path: - `POST /v1/roles/batch` accepts up to 500 roles in a single request. - `POST /v1/search/with-details` runs a search and fetches every role's details server-side. One request enqueues the whole workload; you then poll one job or receive one webhook. A 500-role batch costs one request against your rpm instead of 500, while credits are charged per processed role as usual. If you are sustaining high volume, the annual plan's 300 rpm plus batch endpoints covers most pipelines. For anything beyond that, contact nikhil@landkit.pro. --- # Webhooks guide Receive signed job.succeeded and job.failed events from RolesAPI. Payload shape, x-rolesapi-signature verification, retries, and secret handling. Outbound webhooks let RolesAPI notify your server the moment an [async job](/async-jobs) finishes, instead of you polling for status. Register an endpoint once and every job's completion event is delivered to it, signed so you can verify it came from us. ## How do I create a webhook? ```bash curl -X POST "https://api.rolesapi.com/v1/webhooks" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/rolesapi", "events": ["job.succeeded", "job.failed"] }' ``` ```json { "data": { "id": "wh_01habc", "url": "https://example.com/hooks/rolesapi", "events": ["job.succeeded", "job.failed"], "secret": "whsec_9f2c4e6a8b0d1f3a5c7e9b2d4f6a8c0e", "active": true, "created_at": "2026-07-01T09:15:00Z" }, "request_id": "req_0b3d55e7" } ``` The `secret` is shown once, in this response only. Store it immediately — later reads of the webhook return the object without the secret. If you lose it, delete the webhook and create a new one. There are four supported events: `job.succeeded`, `job.failed`, `job.timed_out`, and `job.aborted`. A webhook receives every matching event for jobs on your account. ## What does a delivery look like? RolesAPI POSTs JSON to your URL: ```json { "event": "job.succeeded", "delivered_at": "2026-07-01T09:32:41Z", "data": { "job": { "id": "5f0f6c2e-4b3a-4e9c-9c74-31d1f2a30c11", "type": "batch_detail", "status": "succeeded", "result_count": 120, "created_at": "2026-07-01T09:30:02Z", "started_at": "2026-07-01T09:30:03Z", "completed_at": "2026-07-01T09:32:41Z" } } } ``` The payload carries the job, not the roles — fetch results from `GET /v1/jobs/{id}/results` with your API key, in any [output format](/output-formats). ## How do I verify the signature? Every delivery includes the header: ``` x-rolesapi-signature: t=1751362361,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` - `t` is the Unix timestamp when the delivery was signed. - `v1` is `HMAC-SHA256(secret, ".")` in hex — the timestamp, a period, and the exact raw request body. Verification steps: 1. Read the raw request body as bytes, before any JSON parsing or re-serialization. 2. Parse `t` and `v1` out of the header. 3. Reject if `|now - t| > 300` seconds — this bounds replay of captured deliveries. 4. Compute `HMAC-SHA256(secret, t + "." + raw_body)` and compare with `v1` using a timing-safe comparison. 5. Only then act on the payload. Runnable Node and Python verifiers are in [Verify a webhook signature](/recipes/verify-webhook). ## What if my endpoint is down? Respond with a 2xx within a few seconds to acknowledge a delivery. Non-2xx responses and timeouts are retried with increasing delay. Every attempt is recorded — inspect them at any time: ```bash curl "https://api.rolesapi.com/v1/webhooks/wh_01habc/deliveries" \ -H "Authorization: Bearer rk_live_your_key" ``` Each delivery record includes the event, attempt number, response status, and timestamp — see the [Webhooks reference](/api/webhooks). Because retries can produce duplicate deliveries, make your handler idempotent: key on the job id and ignore events you have already processed. ## How should I handle the secret? - Store it in a secrets manager or environment variable, never in code or logs. - Use a distinct endpoint (and therefore secret) per environment. - To rotate: create a new webhook, verify deliveries arrive, then `DELETE /v1/webhooks/{id}` on the old one. --- # Account Reference for GET /v1/me — account, plan, and credits remaining — and GET /v1/usage with the since and limit parameters and usage record fields. The account endpoints report who you are, what plan you are on, how many credits remain, and what every past request did. Both are GETs and consume no credits. | Method | Path | What it returns | | --- | --- | --- | | GET | `/v1/me` | Account, plan, and credit balance | | GET | `/v1/usage` | Per-request usage records, newest first | ```bash curl "https://api.rolesapi.com/v1/me" \ -H "Authorization: Bearer rk_live_your_key" ``` ## GET /v1/me No parameters. ```json { "data": { "account": { "id": "acct_01hqrs", "email": "dev@example.com", "created_at": "2026-05-12T08:00:00Z" }, "plan": { "id": "monthly", "name": "Monthly", "credits_per_cycle": 1000, "rpm": 200 }, "credits": { "remaining": 640 } }, "request_id": "req_11a8d5c2" } ``` | Field | Meaning | | --- | --- | | `account` | Your account id, email, and creation time. | | `plan` | Current plan: `free`, `monthly`, or `annual`, with its credit allotment and requests-per-minute. See [Rate limits](/rate-limits). | | `credits.remaining` | Credits available right now, including any top-ups. | `GET /v1/me` is a good health check: it verifies the key, and lets a worker refuse to start a large batch when `credits.remaining` is too low to finish it. ## GET /v1/usage One record per API request, newest first. | Parameter | In | Type | Description | | --- | --- | --- | --- | | `since` | query | string | ISO 8601 timestamp; only records at or after this time. | | `limit` | query | integer | Page size. Combine with `offset` — see [Pagination](/pagination). | ```bash curl "https://api.rolesapi.com/v1/usage?since=2026-07-01T00:00:00Z&limit=50" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": [ { "id": "usg_01hjkl", "created_at": "2026-07-01T09:15:22Z", "method": "GET", "path": "/v1/roles/a1b2c3d4e5f60718", "status": 200, "credits": 1, "request_id": "req_8f3a2b1c", "latency_ms": 842 } ], "meta": { "total": 137, "count": 1, "limit": 1, "offset": 0, "has_more": true }, "request_id": "req_cd02e977" } ``` | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Usage record id. | | `created_at` | string | When the request was made. | | `method` | string | HTTP method. | | `path` | string | The path called. | | `status` | integer | HTTP status returned. | | `credits` | integer | Credits charged (0 for errors and control-plane calls). | | `request_id` | string | Matches the `request_id` in that response — join usage to your logs with it. | | `latency_ms` | integer | Server-side processing time for that request. | ## What is usage good for? - **Reconciliation** — sum `credits` over a window to verify billing against your own metering. - **Debugging** — find the exact failing request by `request_id`, then quote it to support (nikhil@landkit.pro). - **Rotation checks** — after deploying a new key, confirm the old one has gone quiet before revoking it. See [Authentication](/authentication). --- # Jobs Reference for GET /v1/jobs, /v1/jobs/{id}, and /v1/jobs/{id}/results — job object fields, status lifecycle, and results pagination and formats. The jobs endpoints are the read side of [async jobs](/async-jobs): list your jobs, poll one for status, and page through its results. All three are GETs and consume no credits — you paid when the job processed its roles. | Method | Path | What it returns | | --- | --- | --- | | GET | `/v1/jobs` | Your jobs, newest first, filterable by status | | GET | `/v1/jobs/{id}` | One job object | | GET | `/v1/jobs/{id}/results` | The job's output, paginated, in JSON, CSV, or NDJSON | ```bash curl "https://api.rolesapi.com/v1/jobs?status=running&limit=20" \ -H "Authorization: Bearer rk_live_your_key" ``` ## What is in a job object? ```json { "data": { "id": "5f0f6c2e-4b3a-4e9c-9c74-31d1f2a30c11", "type": "batch_detail", "status": "succeeded", "progress": { "done": 120, "total": 120 }, "result_count": 120, "error": null, "created_at": "2026-07-01T09:15:00Z", "started_at": "2026-07-01T09:15:01Z", "completed_at": "2026-07-01T09:32:41Z" }, "request_id": "req_e01b7d2c" } ``` | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Job id (UUID). | | `type` | string | What created it: `batch_detail` (roles batch), `search` (deep search), `chained_search_detail` (search with details). | | `status` | string | `queued`, `running`, `succeeded`, `failed`, `timed_out`, or `aborted`. | | `progress` | object | `{ "done": n, "total": n }` — units processed vs. planned. | | `created_at` | string | Submission time, ISO 8601. | | `result_count` | integer or null | Rows produced, set on completion. | | `started_at` / `completed_at` | string or null | Set when the job starts / reaches a terminal state. | | `error` | string or null | Set when the job fails. | ## What is the lifecycle? | From | To | When | | --- | --- | --- | | — | `queued` | Submission accepted (the 202 response). | | `queued` | `running` | A worker picks the job up; `progress.done` starts advancing. | | `running` | `succeeded` | Every unit processed; results ready. | | `running` | `failed` | Unrecoverable error; `error` is set, partial results (if any) remain readable. | `succeeded`, `failed`, `timed_out`, and `aborted` are terminal. Poll every few seconds with backoff, or skip polling with a [webhook](/webhooks-guide). ## GET /v1/jobs | Parameter | In | Type | Description | | --- | --- | --- | --- | | `status` | query | string | Filter: `queued`, `running`, `succeeded`, `failed`, `timed_out`, `aborted`. | | `limit` | query | integer | Page size. | | `offset` | query | integer | Items to skip. See [Pagination](/pagination). | Returns an array of job objects with the standard `meta` block. ## GET /v1/jobs/{id} Returns the single job object shown above. `404 not_found` if the id does not exist on your account. ## GET /v1/jobs/{id}/results | Parameter | In | Type | Description | | --- | --- | --- | --- | | `limit` | query | integer | Page size. | | `offset` | query | integer | Items to skip. | | `format` | query | string | `json` (default), `csv`, or `ndjson`. See [Output formats](/output-formats). | JSON results are full role objects in the standard envelope: ```bash curl "https://api.rolesapi.com/v1/jobs/job_01hxyz/results?limit=100&offset=0" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": [ { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "location": "Austin, TX", "remote": false, "employment_type": "Full-time", "salary": { "min": 140000, "max": 175000, "currency": "USD", "period": "year", "source": "employer" }, "benefits": ["Health insurance", "401(k) matching", "Paid time off"], "description": "Acme Logistics is hiring a Senior Backend Engineer to build the services that power our fulfillment network...", "posted_at": "2026-06-28", "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718", "country": "us" } ], "meta": { "total": 120, "count": 1, "limit": 1, "offset": 0, "has_more": true }, "request_id": "req_ab44c8e0" } ``` With `format=csv` or `format=ndjson`, the whole result set is returned as one download — rows only, no envelope. Results for a `failed` job return whatever was processed before the failure. Calling `/results` on a `queued` or `running` job returns the rows processed so far, so you can stream partial output from long jobs. --- # Listings Reference for GET /v1/listings and the posted-today, posted-this-week, and remote presets — parameters, examples, and when to use them over search. The listings endpoints answer the common "what is out there right now" questions without you composing search filters. One GET endpoint for quick summary lists, and three POST presets that run recency- and remote-filtered collection as [async jobs](/async-jobs). | Method | Path | What it returns | Mode | | --- | --- | --- | --- | | GET | `/v1/listings` | A page of role summaries | Sync | | POST | `/v1/listings/posted-today` | Roles posted in the last day | Async | | POST | `/v1/listings/posted-this-week` | Roles posted in the last seven days | Async | | POST | `/v1/listings/remote` | Remote roles matching the keyword | Async | ```bash curl "https://api.rolesapi.com/v1/listings?keyword=backend+engineer&location=Austin,+TX&sort=date" \ -H "Authorization: Bearer rk_live_your_key" ``` ## GET /v1/listings A synchronous page of role summaries — the lightest way to show current postings in a UI. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `keyword` | query | string | Yes | Search terms. | | `location` | query | string | No | City, state, or region. | | `country` | query | string | No | Country edition code. Default `us`. | | `sort` | query | string | No | `date` or `relevance`. Default `relevance`. | **Credits:** 1 per page (this endpoint returns one page). ```json { "data": [ { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "location": "Austin, TX", "remote": false, "posted_at": "2026-06-28", "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718", "country": "us" } ], "meta": { "total": 18, "count": 18, "limit": 18, "offset": 0, "has_more": false }, "request_id": "req_5e77ab90" } ``` ## What do the POST presets take? All three presets share one body: | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `keyword` | string | Yes | Search terms. | | `location` | string | No | City, state, or region. Ignored by `/remote` in the usual case. | | `country` | string | No | Country edition code. Default `us`. | | `max_pages` | integer | No | 1–3 pages to collect. Default 1. | **Credits:** 1 per page collected. Each preset responds synchronously with the filtered rows: ```bash curl -X POST "https://api.rolesapi.com/v1/listings/posted-this-week" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{"keyword": "backend engineer", "location": "Austin, TX", "max_pages": 3}' ``` ```json { "data": [ { "job_key": "a1b2c3d4e5f60718", "title": "Backend Engineer", "...": "..." } ], "meta": { "count": 14, "pages_fetched": 3 }, "request_id": "req_90c1e2f4" } ``` The remote preset works the same way: ```bash curl -X POST "https://api.rolesapi.com/v1/listings/remote" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{"keyword": "backend engineer", "country": "us", "max_pages": 2}' ``` **Errors:** `400 invalid_request` if `keyword` is missing or `max_pages` is outside 1–3; `402 out_of_credits` on a zero balance. ## When should I prefer listings over search? - **You want a time or remote filter, not a query language.** The presets encode "today," "this week," and "remote" so you do not maintain filter logic. - **You are building recurring alerts.** A weekly scheduled `posted-this-week` call is a complete alert pipeline — see [Job alerts with n8n or Zapier](/recipes/job-alerts-webhook). - **You need one fast page for a UI.** `GET /v1/listings` is a single synchronous call. Prefer [Search](/api/search) when you need `sort` control across many pages of arbitrary queries, or full role details in one operation via `with-details`. --- # Roles Reference for RolesAPI role endpoints — fetch by URL or job key, company/salary/description/benefits sub-resources, and batch up to 500 roles. The roles endpoints return normalized job-posting objects from any of 60+ Indeed country editions. Fetch one role by URL or job key, fetch a single block of it, or fetch up to 500 roles in one batch. | Method | Path | Credits | Mode | | --- | --- | --- | --- | | GET | `/v1/roles/by-url` | 1 | Sync | | GET | `/v1/roles/{job_key}` | 1 | Sync | | GET | `/v1/roles/{job_key}/company` | 1 | Sync | | GET | `/v1/roles/{job_key}/salary` | 1 | Sync | | GET | `/v1/roles/{job_key}/description` | 1 | Sync | | GET | `/v1/roles/{job_key}/benefits` | 1 | Sync | | POST | `/v1/roles/batch` | 1 per role | Async | ```bash curl "https://api.rolesapi.com/v1/roles/a1b2c3d4e5f60718?country=us" \ -H "Authorization: Bearer rk_live_your_key" ``` ## GET /v1/roles/by-url Fetch a full role from an Indeed viewjob URL. Use this when you have a link — from a browser, an alert email, or an agent — and want structured data in one call. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `url` | query | string | Yes | An Indeed viewjob URL, e.g. `https://www.indeed.com/viewjob?jk=...`. URL-encode it if it carries its own query string. | | `fields` | query | string | No | [Field projection](/field-projection). | **Credits:** 1. ```bash curl "https://api.rolesapi.com/v1/roles/by-url?url=https%3A%2F%2Fwww.indeed.com%2Fviewjob%3Fjk%3Da1b2c3d4e5f60718" \ -H "Authorization: Bearer rk_live_your_key" ``` ```json { "data": { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "location": "Austin, TX", "remote": false, "employment_type": "Full-time", "salary": { "min": 140000, "max": 175000, "currency": "USD", "period": "year", "source": "employer" }, "benefits": ["Health insurance", "401(k) matching", "Paid time off"], "description": "Acme Logistics is hiring a Senior Backend Engineer to build the services that power our fulfillment network...", "posted_at": "2026-06-28", "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718", "country": "us" }, "request_id": "req_8f3a2b1c" } ``` **Errors:** `400 invalid_request` if `url` is missing or not a viewjob URL; `404 not_found` if the posting no longer exists. ## GET /v1/roles/{job_key} Fetch a full role by its 16-character Indeed job key. | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `job_key` | path | string | Yes | The Indeed job key, e.g. `a1b2c3d4e5f60718`. | | `country` | query | string | No | Indeed country edition code. Default `us`. | | `fields` | query | string | No | [Field projection](/field-projection). | **Credits:** 1. ```bash curl "https://api.rolesapi.com/v1/roles/a1b2c3d4e5f60718?country=us" \ -H "Authorization: Bearer rk_live_your_key" ``` The response is the same full role object shown above. **Errors:** `404 not_found` if no role exists for that key in that country — check that `country` matches the edition the posting appeared in; `400 invalid_request` for a malformed key. ## What do the sub-resources return? Each sub-resource returns one block of the role inside the standard envelope, for 1 credit — useful when you only need salary data or the description text and want a smaller payload than the full object. ### GET /v1/roles/{job_key}/company ```json { "data": { "name": "Acme Logistics" }, "request_id": "req_2d90ee17" } ``` ### GET /v1/roles/{job_key}/salary ```json { "data": { "min": 140000, "max": 175000, "currency": "USD", "period": "year", "source": "employer" }, "request_id": "req_60ac3f8b" } ``` ### GET /v1/roles/{job_key}/description ```json { "data": "Acme Logistics is hiring a Senior Backend Engineer to build the services that power our fulfillment network...", "request_id": "req_bb17c9e4" } ``` ### GET /v1/roles/{job_key}/benefits ```json { "data": ["Health insurance", "401(k) matching", "Paid time off"], "request_id": "req_74d2a1f0" } ``` All four accept the same `country` query parameter as the parent endpoint and return `404 not_found` for unknown keys. ## POST /v1/roles/batch Fetch up to 500 roles in one request. Batch always runs as an [async job](/async-jobs). | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `job_keys` | body | string[] | No* | Indeed job keys to fetch. | | `urls` | body | string[] | No* | Indeed viewjob URLs to fetch. | | `country` | body | string | No | Country edition for `job_keys`. Default `us`. | *Provide `job_keys`, `urls`, or both; the combined count must be 1–500. **Credits:** 1 per processed role, charged as the job runs. ```bash curl -X POST "https://api.rolesapi.com/v1/roles/batch" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "job_keys": ["a1b2c3d4e5f60718", "b2c3d4e5f6071829"], "country": "us" }' ``` Response — HTTP 202: ```json { "data": { "job_id": "5f0f6c2e-4b3a-4e9c-9c74-31d1f2a30c11", "status": "queued" }, "request_id": "req_3f0a77b1" } ``` Poll `GET /v1/jobs/{job_id}`, then page `GET /v1/jobs/{job_id}/results` — full role objects, also available as CSV or NDJSON. See [Jobs](/api/jobs). **Errors:** `400 invalid_request` if both lists are empty or the combined count exceeds 500; `402 out_of_credits` if your balance is zero at submission. --- # Search Reference for POST /v1/search and /v1/search/with-details — body parameters, the sync-vs-async threshold at 3 pages, and worked credit math. The search endpoints run a keyword-and-location query against an Indeed country edition. `POST /v1/search` returns role summaries; `POST /v1/search/with-details` also fetches every result's full role object server-side. | Method | Path | Credits | Mode | | --- | --- | --- | --- | | POST | `/v1/search` | 1 per page | Sync at ≤3 pages, async above | | POST | `/v1/search/with-details` | 1 per page + 1 per role | Always async | ```bash curl -X POST "https://api.rolesapi.com/v1/search" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{"keyword": "backend engineer", "location": "Austin, TX", "max_pages": 2}' ``` ## What are the body parameters? Both endpoints take the same body: | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `keyword` | string | Yes | Search terms, e.g. `"backend engineer"`. | | `location` | string | Yes | City, state, or region, e.g. `"Austin, TX"`. | | `country` | string | No | Indeed country edition code. Default `us`. | | `sort` | string | No | `date` or `relevance`. Default `relevance`. | | `max_pages` | integer | No | 1–20 result pages to fetch. Default 1. | ## When does a search run sync vs. async? `POST /v1/search` with `max_pages` of 3 or fewer responds synchronously with results in the body. Above 3 pages, it returns 202 with a job — the same [async job](/async-jobs) flow as batch. `POST /v1/search/with-details` is always async, whatever the page count. ## How are credits charged? - **`/v1/search`:** 1 credit per result page fetched. - **`/v1/search/with-details`:** 1 credit per page, plus 1 credit per role whose details are fetched. Worked examples: | Request | Result | Credits | | --- | --- | --- | | `search`, `max_pages: 2` | 2 pages of summaries | 2 | | `search`, `max_pages: 10` | 10 pages, async | 10 | | `search/with-details`, `max_pages: 2`, jobs return 30 roles | 2 pages + 30 details | 2 + 30 = 32 | | `search/with-details`, `max_pages: 5`, jobs return 70 roles | 5 pages + 70 details | 5 + 70 = 75 | The role counts above are illustrative — how many roles a page holds depends on the query. Credits for details are charged per role actually processed, so short result sets cost less than the worst case. ## What does a sync response look like? ```json { "data": [ { "job_key": "a1b2c3d4e5f60718", "title": "Senior Backend Engineer", "company": { "name": "Acme Logistics" }, "location": "Austin, TX", "remote": false, "posted_at": "2026-06-28", "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718", "country": "us" } ], "meta": { "total": 34, "count": 34, "limit": 34, "offset": 0, "has_more": false }, "request_id": "req_c31f8e02" } ``` Summaries carry the fields visible on a results page. For salary, benefits, and the full description, fetch the role by `job_key` — see [Roles](/api/roles) — or use `with-details`. ## What does an async response look like? Both a deep `search` and every `with-details` call return HTTP 202: ```json { "data": { "job_id": "5f0f6c2e-4b3a-4e9c-9c74-31d1f2a30c11", "status": "queued" }, "request_id": "req_77e0d1a9" } ``` Poll `GET /v1/jobs/{id}` and page `GET /v1/jobs/{id}/results`. For `with-details`, results are full role objects — the Acme Logistics shape in [Roles](/api/roles) — and can be exported as CSV or NDJSON via [output formats](/output-formats). **Errors:** `400 invalid_request` if `keyword` is missing, `sort` is not `date`/`relevance`, or `max_pages` is outside 1–20; `402 out_of_credits` if your balance is zero. ## Search or listings? If your query is really "what was posted today / this week / remote," the [Listings](/api/listings) presets encode those filters for you. --- # Webhooks Reference for the RolesAPI webhook endpoints — create, list, delete, and inspect deliveries; the webhook and delivery objects; secret handling. The webhooks endpoints manage persistent endpoints that receive signed `job.succeeded` and `job.failed` events. For the event flow, payloads, and signature verification, see the [Webhooks guide](/webhooks-guide); this page is the CRUD reference. | Method | Path | What it does | | --- | --- | --- | | POST | `/v1/webhooks` | Register an endpoint; returns the secret once | | GET | `/v1/webhooks` | List your webhooks (without secrets) | | DELETE | `/v1/webhooks/{id}` | Remove a webhook; delivery stops immediately | | GET | `/v1/webhooks/{id}/deliveries` | List delivery attempts for one webhook | ```bash curl "https://api.rolesapi.com/v1/webhooks" \ -H "Authorization: Bearer rk_live_your_key" ``` ## POST /v1/webhooks | Parameter | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `url` | body | string | Yes | HTTPS endpoint to deliver events to. | | `events` | body | string[] | No | Any of `job.succeeded`, `job.failed`. Default: both. | ```bash curl -X POST "https://api.rolesapi.com/v1/webhooks" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/rolesapi", "events": ["job.succeeded", "job.failed"]}' ``` ```json { "data": { "id": "wh_01habc", "url": "https://example.com/hooks/rolesapi", "events": ["job.succeeded", "job.failed"], "secret": "whsec_9f2c4e6a8b0d1f3a5c7e9b2d4f6a8c0e", "active": true, "created_at": "2026-07-01T09:15:00Z" }, "request_id": "req_0b3d55e7" } ``` **The secret appears only in this response.** It is used to sign every delivery (`x-rolesapi-signature`); store it in a secrets manager immediately. It cannot be retrieved later — to get a new secret, delete the webhook and create another. **Errors:** `400 invalid_request` for a non-HTTPS URL or unknown event name. ## GET /v1/webhooks Returns your webhooks in the standard list envelope. The webhook object: | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Webhook id, `wh_...`. | | `url` | string | Destination endpoint. | | `events` | string[] | Subscribed events. | | `active` | boolean | Whether deliveries are being attempted. | | `created_at` | string | ISO 8601. | | `last_delivery_at` | string or null | Time of the most recent delivery attempt. | The `secret` field is never included after creation. ## DELETE /v1/webhooks/{id} ```bash curl -X DELETE "https://api.rolesapi.com/v1/webhooks/wh_01habc" \ -H "Authorization: Bearer rk_live_your_key" ``` Deletion is immediate — no further deliveries are attempted, including retries of past failures. `404 not_found` if the id does not exist on your account. ## GET /v1/webhooks/{id}/deliveries Lists delivery attempts, newest first, with `limit`/`offset` [pagination](/pagination). Use it to debug an endpoint that is not receiving events. ```json { "data": [ { "id": "whd_01hdef", "webhook_id": "wh_01habc", "event": "job.succeeded", "status": "succeeded", "attempt": 2, "response_status": 200, "created_at": "2026-07-01T09:33:05Z" }, { "id": "whd_01hdee", "webhook_id": "wh_01habc", "event": "job.succeeded", "status": "failed", "attempt": 1, "response_status": 503, "created_at": "2026-07-01T09:32:41Z" } ], "meta": { "total": 2, "count": 2, "limit": 50, "offset": 0, "has_more": false }, "request_id": "req_7f19c30a" } ``` | Field | Type | Meaning | | --- | --- | --- | | `id` | string | Delivery id, `whd_...`. | | `event` | string | `job.succeeded` or `job.failed`. | | `status` | string | `succeeded` (2xx received) or `failed`. | | `attempt` | integer | 1 for the first try, incrementing on each retry. | | `response_status` | integer or null | HTTP status your endpoint returned; null on timeout. | | `created_at` | string | When the attempt was made. | Failed deliveries are retried with increasing delay; each retry appears as a new record with a higher `attempt`. Make handlers idempotent — a slow 2xx can race a retry and produce duplicates. --- # Export Indeed jobs to Google Sheets Three ways to land Indeed job-postings data in Google Sheets — dashboard CSV import, an Apps Script fetcher on a time trigger, and IMPORTDATA limits. Google Sheets is where a lot of job-market tracking actually lives. Here are three ways to get RolesAPI data into a sheet, from zero-code to fully automated. ## Option A: Download a CSV, then File > Import No code at all. 1. In the [dashboard](https://rolesapi.com), run a search or open a completed job and download the results as CSV. (Any results endpoint also serves CSV directly — add `?format=csv`. See [Output formats](/output-formats).) 2. In Sheets: **File > Import > Upload**, choose the CSV, and pick "Insert new sheet(s)". Nested fields arrive as dot-path columns (`company.name`, `salary.min`) and array fields joined with `|`, so the sheet is immediately sortable and filterable. Best for one-off analyses. ## Option B: Apps Script on a time trigger (recommended for automation) Apps Script can call RolesAPI with `UrlFetchApp`, which supports the `Authorization` header, and rewrite a sheet on a schedule. 1. In your spreadsheet: **Extensions > Apps Script**. 2. In the script editor, open **Project Settings > Script Properties** and add `ROLESAPI_KEY` with your `rk_` key — keeps the key out of the code. 3. Paste this script: ```javascript const BASE = "https://api.rolesapi.com"; const SHEET_NAME = "Jobs"; function refreshJobs() { const key = PropertiesService.getScriptProperties().getProperty("ROLESAPI_KEY"); const response = UrlFetchApp.fetch( BASE + "/v1/listings?keyword=" + encodeURIComponent("backend engineer") + "&location=" + encodeURIComponent("Austin, TX") + "&country=us&sort=date", { headers: { Authorization: "Bearer " + key } } ); const body = JSON.parse(response.getContentText()); const roles = body.data; const rows = roles.map(function (r) { return [ r.job_key, r.title, r.company && r.company.name, r.location, r.remote, r.posted_at, r.url, ]; }); const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME); sheet.clearContents(); sheet .getRange(1, 1, 1, 7) .setValues([["job_key", "title", "company", "location", "remote", "posted_at", "url"]]); if (rows.length) { sheet.getRange(2, 1, rows.length, 7).setValues(rows); } } ``` 4. Run `refreshJobs` once from the editor to authorize it and check the output. 5. Add the schedule: **Triggers** (clock icon) **> Add Trigger**, choose `refreshJobs`, event source "Time-driven", and an interval such as daily. Each run makes one `GET /v1/listings` call — 1 credit per refresh, so a daily trigger costs about 30 credits a month. To pull larger sets, submit an [async](/async-jobs) preset like `posted-this-week` in one function and read `GET /v1/jobs/{id}/results` in a second run. ## Option C: Why =IMPORTDATA does not work (and what to do) `=IMPORTDATA("https://api.rolesapi.com/v1/jobs/job_01hxyz/results?format=csv")` looks tempting: results endpoints do serve CSV. But `IMPORTDATA` cannot send headers, and RolesAPI authenticates with the `Authorization: Bearer rk_...` header — so the call returns `401 missing_api_key` and the formula fails. Putting a key in the URL is not supported, deliberately: spreadsheet formulas are visible to every viewer of the sheet, get copied between files, and end up in version history — the worst possible place for a credential. Use option A for one-offs and option B for anything recurring; option B keeps the key in Script Properties, invisible to sheet viewers. --- # Job alerts with n8n or Zapier Build a weekly Indeed job alert with no backend — a scheduled posted-this-week call whose synchronous response feeds an n8n or Zapier flow. This recipe builds a weekly job alert with no server of your own: a scheduled call to `POST /v1/listings/posted-this-week` from an n8n HTTP Request node or a Zapier webhook action. The call is synchronous — the matching roles come back in the same response — so the flow simply maps them into Slack or email. ## How does the pipeline fit together? 1. **Schedule** — an n8n Schedule Trigger or Zapier "Every Week" trigger fires once a week. 2. **Submit** — an HTTP node calls RolesAPI with your search; the matching roles are in the response body. 3. **Notify** — the next node maps the roles into Slack or email. ## Step 1: Submit the weekly search In your scheduled HTTP Request node (n8n) or a Webhooks by Zapier POST action: ```bash curl -X POST "https://api.rolesapi.com/v1/listings/posted-this-week" \ -H "Authorization: Bearer rk_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "keyword": "backend engineer", "location": "Austin, TX", "country": "us", "max_pages": 2 }' ``` The call responds synchronously with the matching roles in `data` and costs 1 credit per page fetched (2 here). ## Step 2: Map the response fields Each item in the response's `data` array is a role — map these fields into your Slack message or email: | Notification field | Role field | | --- | --- | | Title line | `title` at `company.name` | | Location | `location` (plus `remote`) | | Salary | `salary.min`–`salary.max` `salary.currency` | | Posted | `posted_at` | | Link | `url` | A Slack message template for an n8n Slack node: ``` New this week: {{ $json.title }} at {{ $json.company.name }} {{ $json.location }} - posted {{ $json.posted_at }} {{ $json.url }} ``` In Zapier, feed the webhook action's `data` output into a Slack "Send Channel Message" or Gmail action with a Looping step for multiple roles. ## What about async jobs and signatures? If you graduate to async work — big batches via `POST /v1/roles/batch` or deep searches — register a hook URL once with `POST /v1/webhooks` and RolesAPI will POST signed `job.succeeded` / `job.failed` events to it (see the [Webhooks guide](/webhooks-guide)). Every delivery carries `x-rolesapi-signature`, and the `secret` returned at registration verifies it. In n8n, drop a **Function** node between the Webhook node and the rest of the flow: ```javascript const crypto = require("crypto"); const secret = $env.ROLESAPI_WEBHOOK_SECRET; const header = $json.headers["x-rolesapi-signature"] || ""; const rawBody = $json.rawBody; // enable "Raw Body" on the Webhook node const parts = Object.fromEntries(header.split(",").map(p => p.split("="))); const t = Number(parts.t); if (Math.abs(Date.now() / 1000 - t) > 300) { throw new Error("Signature timestamp outside tolerance"); } const expected = crypto .createHmac("sha256", secret) .update(`${parts.t}.${rawBody}`) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"))) { throw new Error("Bad signature"); } return $input.all(); ``` For a lighter-weight guard on Zapier, use a hard-to-guess Catch Hook URL and a Filter step that checks `event` equals `job.succeeded`. ## What does this cost? Two credits a week (one per page) — about 8–9 credits a month, well inside the free plan's 100 credits while you evaluate. --- # Search, then full details A runnable Python script — search two pages, collect job keys, batch-fetch full roles with POST /v1/roles/batch, poll the job, and export CSV. This recipe is the workhorse pipeline: run a search for summaries, collect the `job_key` of every hit, batch-fetch the full role objects, and export everything as CSV. One runnable script, using [httpx](https://www.python-httpx.org/) (`pip install httpx`). ## What is the flow? 1. `POST /v1/search` with `max_pages: 2` — synchronous, since 2 ≤ 3 pages. Costs 2 credits. 2. Collect `job_key` from each summary. 3. `POST /v1/roles/batch` with those keys — returns 202 with a job. Costs 1 credit per role. 4. Poll `GET /v1/jobs/{id}` until `completed`. 5. Download `GET /v1/jobs/{id}/results?format=csv`. If you would rather have the server do steps 1–3 in one call, use [`POST /v1/search/with-details`](/api/search); this recipe keeps the steps separate so you can filter between search and detail (skipping roles you already have is the main credit saver). ## The script ```python """Search Indeed postings, then batch-fetch full details and save CSV.""" import sys import time import httpx BASE = "https://api.rolesapi.com" API_KEY = "rk_live_your_key" # read from an env var in real code HEADERS = {"Authorization": f"Bearer {API_KEY}"} KEYWORD = "backend engineer" LOCATION = "Austin, TX" def search_two_pages(client: httpx.Client) -> list[dict]: """POST /v1/search with max_pages=2 -- sync at <=3 pages.""" r = client.post( f"{BASE}/v1/search", json={ "keyword": KEYWORD, "location": LOCATION, "country": "us", "sort": "date", "max_pages": 2, }, ) r.raise_for_status() return r.json()["data"] def submit_batch(client: httpx.Client, job_keys: list[str]) -> str: """POST /v1/roles/batch -- always async, 202 with a job id.""" r = client.post( f"{BASE}/v1/roles/batch", json={"job_keys": job_keys, "country": "us"}, ) r.raise_for_status() job = r.json()["data"] print(f"Job {job['job_id']} queued") return job["job_id"] def wait_for_job(client: httpx.Client, job_id: str) -> None: """Poll GET /v1/jobs/{id} until it reaches a terminal state.""" while True: r = client.get(f"{BASE}/v1/jobs/{job_id}") r.raise_for_status() job = r.json()["data"] p = job["progress"] print(f" {job['status']}: {p['done']}/{p['total']}") if job["status"] == "succeeded": return if job["status"] in ("failed", "timed_out", "aborted"): sys.exit(f"Job {job['status']}: {job['error']}") time.sleep(3) def download_csv(client: httpx.Client, job_id: str, path: str) -> None: """GET /v1/jobs/{id}/results?format=csv -- whole result set, no envelope.""" r = client.get(f"{BASE}/v1/jobs/{job_id}/results", params={"format": "csv"}) r.raise_for_status() with open(path, "w", encoding="utf-8") as f: f.write(r.text) print(f"Wrote {path}") def main() -> None: with httpx.Client(headers=HEADERS, timeout=30) as client: summaries = search_two_pages(client) print(f"Search returned {len(summaries)} roles") job_keys = [s["job_key"] for s in summaries] if not job_keys: sys.exit("No results for this query.") job_id = submit_batch(client, job_keys) wait_for_job(client, job_id) download_csv(client, job_id, "roles.csv") if __name__ == "__main__": main() ``` ## What does the output look like? `roles.csv` has one row per role, with nested fields flattened to dot-path columns and arrays joined with `|` — the rules in [Output formats](/output-formats): ``` job_key,title,company.name,location,remote,employment_type,salary.min,salary.max,salary.currency,salary.period,benefits,posted_at,country a1b2c3d4e5f60718,Senior Backend Engineer,Acme Logistics,4.1,"Austin, TX",false,Full-time,140000,175000,USD,year,Health insurance|401(k) matching|Paid time off,2026-06-28,us ``` ## How many credits did this use? 2 (search pages) + 1 per role fetched in the batch. If the search returned 30 roles: 2 + 30 = 32 credits. ## How can I adapt it? - **Dedupe before the batch.** Filter `job_keys` against roles already in your database — that line is where this pipeline beats `with-details` on cost. - **Skip the poll loop.** Register a webhook with `POST /v1/webhooks` and let RolesAPI call you — see the [Webhooks guide](/webhooks-guide). - **Trim the payload.** Add `params={"format": "csv", "fields": "job_key,title,company.name,salary.min"}` to the results request to control the columns — see [Field projection](/field-projection). --- # Verify a webhook signature Verify x-rolesapi-signature in Node and Python — parse t and v1, enforce the 300-second tolerance, and compare HMAC-SHA256 timing-safely. Every webhook delivery from RolesAPI carries a signature so your endpoint can prove the request came from us and was not replayed. This recipe gives you drop-in verifiers for Node and Python. The header looks like: ``` x-rolesapi-signature: t=1751362361,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` `v1` is `HMAC-SHA256(secret, ".")` in hex, where `secret` is the `whsec_...` value returned once when you created the webhook ([reference](/api/webhooks)). ## Node ```javascript const crypto = require("crypto"); const TOLERANCE_SECONDS = 300; function verifySignature(rawBody, signatureHeader, secret) { // rawBody must be the exact bytes received -- use express.raw() or // equivalent, never JSON.stringify(req.body). const parts = Object.fromEntries( signatureHeader.split(",").map((p) => p.split("=")) ); const t = Number(parts.t); const v1 = parts.v1; if (!t || !v1) return false; // Reject stale or future-dated timestamps. if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(v1, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express usage: // app.post("/hooks/rolesapi", express.raw({ type: "application/json" }), (req, res) => { // const ok = verifySignature(req.body, req.get("x-rolesapi-signature"), process.env.ROLESAPI_WEBHOOK_SECRET); // if (!ok) return res.status(400).send("bad signature"); // const event = JSON.parse(req.body); // res.sendStatus(200); // }); ``` ## Python ```python import hashlib import hmac import time TOLERANCE_SECONDS = 300 def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool: """raw_body must be the exact request bytes, before JSON parsing.""" parts = dict(p.split("=", 1) for p in signature_header.split(",")) t = parts.get("t") v1 = parts.get("v1") if not t or not v1: return False if abs(time.time() - int(t)) > TOLERANCE_SECONDS: return False expected = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1) # Flask usage: # @app.post("/hooks/rolesapi") # def hook(): # ok = verify_signature( # request.get_data(), # raw bytes # request.headers.get("x-rolesapi-signature", ""), # os.environ["ROLESAPI_WEBHOOK_SECRET"], # ) # if not ok: # abort(400) # event = request.get_json() # return "", 200 ``` ## Why the 300-second tolerance on t? The `t` timestamp is part of what gets signed, so an attacker cannot change it without breaking the signature. Rejecting deliveries where `t` is more than 300 seconds from your clock means a captured request cannot be replayed later — after five minutes it is dead, even with a valid signature. Five minutes is generous enough to absorb ordinary clock drift and retry delay; if you see valid deliveries failing the window, check your server's clock synchronization before widening it. ## Why a timing-safe compare? A plain `===` or `==` string comparison returns as soon as the first character differs, so how long it takes leaks how much of an attacker's guess was correct — with enough tries, that leak can be walked into a full forgery. `crypto.timingSafeEqual` and `hmac.compare_digest` take the same time whether the guess is wrong at the first byte or the last, closing that channel. Always use them for signature checks. ## Common mistakes - **Re-serializing the body.** Sign checks must use the raw received bytes. Parsing to JSON and re-stringifying can reorder keys or change whitespace, and the HMAC will not match. - **Comparing against the whole header.** Parse out `v1`; the header also contains `t=`. - **Returning 200 before verifying.** Verify first, then acknowledge, then process — ideally asynchronously, so slow processing does not cause a retry and a duplicate. --- # AI agents How AI agents use RolesAPI — the MCP server at api.rolesapi.com/mcp, its five tools, machine-readable surfaces, and configs for Claude and Cursor. ## How do AI agents use RolesAPI? RolesAPI ships a hosted MCP (Model Context Protocol) server, so agents like Claude can search Indeed job postings and pull role details as native tool calls — no glue code. ``` https://api.rolesapi.com/mcp ``` Transport is streamable HTTP. Authentication is either OAuth 2.1 with PKCE (the agent walks the user through sign-in) or a plain Bearer `rk_` API key. Tool calls consume credits from the connected account exactly like REST calls — 1 credit per role detail or search page. ## What tools does the MCP server expose? | Tool | What it does | | --- | --- | | `search_roles` | Search postings by keyword and location; returns a page of role summaries. | | `get_role_by_key` | Fetch a full role object by its Indeed job key. | | `get_role_by_url` | Fetch a full role object from an Indeed viewjob URL. | | `get_salary_info` | Return just the salary block for a role. | | `get_role_description` | Return just the full description text for a role. | Example tool-call arguments: ```json { "tool": "search_roles", "arguments": { "keyword": "backend engineer", "location": "Austin, TX", "country": "us", "sort": "date" } } ``` ```json { "tool": "get_role_by_key", "arguments": { "job_key": "a1b2c3d4e5f60718", "country": "us" } } ``` ```json { "tool": "get_role_by_url", "arguments": { "url": "https://www.indeed.com/viewjob?jk=a1b2c3d4e5f60718" } } ``` ```json { "tool": "get_salary_info", "arguments": { "job_key": "a1b2c3d4e5f60718", "country": "us" } } ``` ```json { "tool": "get_role_description", "arguments": { "job_key": "a1b2c3d4e5f60718", "country": "us" } } ``` ## How do I connect Claude Code? ```bash claude mcp add --transport http rolesapi https://api.rolesapi.com/mcp ``` ## How do I connect Claude Desktop? Add to `claude_desktop_config.json`: ```json {"mcpServers":{"rolesapi":{"command":"npx","args":["mcp-remote","https://api.rolesapi.com/mcp"]}}} ``` ## How do I connect Cursor? Add to `.cursor/mcp.json`: ```json {"mcpServers":{"rolesapi":{"url":"https://api.rolesapi.com/mcp"}}} ``` On first use, each client either opens the OAuth sign-in flow or accepts a Bearer `rk_` key, depending on how you configure credentials. ## What machine-readable surfaces exist? For agents and tooling that discover capabilities by convention: | Surface | Path | | --- | --- | | OpenAPI specification | `https://api.rolesapi.com/openapi.json` | | LLM-oriented index | `https://rolesapi.com/llms.txt` | | Full LLM context file | `https://rolesapi.com/llms-full.txt` | | MCP server card | `https://api.rolesapi.com/.well-known/mcp/server-card.json` | | API catalog | `https://api.rolesapi.com/.well-known/api-catalog` | | Agent skills | `https://rolesapi.com/agent-skills` | Point a coding agent at `llms-full.txt` and it can write a working integration without browsing these docs. ## When should an agent use REST instead of MCP? MCP tools cover interactive lookups — one role, one search, one salary check. For bulk workloads (a 500-role batch, a search with full details), the REST [async jobs](/async-jobs) surface is the better fit; an agent can submit the job, then poll `GET /v1/jobs/{id}` with ordinary HTTP calls. --- # What are the ways to get Indeed job-postings data? An honest comparison of every route to Indeed job-postings data — official APIs, DIY scraping infrastructure, aggregators, and RolesAPI. There are four realistic routes to Indeed job-postings data, and they differ mostly in who does the maintenance and how you pay. This page lays them out honestly, including when RolesAPI is the wrong choice. ## Option 1: Official Indeed APIs Indeed's public Publisher job-search API — the one that used to let third parties query postings — was deprecated and is no longer open to new integrations. The official APIs that remain, such as Job Sync and Indeed's GraphQL surface, serve the hiring side: employers and applicant-tracking systems pushing jobs *into* Indeed and managing applications. They are not designed for pulling postings data *out*, and access is gated on partner agreements oriented around recruiting workflows. Practical consequence: as of today there is no official, general-purpose API for reading Indeed postings. Every route below is a third-party approach to that gap. ## Option 2: DIY scraping infrastructure Providers in this category (Bright Data-class tools) sell the building blocks — proxy networks, browser automation, unblocking layers — and you assemble the pipeline. You write the collection logic, parse the pages into your own schema, handle layout changes when they break your parser, and manage blocks, retries, and job scheduling yourself. This is the maximum-control option: any site, any field, your rules. It is also an ongoing engineering commitment, not a one-time setup — the pipeline is code you own and operate. Pricing is typically usage-based on traffic or successful requests, and predicting monthly cost takes some experience. It makes sense for teams with dedicated data-engineering capacity collecting from many sources at once. ## Option 3: Multi-source aggregators Aggregators continuously collect postings from many job boards and sell access to the combined, deduplicated feed — often with enrichment such as normalized titles or company matching. If you need cross-board market coverage (not just Indeed), this is the category that delivers it. The trade-offs: contracts are typically enterprise-shaped — sales calls, annual commitments, per-seat or volume pricing — and the data model is the aggregator's, updated on the aggregator's schedule. For a team that just needs Indeed postings on demand, an aggregator is usually more product, more contract, and more cost than the job requires. ## Option 4: RolesAPI RolesAPI is a REST API for Indeed job-postings data: you call an endpoint, you get a normalized JSON role object. There is nothing to run — no proxies, no parsers, no maintenance when page layouts change. It is also agent-ready out of the box: an [MCP server](/ai-agents), OpenAPI spec, and llms.txt let AI agents use it without custom integration. Pricing is per answer: 1 credit per role detail or search page, with 100 free credits (no card), a $5/month plan with 1,000 credits, and an annual plan at $54/year with 12,000. You can see real cost from the [credit math](/api/search) before you commit to anything. ## How do the options compare? | | Official Indeed APIs | DIY scraping infrastructure | Multi-source aggregators | RolesAPI | | --- | --- | --- | --- | --- | | Setup effort | N/A for reading postings | High — build the pipeline | Medium — contract, then integrate their feed | Low — one REST call | | Maintenance | — | Yours: parsers, blocks, breakage | Vendor's | Vendor's | | Pricing model | — | Usage-based on traffic/requests | Enterprise contracts | Per-answer credits, from $5/mo | | Agent-readiness | — | None — you build tooling | Varies, generally API-only | MCP server, OpenAPI, llms.txt | | Data shape | — | Whatever you parse | Vendor's cross-board schema | Normalized Indeed role JSON | | Source coverage | — | Anything you build for | Many boards | Indeed only | ## When should you NOT use RolesAPI? - **You need multi-source coverage.** RolesAPI covers Indeed's country editions only. If your product depends on aggregating many job boards into one feed, an aggregator (option 3) is the right tool. - **You need candidate or resume data.** RolesAPI serves publicly available job-postings data only — no resumes, no candidate profiles, no application data. Recruiting-side workflows belong with Indeed's own hiring APIs. - **You need fields we do not return.** Our role object is fixed and documented ([see it here](/api/roles)). If you need something outside it, a DIY pipeline is the only way to define your own schema. If what you need is Indeed postings, normalized, on demand, with per-answer pricing — [start with the free 100 credits](/quickstart). --- # Trademark and affiliation RolesAPI's relationship to Indeed, Inc. (none), the data it serves, and how to reach us about trademark or takedown concerns. RolesAPI is not affiliated with, endorsed by, or sponsored by Indeed, Inc. Indeed is a registered trademark of Indeed, Inc. RolesAPI provides access to publicly available data via a stable REST API. ## What is RolesAPI's relationship to Indeed? None. RolesAPI is an independent product with no partnership, sponsorship, endorsement, or other business relationship with Indeed, Inc. References to Indeed throughout this site are nominative — they describe the source of the job-postings data the API serves, and nothing more. Product names such as "RolesAPI" and this website are not associated with Indeed, Inc. in any way. ## What data does RolesAPI serve? RolesAPI provides access to publicly available job-postings data only: the role information an employer publishes for anyone to see — title, company, location, salary where posted, benefits, and description. RolesAPI does not serve resume data, candidate profiles, application data, or any personal information about job seekers. It has no access to accounts, private listings, or anything behind a login. ## How do I raise a trademark or takedown concern? If you are a rights holder — an employer whose posting appears in API responses, or a trademark owner with a concern about content on this site — contact us and we will respond promptly: **nikhil@landkit.pro** Please include the specific URL or `job_key` at issue and the nature of your concern, so we can act on it quickly.