{
  "openapi": "3.1.0",
  "info": {
    "title": "Upwork API",
    "version": "1.0.0",
    "description": "Search public Upwork job postings by keyword or by an Upwork search URL, and read one posting in full. Budgets are parsed rather than passed through as text, and every response reports how many postings were expanded into their full record (enrichedJobs) against how many came back — because per-posting expansion is intermittently refused upstream and the response says so rather than hiding it."
  },
  "servers": [
    {
      "url": "https://api.fetchlayer.dev/upwork"
    }
  ],
  "security": [
    {
      "BearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API Key",
        "description": "Send your FetchLayer key as Authorization: Bearer ss-your-key."
      }
    }
  },
  "paths": {
    "/search-jobs": {
      "post": {
        "operationId": "search-jobs",
        "tags": [
          "Jobs"
        ],
        "summary": "Search job postings",
        "description": "Search public Upwork job postings by keyword or by an Upwork search page address, with the platform's own filters — job type, experience level, budget band, client history, location and posting age. Every field here is optional on its own, but the request carries a cross-field rule: **give either `query` or `searchUrl`**. An empty body is a 400 in about 80ms — `\"Provide either a query or a searchUrl.\"` — not an unfiltered search of everything. **Coverage caveat, and the first thing to build around.** A search always returns the listing-level fields Upwork puts on its results page. The fuller record behind each posting — the exact posting time, the proposal count, the client's hire history — is **not always available**, and what is available varies by hour. When it is not, this route still answers **200**: the postings carry listing-level fields only, `enrichedJobs` comes back **lower than `jobs.length`** (0 when nothing expanded), every job is marked `enriched: false`, and `notes[]` says so in words — *\"Full detail was not available for this request, so these postings carry listing-level fields only (see \\\"enriched\\\" on each job). Exact posting times, proposal counts and client hire history are omitted. Retrying often succeeds.\"* **Detect it with one comparison: `enrichedJobs` against `jobs.length`.** A thin row is a posting whose detail was not published to this request, not a posting with no proposals — reading it the second way silently corrupts any analysis built on it. Retrying often succeeds. Billing: 1 credit per results page fetched, reported as `pagesFetched`. A page costs the same whether it returns 3 postings or 50, and a posting that came back without its fuller record costs nothing extra.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "query": {
                    "type": "string",
                    "description": "Keywords to search postings for, e.g. \"python developer\". Either this or `searchUrl` must be given — an empty body is a 400."
                  },
                  "searchUrl": {
                    "type": "string",
                    "description": "An Upwork job-search page address to read the filters off, e.g. https://www.upwork.com/nx/search/jobs/?q=react. The easiest way to reproduce a search built in the Upwork UI. Either this or `query` must be given."
                  },
                  "page": {
                    "type": "string",
                    "description": "Which results page to start from.",
                    "example": "1"
                  },
                  "pages": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "How many results pages to walk, up to 20. Each page is 1 credit.",
                    "example": "1"
                  },
                  "perPage": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Postings per page requested from Upwork, up to 50."
                  },
                  "limit": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Maximum postings returned in total, up to 500."
                  },
                  "sort": {
                    "type": "string",
                    "description": "Result order: relevance or newest.",
                    "example": "relevance"
                  },
                  "experienceLevel": {
                    "type": "string",
                    "description": "Narrow to one level: entry, intermediate, expert."
                  },
                  "jobType": {
                    "type": "string",
                    "description": "Narrow to hourly or fixed postings."
                  },
                  "paymentVerified": {
                    "type": "boolean",
                    "description": "Only postings from clients whose payment method Upwork has verified."
                  },
                  "fixedPriceRange": {
                    "type": "string",
                    "description": "Fixed-price budget band, in the spelling Upwork uses on its own filter."
                  },
                  "hourlyRateRange": {
                    "type": "string",
                    "description": "Hourly rate band, in the spelling Upwork uses on its own filter."
                  },
                  "clientHistory": {
                    "type": "string",
                    "description": "Narrow by how much the client has hired before, in Upwork's own vocabulary."
                  },
                  "location": {
                    "type": "string",
                    "description": "Narrow to postings open to freelancers in a location."
                  },
                  "maxJobAge": {
                    "type": "string",
                    "description": "Only postings newer than this age, in the spelling Upwork uses on its own filter."
                  },
                  "filters": {
                    "type": "object",
                    "additionalProperties": true,
                    "description": "Any further Upwork filter parameters, passed through as the search page's own query fields."
                  },
                  "includeDetails": {
                    "type": "boolean",
                    "description": "Ask for every posting to come back with its fuller record. Best-effort: when it is not available, the response is still a 200 with listing-level fields, `enrichedJobs` below `jobs.length` and a note. Verified against prod — setting it true does not guarantee it."
                  },
                  "format": {
                    "type": "string",
                    "description": "Envelope format: json or markdown.",
                    "example": "json"
                  }
                },
                "additionalProperties": false
              },
              "examples": {
                "default": {
                  "value": {
                    "query": "python developer",
                    "limit": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "transport": {
                      "type": "string",
                      "description": "The source. \"upwork-jobs\"."
                    },
                    "notes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Caveats you can act on. The one that matters here says per-posting detail was not available and the postings carry listing-level fields only."
                    },
                    "pagesFetched": {
                      "type": "number",
                      "description": "Pages read, and what the request is billed at: 1 credit per page. A search of one results page is 1 whether it returns 3 postings or 50."
                    },
                    "query": {
                      "type": "string",
                      "description": "The keywords that were searched, or null when the request was made with `searchUrl` alone."
                    },
                    "searchUrl": {
                      "type": "string",
                      "description": "The Upwork results page the search read."
                    },
                    "pages": {
                      "type": "array",
                      "items": {
                        "type": "number"
                      },
                      "description": "Which results pages were walked."
                    },
                    "totalJobs": {
                      "type": "number",
                      "description": "How many postings came back."
                    },
                    "enrichedJobs": {
                      "type": "number",
                      "description": "**How many of them came back with their fuller record.** Compare this to `jobs.length`: lower means the rest carry listing-level fields only. This is the field to branch on, not `notes`."
                    },
                    "jobs": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "The posting's Upwork id, in its `~02…` form. Pass it to /job-detail as `jobId`."
                          },
                          "uid": {
                            "type": "string",
                            "description": "The same id without its prefix."
                          },
                          "title": {
                            "type": "string",
                            "description": "The posting title as published."
                          },
                          "description": {
                            "type": "string",
                            "description": "The posting body. On a listing this is the text Upwork shows on the results page."
                          },
                          "url": {
                            "type": "string",
                            "description": "The posting's full address. Pass it to /job-detail as `jobUrl`."
                          },
                          "jobType": {
                            "type": "string",
                            "description": "\"hourly\" or \"fixed\"."
                          },
                          "budget": {
                            "type": "object",
                            "additionalProperties": true,
                            "description": "Parsed rather than passed through as text: {type, amount, min, max, currency, raw}. A fixed-price posting fills `amount` ({type:\"fixed\", amount:210, currency:\"USD\", raw:\"Est. budget:$210.00\"}). An hourly posting with a published range fills `min` and `max` ({type:\"hourly\", min:15, max:100, currency:\"USD\", raw:\"Hourly: $15.00 - $100.00\"}). An hourly posting with no published range fills neither and keeps `raw: \"Hourly\"` — so a null `min` means Upwork published no rate, not that the rate is zero. `raw` is always the string Upwork showed, so any parse can be checked against it."
                          },
                          "experienceLevel": {
                            "type": "string",
                            "description": "The level the client asked for: entry, intermediate, expert (published capitalised, e.g. \"Intermediate\")."
                          },
                          "relativeDate": {
                            "type": "string",
                            "description": "The posting age exactly as Upwork wrote it, e.g. \"Posted 1 hour ago\"."
                          },
                          "postedAt": {
                            "type": "string",
                            "description": "An ISO timestamp. On an unenriched posting this is derived from `relativeDate`, so it is accurate to the bucket Upwork published rather than to the minute."
                          },
                          "postedAtIsApproximate": {
                            "type": "boolean",
                            "description": "True when `postedAt` came from the relative label rather than from an exact published time — which is the normal case on a listing. Check it before treating `postedAt` as a precise ordering key."
                          },
                          "skills": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "description": "The skill tags on the posting, from Upwork's own annotations."
                          },
                          "proposals": {
                            "type": "number",
                            "description": "The exact proposal count. Published only on an expanded posting; null on a listing-level one."
                          },
                          "proposalsRange": {
                            "type": "object",
                            "additionalProperties": true,
                            "description": "{min, max, label} — the bucket Upwork shows on the results page, e.g. {min:50, max:null, label:\"50+\"}. This is what you get when `proposals` is null, and it is usually present even on an unenriched posting."
                          },
                          "duration": {
                            "type": "string",
                            "description": "Expected project length as published, e.g. \"1 to 3 months\"."
                          },
                          "workload": {
                            "type": "string",
                            "description": "Expected time commitment as published, e.g. \"Less than 30 hrs/week\"."
                          },
                          "freelancersToHire": {
                            "type": "number",
                            "description": "How many people the client intends to hire, when published."
                          },
                          "clientLocation": {
                            "type": "string",
                            "description": "The client's country as Upwork shows it."
                          },
                          "clientPaymentVerified": {
                            "type": "boolean",
                            "description": "Whether Upwork has verified the client's payment method."
                          },
                          "clientRating": {
                            "type": "number",
                            "description": "The client's average feedback score. 0 on a client with no history — check `clientFeedbackCount` before reading a 0 as a bad rating."
                          },
                          "clientFeedbackCount": {
                            "type": "number",
                            "description": "How many reviews that rating is based on."
                          },
                          "clientTotalSpent": {
                            "type": "number",
                            "description": "Total the client has spent on Upwork."
                          },
                          "clientHireRatePercent": {
                            "type": "number",
                            "description": "Share of the client's postings that ended in a hire. Published on an expanded posting."
                          },
                          "clientAvgHourlyRate": {
                            "type": "number",
                            "description": "Average hourly rate the client has paid. Published on an expanded posting."
                          },
                          "clientName": {
                            "type": "string",
                            "description": "The client name, where one could be established."
                          },
                          "clientNameConfidence": {
                            "type": "string",
                            "description": "How firmly `clientName` was established, so a guess is never mistaken for a published name."
                          },
                          "clientHasHired": {
                            "type": "boolean",
                            "description": "Whether the client has hired before. Published on an expanded posting."
                          },
                          "allowedApplicantCountries": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "description": "Countries the client restricted applicants to, when they did."
                          },
                          "screeningQuestions": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "description": "Questions applicants must answer, when the posting has any."
                          },
                          "category": {
                            "type": "string",
                            "description": "Upwork's category for the posting."
                          },
                          "categoryGroup": {
                            "type": "string",
                            "description": "Upwork's broader category group."
                          },
                          "enriched": {
                            "type": "boolean",
                            "description": "Whether this posting came back with its fuller record. **False is the field to branch on**: it means the fields above marked \"published on an expanded posting\" are absent for this row, and it is not an error."
                          }
                        },
                        "additionalProperties": false
                      }
                    }
                  },
                  "additionalProperties": false
                },
                "examples": {
                  "default": {
                    "value": {
                      "query": "python developer",
                      "searchUrl": "https://www.upwork.com/nx/search/jobs/?q=python+developer",
                      "pages": [
                        1
                      ],
                      "totalJobs": 5,
                      "enrichedJobs": 0,
                      "jobs": [
                        {
                          "id": "~022100333565184435123",
                          "uid": "2100333565184435123",
                          "title": "Full Stack Developer for Web App",
                          "description": "We need a full stack developer to build a web app for a small business. …",
                          "url": "https://www.upwork.com/jobs/Full-Stack-Developer-for-Web-App_~022100333565184435123/",
                          "jobType": "hourly",
                          "budget": {
                            "type": "hourly",
                            "amount": null,
                            "min": 15,
                            "max": 100,
                            "currency": "USD",
                            "raw": "Hourly: $15.00 - $100.00"
                          },
                          "experienceLevel": "Intermediate",
                          "relativeDate": "Posted 1 hour ago",
                          "postedAt": "2026-09-16T21:52:00.000Z",
                          "postedAtIsApproximate": true,
                          "skills": [
                            "Python",
                            "JavaScript",
                            "HTML5",
                            "PHP",
                            "AngularJS",
                            "TypeScript"
                          ],
                          "proposals": null,
                          "proposalsRange": {
                            "min": 50,
                            "max": null,
                            "label": "50+"
                          },
                          "duration": "1 to 3 months",
                          "workload": "Less than 30 hrs/week",
                          "clientLocation": "United States",
                          "clientPaymentVerified": true,
                          "clientRating": 0,
                          "clientFeedbackCount": 0,
                          "clientTotalSpent": 0,
                          "enriched": false
                        },
                        {
                          "id": "~022100291793583666591",
                          "uid": "2100291793583666591",
                          "title": "Digital Skills Teacher",
                          "description": "…",
                          "url": "https://www.upwork.com/jobs/Digital-Skills-Teacher_~022100291793583666591/",
                          "jobType": "fixed",
                          "budget": {
                            "type": "fixed",
                            "amount": 210,
                            "min": null,
                            "max": null,
                            "currency": "USD",
                            "raw": "Est. budget:$210.00"
                          },
                          "experienceLevel": "Intermediate",
                          "relativeDate": "Posted 2 hours ago",
                          "postedAt": "2026-09-16T20:52:00.000Z",
                          "postedAtIsApproximate": true,
                          "skills": [
                            "Teaching",
                            "Curriculum Development"
                          ],
                          "proposals": null,
                          "proposalsRange": {
                            "min": 10,
                            "max": 15,
                            "label": "10 to 15"
                          },
                          "clientLocation": "United Kingdom",
                          "clientPaymentVerified": true,
                          "enriched": false
                        }
                      ],
                      "notes": [
                        "Full detail was not available for this request, so these postings carry listing-level fields only (see \"enriched\" on each job). Exact posting times, proposal counts and client hire history are omitted. Retrying often succeeds."
                      ],
                      "transport": "upwork-jobs",
                      "pagesFetched": 1
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing Bearer token"
          }
        },
        "security": [
          {
            "BearerAuth": []
          }
        ]
      }
    },
    "/job-detail": {
      "post": {
        "operationId": "job-detail",
        "tags": [
          "Jobs"
        ],
        "summary": "Read one posting in full",
        "description": "Read one Upwork posting in full by its id or its URL — the fuller record behind a search result, with the exact posting time, the proposal count and the client's hire history. Give either `jobId` or `jobUrl`; an empty body is a 400 saying `\"Provide either a jobId or a jobUrl.\"` **Coverage caveat, and the first thing to build around.** A search always returns the listing-level fields Upwork puts on its results page. The fuller record behind each posting — the exact posting time, the proposal count, the client's hire history — is **not always available**, and what is available varies by hour. When it is not, this route still answers **200**: the postings carry listing-level fields only, `enrichedJobs` comes back **lower than `jobs.length`** (0 when nothing expanded), every job is marked `enriched: false`, and `notes[]` says so in words — *\"Full detail was not available for this request, so these postings carry listing-level fields only (see \\\"enriched\\\" on each job). Exact posting times, proposal counts and client hire history are omitted. Retrying often succeeds.\"* **Detect it with one comparison: `enrichedJobs` against `jobs.length`.** A thin row is a posting whose detail was not published to this request, not a posting with no proposals — reading it the second way silently corrupts any analysis built on it. Retrying often succeeds. **Here the same gap is a 503, not a thin 200**, because this route returns only that fuller record and has no listing-level rows to hand back instead: `{\"error\":\"The upstream source is temporarily refusing automated requests. Please retry.\"}`. Four consecutive calls returned it during the window measured on 17 September 2026, while `/search-jobs` kept answering 200 throughout. Treat this route as best-effort: retry it, and fall back to the listing-level fields from a search when you need breadth rather than depth. Billing: 1 credit per page fetched, reported as `pagesFetched`. A 503 fetched no page.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "jobId": {
                    "type": "string",
                    "description": "The posting's id, as returned on a search result's `id` or `uid`. Either this or `jobUrl` must be given."
                  },
                  "jobUrl": {
                    "type": "string",
                    "description": "The posting's full Upwork address, as returned on a search result's `url`. Either this or `jobId` must be given."
                  },
                  "format": {
                    "type": "string",
                    "description": "Envelope format: json or markdown.",
                    "example": "json"
                  }
                },
                "additionalProperties": false
              },
              "examples": {
                "default": {
                  "value": {
                    "jobUrl": "https://www.upwork.com/jobs/Digital-Skills-Teacher_~022100291793583666591/"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "transport": {
                      "type": "string",
                      "description": "The source. \"upwork-jobs\"."
                    },
                    "notes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Caveats you can act on. The one that matters here says per-posting detail was not available and the postings carry listing-level fields only."
                    },
                    "pagesFetched": {
                      "type": "number",
                      "description": "Pages read, and what the request is billed at: 1 credit per page. A search of one results page is 1 whether it returns 3 postings or 50."
                    },
                    "job": {
                      "type": "object",
                      "additionalProperties": true,
                      "description": "The posting, in the same shape as a `jobs[]` entry from /search-jobs — with `enriched: true` and the fuller fields filled in."
                    }
                  },
                  "additionalProperties": false
                },
                "examples": {
                  "default": {
                    "value": {
                      "error": "The upstream source is temporarily refusing automated requests. Please retry."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing Bearer token"
          }
        },
        "security": [
          {
            "BearerAuth": []
          }
        ]
      }
    },
    "/resolve-url": {
      "post": {
        "operationId": "resolve-url",
        "tags": [
          "Utilities"
        ],
        "summary": "Parse an Upwork address",
        "description": "Parse any Upwork address into structured input for the other routes, without fetching anything. Tells you whether an address is a results page or a posting, and pulls the search terms off a results page so you can hand them straight to /search-jobs. The right first step for a list of pasted addresses — it sorts them before a single page fetch is spent. **Free.** It makes no upstream request at all, answers in about a tenth of a second, and reports `pagesFetched: 0`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "Any Upwork address — a job posting or a job-search results page."
                  },
                  "format": {
                    "type": "string",
                    "description": "Envelope format: json or markdown.",
                    "example": "json"
                  }
                },
                "additionalProperties": false,
                "required": [
                  "url"
                ]
              },
              "examples": {
                "default": {
                  "value": {
                    "url": "https://www.upwork.com/nx/search/jobs/?q=python"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "transport": {
                      "type": "string",
                      "description": "The source. \"upwork-jobs\"."
                    },
                    "notes": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      },
                      "description": "Caveats you can act on. The one that matters here says per-posting detail was not available and the postings carry listing-level fields only."
                    },
                    "pagesFetched": {
                      "type": "number",
                      "description": "Pages read, and what the request is billed at: 1 credit per page. A search of one results page is 1 whether it returns 3 postings or 50."
                    },
                    "kind": {
                      "type": "string",
                      "description": "\"search\" for a results page, or the posting kind for a single job."
                    },
                    "jobId": {
                      "type": "string",
                      "description": "The posting's id when the address names one; null on a results page."
                    },
                    "searchUrl": {
                      "type": "string",
                      "description": "The results page address, ready to pass to /search-jobs as `searchUrl`."
                    },
                    "query": {
                      "type": "string",
                      "description": "The search terms read off a results page, ready to pass to /search-jobs as `query`."
                    }
                  },
                  "additionalProperties": false
                },
                "examples": {
                  "default": {
                    "value": {
                      "kind": "search",
                      "jobId": null,
                      "searchUrl": "https://www.upwork.com/nx/search/jobs/?q=python",
                      "query": "python",
                      "notes": [
                        "Use the job search capability with this searchUrl."
                      ],
                      "transport": "upwork-jobs",
                      "pagesFetched": 0
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing Bearer token"
          }
        },
        "security": [
          {
            "BearerAuth": []
          }
        ]
      }
    }
  }
}