Getting Started
Public Upwork job postings as structured JSON. No Upwork account — just your FetchLayer key.
1. Get your API key
Create an account and generate a FetchLayer API key from your dashboard. The same key works on every platform.
Create free account →2. Search postings
Give a query, or an Upwork search page address as searchUrl. One of the two is required.
curl -X POST https://api.fetchlayer.dev/upwork/search-jobs \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"query":"python developer","limit":5}'Check enrichedJobs against jobs.length before you use the data
enrichedJobs tells you how many came back complete, and each row carries enriched so you can filter per posting rather than per response. A real response measured on 17 September 2026 returned 5 postings with enrichedJobs: 0 — a 200, with a note saying exactly that. Retrying often returns more. null is not zero
proposals: null on a listing-level posting means the exact count was not published to this request — and proposalsRange is still there with {"label":"50+"}. A posting nobody has applied to would be a real 0. Branch on enriched before you aggregate anything. 3. Reuse a search you already built
Build the search on Upwork with its own filter UI, then paste the address. The filters come across as-is, so there is no re-expressing a saved search as a dozen parameters.
curl -X POST https://api.fetchlayer.dev/upwork/search-jobs \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"searchUrl":"https://www.upwork.com/nx/search/jobs/?q=react","limit":3}'An empty body is a 400, on purpose
{} returns “Provide either a query or a searchUrl.” in about 80ms. Every individual field is optional, but the request has to name a subject — an unfiltered sweep of every posting on Upwork is not something anyone means to ask for. The body is also a strict object, so an unknown field is a 400 naming it rather than a field quietly ignored. 4. Read one posting in full
Pass a posting’s id or URL from a search result for the exact posting time, the exact proposal count, the screening questions and the client’s hire history.
curl -X POST https://api.fetchlayer.dev/upwork/job-detail \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"jobUrl":"https://www.upwork.com/jobs/Digital-Skills-Teacher_~022100291793583666591/"}'This route is best-effort, and says so with a 503
/search-jobs kept answering 200 throughout. Retry it, and use the search route when you need breadth rather than depth. 5. Sort a pile of pasted links first
Free, fetches nothing, and tells you which route each address belongs to — plus the search terms read straight off a results page.
curl -X POST https://api.fetchlayer.dev/upwork/resolve-url \
-H "Authorization: Bearer ss-your-key" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.upwork.com/nx/search/jobs/?q=python"}'That returns {"kind":"search","jobId":null,"searchUrl":"…","query":"python","pagesFetched":0} in about a tenth of a second — hand searchUrl or query straight to /search-jobs.
Speed and billing
One page of results measured 24–28 seconds against production, and costs 1 credit whether it returns 3 postings or 50. Several pages in one request take proportionally longer, so page through rather than batching if something is waiting on the response. /resolve-url is free.
Errors and retries
A 400 names what is wrong with the request — an empty body, or an unrecognised field. A 401 means the key is missing or invalid. A 503 on /job-detail means the fuller record was not available; retry it, and fall back to /search-jobs in the meantime.
Error responses
Every error, on every route, is JSON in one shape: error (what went wrong, in words), status, code, retryable and requestId, which also comes as the X-Request-Id header. Branch on code and retryable rather than on the text; on a 5xx the text is generic. Quote requestId when reporting a problem.
{
"error": "Missing required parameter: url",
"status": 400,
"missing": ["url"],
"code": "invalid_input",
"retryable": false,
"requestId": "922874e3-c0d0-40d8-8866-d198af132420"
}| code | Status | Retryable | Meaning |
|---|---|---|---|
| invalid_input | 400 | no | The request needs fixing; error names the field. A missing required parameter also lists it in missing. |
| unauthorized | 401 | no | The API key is missing or not valid. |
| insufficient_credits | 402 | no | Nothing left to spend: no credits, a used-up free trial, or a monthly allowance and credit balance both spent. error says which. Waiting does not help; topping up does. |
| forbidden | 403 | no | This key or account is not allowed this request. |
| not_found | 404 | no | What the request names does not exist or is not public. |
| expired | 410 | no | What the request refers to is no longer available; ask for it afresh. |
| quota_exceeded | 429 | no | A request-rate limit was reached; error says which. Slow down before sending more. |
| cancelled | 499 | no | The caller closed or cancelled the request before it finished. The work was stopped and nothing was charged. |
| time_limit | 503 | yes | Your timeoutMs ran out before there was anything to return; ask again, or allow more time. |
| temporarily_unavailable | 503 | yes | Not available right now; ask again shortly. |
| posts_unavailable | 403 | no | TikTok user videos: TikTok is not showing this account’s posts publicly right now. Asking again at once returns the same answer; nothing is charged. |
| store_unreachable | 503 | yes | Store routes (Shopify, WooCommerce): the store itself can’t be reached right now — it may be offline. Ask again later. Where a store route doesn’t say so yet, an offline store is temporarily_unavailable. |
| upstream_error | 502 | yes | The source answered in a way that could not be used; ask again later. |
| internal_error | 500 | yes | Something failed on our side; ask again later, and quote requestId if it persists. |
| tool_error | — | no | MCP only: a tool call failed in a way no other code describes; error says what. |
More codes may be added; treat one you do not recognise by its retryable flag. Over MCP, a tool error carries the same fields in structuredContent, and as a JSON text block beside the message.