# How Lancer works (/docs/how-lancer-works) Lancer generates leads on Upwork by finding the right jobs, deciding which ones are worth your connects, writing the proposals, and submitting them from your own Upwork account. This page explains that pipeline once, at a high level, so the rest of the docs make sense. ## The pipeline, step by step [#the-pipeline-step-by-step] ### 1. Campaigns find jobs [#1-campaigns-find-jobs] A **campaign** is a targeting strategy: search queries and filters (keywords, budget floors, client history, payment verification, regions, and more) that continuously scan new Upwork job posts. Every job that matches becomes a **lead** in the campaign's feed. Jobs that do not match are never seen again, which makes filters your volume control. ### 2. The suitability AI decides [#2-the-suitability-ai-decides] Matching a filter is not the same as being worth a proposal. Each lead is scored by Lancer's **suitability AI**, which reads your **knowledge base** and returns a plain yes or no per job. The knowledge base is a profile you author (or let an [MCP agent](/docs/mcp) draft for you) describing what you actually do: core services, tools and technologies, projects you avoid, languages, case studies. It is the single biggest lever over which jobs get through. A vague knowledge base lets bad jobs through; an overly narrow one starves the campaign. Rejected leads are kept with the AI's reasoning, so you can audit its decisions and tune the knowledge base until you agree with them. ### 3. The proposal is generated [#3-the-proposal-is-generated] For each suitable job, Lancer's proposal AI writes the cover letter and answers the job's screening questions. It composes three things you control: * Your **cover letter template**. Literal text is kept word for word, and anything wrapped in double curly braces is a per-job instruction, for example `{{mention the most relevant case study from the knowledge base}}`. * Your **question rules**, reusable answers for screening questions that follow the same convention. * Your **knowledge base**, which gives the AI the facts it is allowed to use. This is why the proposals sound like you: the AI fills in job-specific blanks inside copy you wrote, rather than writing freely. ### 4. Submission through your account [#4-submission-through-your-account] Proposals are submitted on Upwork through your connected Upwork account (called a **bidder account** in Lancer), applying the campaign's bidding settings: hourly rate strategy, connects budget, and optional boosting. Each campaign binds to exactly one bidder account. ### 5. Tracking and pipeline [#5-tracking-and-pipeline] Lancer tracks each proposal's lifecycle: contacted, opened, replied, rejected, plus connect spend. When a client replies, the conversation appears in your **inbox**, and the lead enters your **pipeline**, a CRM board with stages, deal values, notes, tasks, and meeting scheduling. ## Where the developer surfaces plug in [#where-the-developer-surfaces-plug-in] * The **[MCP server](/docs/mcp)** lets an AI agent configure and operate everything above: draft the knowledge base, create campaigns, simulate suitability decisions on real jobs before going live, write templates, and manage the inbox and pipeline. * **[External mode](/docs/external-mode)** replaces steps 2 and 3 for a campaign. Lancer still finds the jobs (step 1) and still submits and tracks (steps 4 and 5), but the deciding and writing move to your own system via webhooks and the API. ## Concepts cheat sheet [#concepts-cheat-sheet] | Term | What it is | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Organization | Your account's workspace. Campaigns, members, billing, and settings all live in one. | | Campaign | A targeting strategy: filters plus bidding config plus bindings to a template, a knowledge base profile, and a bidder account. | | Bidder account | A connected Upwork account (freelancer or agency) that submits the proposals. | | Lead | An Upwork job captured by a campaign, with its submission state and a snapshot of the client. | | Knowledge base | Your authored profile that the suitability AI reads to decide which jobs fit you. | | Cover letter template | Your proposal skeleton; `{{...}}` segments become per-job content. | | Question rules | Reusable answers for screening questions, same `{{...}}` convention. | | Proposal | The submitted application: cover letter, answers, attachments, bid, and boost. | | Pipeline | The CRM board your replied leads move through. | # Introduction (/docs) Lancer finds Upwork jobs that match your campaigns, decides which ones are worth bidding on, writes the proposals, and submits them. New to Lancer or fuzzy on the concepts? Start with [How Lancer works](/docs/how-lancer-works). Most people run Lancer entirely from the web app, but everything important is also available programmatically. These docs cover the two surfaces you can build on: Connect Claude, ChatGPT, or any MCP client and let your AI agent configure and run Lancer: knowledge base, campaigns, cover letters, inbox, and pipeline. Flip a campaign to external and Lancer streams matching jobs to your system over webhooks. You decide what to bid and what to write through the API. Lancer still handles the Upwork submission. ## Which surface do you need? [#which-surface-do-you-need] | You want to | Use | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Set up or optimize Lancer by chatting with an AI agent | [MCP server](/docs/mcp) | | Pull matching jobs into your own system and control every bid | [External mode](/docs/external-mode), which pairs [webhooks](/docs/external-mode/webhooks) with the [API](/docs/external-mode/api) | | Get notified in your own tooling when something happens | [Webhooks](/docs/external-mode/webhooks), usable on any campaign | | Submit a bid or reject a lead from a script | The [API](/docs/external-mode/api) | ## Requirements [#requirements] * A Lancer account with an organization. Sign up at [lancer.app](https://lancer.app). * An active subscription or trial. The Public API and webhooks require a paid plan; the MCP server works during trials too. * For anything that touches Upwork, at least one connected Upwork account (called a bidder account) in your organization. ## Base URLs [#base-urls] | Surface | URL | | ----------------- | ---------------------------- | | Web app | `https://1.lancer.app` | | API and MCP host | `https://api.lancer.app` | | MCP endpoint | `https://api.lancer.app/mcp` | | External mode API | `https://api.lancer.app/v1` | ## Built for AI agents [#built-for-ai-agents] Every page has a **Copy Markdown** button next to its title, ready to paste into any AI assistant. For agents that fetch URLs themselves, append `.md` to any page URL for raw markdown, or use `https://docs.lancer.app/llms.txt` (an index of every page) and `https://docs.lancer.app/llms-full.txt` (the entire documentation as one markdown file). # Overview (/docs/external-mode) Every Lancer campaign runs in one of two modes: * **Standard** (the default): Lancer picks the jobs, writes the proposals, and submits them. This is how Lancer works out of the box. * **External**: Lancer finds the jobs and submits the proposals, but **you handle everything in between**: deciding which jobs are worth bidding on, writing the cover letter, answering the screening questions. Mode is set per campaign. You can run standard and external campaigns side by side in the same organization, each with its own filters and its own logic. ## How it works [#how-it-works] External mode is a simple loop with three parts: 1. **Lancer sends you the job.** Whenever a job passes the campaign's saved filters, Lancer POSTs it to your webhook URL as a [`job.new` event](/docs/external-mode/webhooks/events#jobnew-campaign-webhooks). The payload contains everything about the job and the client, plus ready-made `bidUrl` and `rejectUrl` links. 2. **You decide and write.** Your system looks at the job and either passes on it or produces a proposal. What happens here is entirely up to you. 3. **You call back, Lancer submits.** POST your cover letter and answers to `bidUrl`, or a rejection to `rejectUrl`, using your [API key](/docs/external-mode/api/authentication). Lancer submits the proposal on Upwork through your connected account and confirms with a `bid_complete` webhook. If you never call back for a job, nothing happens: the lead is simply skipped. There is no timeout and no fallback bid. You are always in control. ## The step in between is the whole point [#the-step-in-between-is-the-whole-point] Once `job.new` hits your server, Lancer is out of the loop until you call back. That middle step is yours, with full creative freedom: * **No-code workflows** in n8n, Zapier, or Make. A webhook trigger plus an HTTP request node is a complete integration. * **Your own prompts and models.** Score suitability and draft proposals with whatever AI provider and system prompts you like. * **Personalized extras.** Intro videos generated per client (ElevenLabs, HeyGen, Loom), custom work samples built from what the job post mentions, enrichment from your own data. * **Plain code.** A small script on any server you host. Nothing about the in-between step is dictated by Lancer. ## What changes when you flip a campaign to external [#what-changes-when-you-flip-a-campaign-to-external] 1. Lancer's suitability AI stops running for this campaign. Every job that passes the filters goes to your webhook instead. 2. Your campaign filters become the only gate. Jobs that do not match never reach your endpoint, so the filters are your volume control. 3. Slack and Discord "new job" alerts are turned off for this campaign; the webhook is the delivery channel. Other campaigns are unaffected. 4. The go-live requirements change. An external campaign can start once it has at least one saved filter and one active `job.new` webhook. The standard-mode requirements (knowledge base, cover letter template) do not apply, because you provide the content. 5. If the campaign was active, switching pauses it, so nothing streams before your integration is ready. 6. Every lead card in the campaign Feed gets a **Trigger** button that re-sends `job.new` for that job, which makes testing easy. Switching back to standard mode reverses everything: webhooks stop firing, Lancer takes back over, alerts resume. Your webhooks stay configured but inactive, so you can flip back and forth safely. ## What you need [#what-you-need] * A Lancer organization on a paid plan, with a campaign and a connected Upwork account. * An [API key](/docs/external-mode/api/authentication) (Settings, then Integrations, API keys tab). * Somewhere to receive a webhook: an n8n/Zapier/Make workflow or any HTTPS endpoint. Ready? The [Quickstart](/docs/external-mode/quickstart) walks through the whole setup in about 15 minutes. # Quickstart (/docs/external-mode/quickstart) By the end of this guide, Lancer will be sending matching jobs to you, and you will be bidding on them through the API. No step requires more than a browser and the ability to make an HTTP request; an n8n, Zapier, or Make workflow is enough. **Before you start**, make sure you have: * A Lancer organization on a paid plan. * A campaign (a draft is fine). * A connected Upwork account on that campaign. ## Create an API key [#create-an-api-key] In the Lancer web app, go to **Settings**, then **Integrations**. On the **API keys** tab, click **Create key** and give it any name, for example `my-bidding-bot`. Copy the key now. It starts with `lk_` and is shown **only once**. One key per organization is usually enough; it works for every campaign. ## Get a webhook URL [#get-a-webhook-url] You need a URL that Lancer can POST jobs to. Pick whichever is easiest for you: * **n8n / Zapier / Make**: create a new workflow starting with a webhook trigger and copy its URL. This is the fastest path and needs no code. * **Your own server**: any HTTPS endpoint that accepts POST requests. While developing locally, expose it with a tunnel like `ngrok http 8080` and use the tunnel URL. Whatever receives the request only has to do one thing for now: respond with HTTP 200 within 10 seconds. ## Switch the campaign to External and add the webhook [#switch-the-campaign-to-external-and-add-the-webhook] Open your campaign and go to its **Integrations** tab: 1. Flip **Bidding mode** to **External**. If the campaign was active it gets paused, which is expected; nothing will stream until you are ready. 2. Click **Add webhook**, paste your URL, and subscribe to the `job.new` event. That is the entire Lancer-side setup. ## Send yourself a test job [#send-yourself-a-test-job] You do not have to wait for a real job. Two buttons send one on demand: * **Test** (next to your webhook in the Integrations tab): sends a `job.new` built from a recent lead of this campaign. * **Trigger** (on any lead card in the campaign's **Feed** tab): sends a `job.new` for that exact job. Fire one and look at what arrived in your workflow or server. This is a complete, realistic payload with every section annotated: ```jsonc { "id": "YDDYfNaFpafJMIQCdqC8", // delivery id, also in the X-Lancer-Delivery-Id header "event": "job.new", "version": "2026-04-20", "createdAt": 1776861204, // UNIX seconds "data": { "organizationId": "4IumEJtmvJEE2nShMWwM", "campaignId": "ICdP5Knfizog5IkDRyTV", "leadId": "ma6H1U9jYesVEzLjzhAc", // Ready-made action URLs. Call one of them to answer. "bidUrl": "https://api.lancer.app/v1/campaigns/ICdP5Knfizog5IkDRyTV/leads/ma6H1U9jYesVEzLjzhAc/bid", "rejectUrl": "https://api.lancer.app/v1/campaigns/ICdP5Knfizog5IkDRyTV/leads/ma6H1U9jYesVEzLjzhAc/reject", "lead": { "id": "ma6H1U9jYesVEzLjzhAc", "createdAt": 1762332662524, // UNIX ms "campaignId": "ICdP5Knfizog5IkDRyTV", // Resolved at dispatch time. null if the campaign was deleted // or otherwise unreadable when the webhook fired. "campaignName": "Zoho / CRM specialists", "organizationId": "4IumEJtmvJEE2nShMWwM", "title": "Customer Support Phone Agent eCommerce | Shopify | Gorgias", "category": "Customer Service & Tech Support", "skills": [ { "name": "Shopify" }, { "name": "Customer Support" }, { "name": "Gorgias" } ], "datetime": 1762332592952, // when the job was posted, UNIX ms "description": "About Us: We're a fast-growing beauty brand...", "projectType": "Hiring 4 freelancers", // "Less than 1 month" | "1 to 3 months" | "3 to 6 months" | "More than 6 months" | null "projectDuration": "3 to 6 months", // Your bid must answer these, quoting each question verbatim. "questions": [ "Are you fine doing email support as well as inbound/outbound call support?", "Which e-commerce brands did you work for before?" ], "jobUrl": "https://www.upwork.com/jobs/~021985992773868976101", "metadata": { "hours": null, // "Less than 30 hrs/week" | "More than 30 hrs/week" | null "duration": "More than 30 hrs/week", // "Entry level" | "Intermediate" | "Expert" "experienceLevel": "Expert", "hourlyRate": { "min": 5, "max": 20 }, // "Hourly" | "Fixed-price" "paymentType": "Hourly", "fixedPrice": null }, "clientInfo": { "isPaymentVerified": true, "isPhoneVerified": false, "rating": 4.79, "numberOfReviews": 59, "country": "United States", "region": "Worldwide", "jobsPosted": 210, "totalSpent": 164280.2, "numberOfHires": 119, "activeEngagements": 57, "openJobs": 15, // count of the client's other open jobs "hireRate": 57, "memberSince": "2021-06-19T00:00:00.000Z", "companyIndustry": null, "companySize": null, "enterpriseClient": false, "avgHourlyRatePaid": 16.5, "companyId": "1406231475600871425" }, "vendorQualifications": { // if not null, only freelancers from this location can bid "location": null, // "Agency" | "Independent" | "ANY"; if specified, only that type can apply "talentType": "ANY", // "FLUENT" | "CONVERSATIONAL" | "NATIVE_OR_BILINGUAL" | "BASIC" | null "englishLevel": "FLUENT", // freelancer must have earned more than this (USD) to bid "minimumEarnings": null, // freelancers with a Job Success Score below this cannot bid "jobSuccessScore": 0, "includeRisingTalent": false }, "isFeatured": false, "clientReviews": [], // "Worldwide" | "USOnly" | "UKOnly" "region": "Worldwide", // The Upwork occupation for the job post. "occupation": "Email, Phone & Chat Support", // Files the client attached to the job post. Empty array (or null) // when the client did not attach anything. Each uri is an // Upwork-relative path; prefix with https://www.upwork.com to // download. The download requires the freelancer's Upwork session, // so links typically only work in a browser logged in to Upwork. "attachments": [ { "fileName": "Brand_guidelines_v3.pdf", "length": 532872, // bytes "uri": "/att/download/openings/2058619791124720878/attachments/cd3c2aea-3602-426c-a16f-d57c0b79f905/download" } ], // The client's other currently open postings. Useful for spotting // clients hiring across multiple roles right now. Distinct from // clientInfo.openJobs above, which is only a count. "openJobs": [ { "id": "1985992773868976101", // The opaque Upwork cipher; build the job URL as // https://www.upwork.com/jobs/~02 "ciphertext": "~021985992773868976101", "title": "Shopify storefront redesign", // "HOURLY" | "FIXED" "type": "HOURLY", // Private Talent Cloud posting flag. Most jobs are false. "isPtcPrivate": false } ] } } } ``` ## Answer with a bid or a reject [#answer-with-a-bid-or-a-reject] Now close the loop. Take the `bidUrl` from the payload you just received and POST to it with your API key. Start with `"dryRun": true`, which validates everything and fires a simulated `bid_complete`, but touches nothing on Upwork: ```bash curl -X POST "https://api.lancer.app/v1/campaigns/.../leads/.../bid" \ -H "Authorization: Bearer lk_..." \ -H "Content-Type: application/json" \ -d '{ "coverLetter": "Hi! I noticed you are looking for...", "questionAnswerPairs": [ { "question": "Which e-commerce brands did you work for before?", "answer": "Over the past three years I supported..." } ], "dryRun": true }' ``` Two rules cover almost every failed request: 1. **Quote questions exactly.** Each `question` string must match the lead's `questions` array word for word. If it does not, the error response lists the accepted strings so you can fix your integration. 2. **`questionAnswerPairs` is always required.** Send `[]` when the job has no questions. To pass on a job instead, POST to `rejectUrl`: ```bash curl -X POST "https://api.lancer.app/v1/campaigns/.../leads/.../reject" \ -H "Authorization: Bearer lk_..." \ -H "Content-Type: application/json" \ -d '{ "reason": "Budget too low" }' ``` Everything you send shows up in the campaign's **Activity** tab, successes and failures alike, so you can debug without any logging on your side. ## Add your decision logic [#add-your-decision-logic] This is your part of the loop, and anything goes: * In **n8n / Zapier / Make**: webhook trigger, then an AI step that scores the job and drafts the cover letter, then an HTTP request to `bidUrl` or `rejectUrl`. * In **code**: a function that receives the payload and decides. Useful signals most people start with: `clientInfo.isPaymentVerified`, `clientInfo.totalSpent`, `clientInfo.rating`, `metadata.hourlyRate`, and keywords in `title` and `description`. * Optionally add flair Lancer cannot: personalized intro videos, custom work samples based on what the client mentioned, your own fine-tuned model. You can also fetch a batch of past matching jobs at any time with [List leads](/docs/external-mode/api/list-leads) to test your logic against real data without waiting for webhooks. ## Go live [#go-live] 1. Remove `"dryRun": true` from your bid call. 2. Check the campaign's filters one more time; they are the only thing deciding which jobs reach you. The `total` from [List leads](/docs/external-mode/api/list-leads) tells you the current matching volume. 3. Start the campaign. The campaign page lists anything still missing (it needs at least one saved filter and one active `job.new` webhook). From now on every matching job lands on your endpoint, and every submitted proposal is confirmed with a `bid_complete` webhook if you [subscribe to it](/docs/external-mode/webhooks/) at the organization level. ## Good to know [#good-to-know] * **There are no retries.** Each event is delivered once, with a 10 second timeout. If your endpoint fails to answer, that delivery is marked failed in the Activity log and the next event arrives normally; the webhook itself stays active. Respond 200 first, process after. * **A `200` from `/bid` means scheduled, not submitted.** The actual Upwork submission happens moments later and is confirmed by `bid_complete`. * **No response means no bid.** Ignoring a job is a valid decision; the lead just stays parked. * **Attachments**: to attach files to a specific proposal, [upload them to the lead](/docs/external-mode/api/upload-attachments) before calling `/bid`. * **Per-bid overrides**: rate strategy and boosting can be set per bid via [`biddingConfig`](/docs/external-mode/api/submit-bid#biddingconfig-fields); anything you omit falls back to the campaign's settings. ## A complete minimal receiver [#a-complete-minimal-receiver] If you prefer code over no-code, this is a full working receiver in Node.js, no dependencies. It mirrors the two layers your system replaces from standard mode: a **suitability layer** that decides whether the job is worth bidding on, and a **proposal layer** that writes the content. ```js title="server.mjs" import http from 'node:http'; const API_KEY = process.env.LANCER_API_KEY; http .createServer((req, res) => { let body = ''; req.on('data', (chunk) => (body += chunk)); req.on('end', () => { res.writeHead(200).end('ok'); // always acknowledge first const payload = JSON.parse(body); if (payload.event === 'job.new') handleJob(payload.data); }); }) .listen(8080); // Layer 1: suitability. Decide whether this job is worth your connects. // This example uses simple client-quality rules; most real setups call // an LLM here with the job description and their own criteria, the same // role Lancer's suitability AI plays in standard mode. function isSuitable(lead) { const client = lead.clientInfo ?? {}; if (!client.isPaymentVerified) return false; if ((client.totalSpent ?? 0) < 1000) return false; const maxRate = lead.metadata?.hourlyRate?.max; if (maxRate !== null && maxRate !== undefined && maxRate < 30) return false; return true; } // Layer 2: proposal. Write the cover letter and answer the screening // questions. Again, most real setups generate this per job with an LLM. function writeProposal(lead) { return { coverLetter: `Hi! I read your post about "${lead.title}" and ...`, questionAnswerPairs: lead.questions.map((question) => ({ question, // quoted verbatim, as required answer: 'My answer here', })), dryRun: true, // remove once you trust the integration }; } async function handleJob({ lead, bidUrl, rejectUrl }) { const suitable = isSuitable(lead); const response = await fetch(suitable ? bidUrl : rejectUrl, { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify( suitable ? writeProposal(lead) : { reason: 'Did not pass suitability rules' }, ), }); console.log(lead.title, '->', suitable ? 'bid' : 'reject', response.status); } ``` Run it with `LANCER_API_KEY=lk_... node server.mjs`, expose port 8080 with a tunnel, and use the tunnel URL as your webhook. # Access, limits, and errors (/docs/mcp/access-and-errors) ## Access and plans [#access-and-plans] * **Who gets a token**: the MCP page in the web app is available to organization owners. Tokens are personal; each member gets their own. * **Subscription**: every organization-scoped tool call checks that the organization has an active subscription. Trials count as active, so you can use the MCP during a trial. * **Paused billing**: if billing collection is paused, MCP access is paused too. Resume billing under Settings, then Billing, in the web app. * **Paid-only features**: creating Public API keys and webhooks through MCP tools requires a paid plan, same as in the web app. ## Limits [#limits] There is no request rate limit on the MCP endpoint. Instead, individual tools cap their own cost: | Limit | Where | | -------------------------------------------------------------------------- | ---------------------------- | | 50 sampled jobs per call (default 20) | `knowledge_base_simulate` | | 10 sampled jobs per call (default 5) | `cover_letter_simulate` | | 1,000 rooms and 500 messages per room (default 100 and 50), 50 MB per file | `inbox_export_start` | | 100 messages per room read (default 30) | `inbox_room_get` | | 25 MB per file, 5 proposal attachments | `campaign_attachment_create` | | About 15 MB per attachment | `inbox_room_send_message` | | 24-hour expiry on export download links | all export tools | Simulation tools run the production AI on each sampled job, so agents are instructed to offer them rather than run them automatically. ## Errors [#errors] Connection-level failures come back as JSON-RPC errors: | Situation | HTTP | Message | | -------------------------------------------------------- | ---- | ------------------------------------------------ | | No token in the URL or headers | 401 | `Missing MCP token` | | Malformed, unknown, or regenerated token | 401 | `Invalid MCP token` | | Expired token | 401 | `MCP token expired` | | Subscription inactive or billing paused | 403 | Explains how to fix it in Settings, then Billing | | More than one organization and no `organizationId` given | 400 | Tells the agent to call `organization_list` | Tool-level failures (a campaign that cannot start, a knowledge base profile at its plan limit, a blocked delete) come back as structured tool errors with a message the agent can act on, not as protocol failures. Well-behaved agents read the message, fix the input or ask you, and retry. Common ones you might see surfaced in chat: * **Campaign cannot start**: the response lists every unmet requirement (missing filters, no template, no active subscription, and so on). * **Knowledge base profile limit reached**: delete an unused profile or upgrade your plan. * **Delete blocked**: knowledge base profiles and templates cannot be deleted or archived while a live campaign uses them. * **Export expired**: export downloads are valid for 24 hours; start a new export. ## Privacy and audit [#privacy-and-audit] * Every tool response passes through a sanitizer that strips credentials, tokens, and secrets before the agent sees it. * Every call is audit-logged with the tool name, sanitized input, latency, and outcome. Writes additionally record what changed, with before and after state. * Reading messages with `inbox_room_get` marks the room read on Upwork, and `inbox_room_send_message` posts real messages to real clients. The server flags these side effects to the agent so it confirms with you first. # Connect your client (/docs/mcp/connect) Every client connects the same way: one URL, no OAuth, no extra headers. Get the URL once, then follow the section for your client below. ## Get your connect URL [#get-your-connect-url] Open the Lancer web app and go to **MCP** in the sidebar. The **Authentication** card shows your personal connect URL; it is created automatically the first time you open the page: ```text https://api.lancer.app/mcp?token=lmc_XXXXXXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` Click **Reveal** to see it, then **Copy**. Clients that accept custom headers can also send the key as `Authorization: Bearer lmc_...` against the bare endpoint. The `lmc_...` key grants access to every Lancer organization you belong to. Treat the full URL like a password. If it leaks, click **Regenerate key** on the MCP page; the old key stops working immediately. ## Claude Code [#claude-code] Add the server from your terminal, replacing the URL with your own: ```bash claude mcp add --transport http lancer "https://api.lancer.app/mcp?token=lmc_..." ``` By default this registers the server for the current project. Add `--scope user` to make it available in every project: ```bash claude mcp add --transport http --scope user lancer "https://api.lancer.app/mcp?token=lmc_..." ``` Verify with `claude mcp list`, or run `/mcp` inside a session to see the connection status and browse tools. The [`set_up_lancer` prompt](/docs/mcp/prompts) appears as the slash command `/mcp__lancer__set_up_lancer`. ## Claude (claude.ai and Claude Desktop) [#claude-claudeai-and-claude-desktop] Custom connectors require a paid Claude plan. Go to **Settings**, then **Connectors** (on the web: `claude.ai/settings/connectors`; in Claude Desktop: Settings, then Connectors). Click **Add custom connector**, name it `Lancer`, and paste your connect URL as the remote MCP server URL. Leave the OAuth fields empty; the token in the URL is the authentication. Start a new chat, open the search-and-tools menu, and make sure the Lancer connector is enabled. Then ask something like "List my Lancer organizations" to confirm it works. On mobile, connectors added on the web are available too, so you can manage your pipeline from your phone. ## ChatGPT (chatgpt.com) [#chatgpt-chatgptcom] Custom MCP connectors in ChatGPT require developer mode. Enable developer mode: **Settings**, then **Apps and Connectors**, then **Advanced settings**, and turn on **Developer mode**. Back in **Apps and Connectors**, click **Create** (custom connector). Name it `Lancer`, paste your connect URL as the MCP server URL, and choose **No authentication**; the token travels in the URL. In a new chat, open the plus menu, choose **Developer mode**, and enable the Lancer connector. ChatGPT asks for confirmation before running write tools; read the previews before approving. ## Codex [#codex] The Codex CLI configures MCP servers in `~/.codex/config.toml`. Recent Codex versions support remote servers over Streamable HTTP directly: ```toml title="~/.codex/config.toml" [mcp_servers.lancer] url = "https://api.lancer.app/mcp?token=lmc_..." ``` If your Codex version only supports stdio servers (`command`-based entries), bridge with `mcp-remote`: ```toml title="~/.codex/config.toml" [mcp_servers.lancer] command = "npx" args = ["-y", "mcp-remote", "https://api.lancer.app/mcp?token=lmc_..."] ``` Restart Codex and check the server is listed with `codex mcp list`. ## Cursor [#cursor] Add this to `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` to make it global: ```json title=".cursor/mcp.json" { "mcpServers": { "lancer": { "url": "https://api.lancer.app/mcp?token=lmc_..." } } } ``` Then enable the server under **Cursor Settings**, then **MCP**. Tools are used by the agent automatically; writes ask for your approval. ## VS Code (GitHub Copilot) [#vs-code-github-copilot] Add this to `.vscode/mcp.json` in your workspace: ```json title=".vscode/mcp.json" { "servers": { "lancer": { "type": "http", "url": "https://api.lancer.app/mcp?token=lmc_..." } } } ``` Open Copilot Chat in agent mode and the Lancer tools appear in the tools picker. ## Any other client [#any-other-client] Two universal options, in order of preference: 1. **Remote HTTP support.** Give the client your connect URL as a Streamable HTTP MCP server. If the client configures auth separately from the URL, you can instead point it at `https://api.lancer.app/mcp` with header `Authorization: Bearer lmc_...`. 2. **stdio bridge.** For clients that only launch local stdio servers, use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as the command: `npx -y mcp-remote "https://api.lancer.app/mcp?token=lmc_..."`. ## Token semantics [#token-semantics] A few things worth knowing about the token behind the URL: * **Personal, not per-organization.** The token identifies you. If you belong to several organizations, the agent picks one per call; the server re-checks your live memberships on every tool call. * **One active token per user.** Regenerating revokes all your previous tokens immediately. * **Always available.** Your connect URL can be revealed and copied again from the MCP page at any time. * **POST only, stateless.** The endpoint speaks Streamable HTTP without server-push sessions, which is what all the clients above expect. No configuration needed. ## Verify the connection [#verify-the-connection] Ask your agent something simple: ```text List my Lancer organizations. ``` The agent should call the `organization_list` tool and answer with your organization name(s). If it fails, check [Access, limits, and errors](/docs/mcp/access-and-errors). # Overview (/docs/mcp) The Lancer MCP server lets an AI agent operate Lancer on your behalf through the [Model Context Protocol](https://modelcontextprotocol.io). Once connected, your agent can: * Configure your **knowledge base**, the profile the suitability AI reads when deciding which jobs fit you. * Create and tune **campaigns**: filters, search queries, bidding and boosting config, go-live. * Write and simulate **cover letter templates** with the same agent that writes your production proposals. * Manage your **inbox and pipeline**: triage rooms, update CRM fields, create tasks, send messages, schedule meetings. * Analyze **performance** and export leads or conversations for deeper analysis. * Manage **integrations**: webhooks, Public API keys, Slack and Discord notifications, external mode. The only thing that stays in the web app is connecting your Upwork account. Using the MCP is the preferred way to set up and run Lancer. ## Opinionated by design [#opinionated-by-design] A generic API integration leaves every judgment call to the agent. The Lancer MCP does not: alongside its 59 tools it ships Lancer's own operating knowledge, so any agent, on any model, behaves like an operator trained on how Lancer is meant to be run. * **17 policy resources.** Playbooks and rules the agent is required to read before acting: how to structure campaigns, how to write search queries, how each knowledge base field should be authored, what a good recommendation looks like. Browse them under [Resources](/docs/mcp/resources). * **Recommendation guardrails.** Dedicated validation tools check every proposed campaign or knowledge base change against evidence requirements (real lead samples, cited examples, sufficient data) before the agent may present it to you. Ungrounded advice gets revised or blocked. * **Server-side enforcement.** The highest-impact rules are not advisory. Writes that silently degrade lead quality, like removing budget floors or client-quality filters, are rejected by the server unless you explicitly opt in. * **Two guided prompts.** [Ready-made workflows](/docs/mcp/prompts) for first-time setup and suitability tuning, so the most important journeys are scripted rather than improvised. The practical effect: an agent cannot talk you into configurations that hurt your results, and the quality of its advice does not depend on it already knowing how Upwork works. ## How it works [#how-it-works] * **Transport**: Streamable HTTP, stateless. One endpoint, no session setup. * **Auth**: a personal token embedded in the connect URL. See [Connect your client](/docs/mcp/connect). * **Scope**: the token is tied to you, not to one organization. Tools accept an `organizationId` parameter, and your live organization memberships are checked on every call. ## Where to start [#where-to-start] Setup guides for Claude, Claude Code, ChatGPT, Codex, Cursor, VS Code, and any other MCP client. Ready-made prompts that put your agent into guided Lancer workflows, starting with first-time setup. All 59 tools, grouped by what they operate on. Who can connect, what plans are required, and the errors you can hit. ## First-time setup with an agent [#first-time-setup-with-an-agent] If your organization is new, ask your connected agent to set Lancer up for you. The server ships a [`set_up_lancer` prompt](/docs/mcp/prompts#set_up_lancer) that walks the agent through profile grounding, knowledge base, campaigns, testing the AI's decisions, cover letters, and go-live. # Prompts (/docs/mcp/prompts) Prompts are ready-made instructions shipped by the Lancer MCP server. Invoking one hands your agent a complete, tested playbook, so you do not have to explain Lancer to your agent yourself. There are three ways to run one: 1. **Native MCP prompts.** Clients with prompt support list them once the server is connected. In Claude Code they appear as slash commands, in Claude Desktop under the prompts picker in the chat input. 2. **Copy from this page.** Each prompt below comes with a paste-ready version; use the copy button on the block. 3. **Copy from the web app.** The **Prompts** card on the MCP page in the Lancer web app has the full text of every prompt with a click-to-copy. All routes lead to the same behavior, so use whichever is more convenient. ## Available prompts [#available-prompts] ### `set_up_lancer` [#set_up_lancer] Guided first-time setup: knowledge base, campaigns, cover letter, and go-live, grounded in your Upwork profile. In Claude Code, type: ```text /mcp__lancer__set_up_lancer ``` (The `lancer` part is whatever name you gave the server when [connecting](/docs/mcp/connect); the same pattern works for any prompt.) In any other client, copy the full prompt and paste it into a chat with Lancer connected: Use it when your organization is new, or when you want to redo your setup properly. The agent walks you through six phases and confirms each step with you before writing anything: **Profile grounding.** The agent reads your connected Upwork account with `bidder_account_get`. If you have not connected one yet, it scrapes your public Upwork profile by URL instead, and only asks you to describe your positioning manually as a last resort. **Knowledge base.** It drafts a knowledge base from your profile with `knowledge_base_draft`, then reviews it with you field by field: core services, tools and technologies, projects you avoid, languages, case studies. **Campaigns.** It proposes one or more campaigns with filters and search queries that match your positioning, checked against Lancer's campaign policy before anything is saved. **Test the AI's decisions.** It runs `knowledge_base_simulate` on real jobs so you can see which jobs Lancer would accept or reject for you, and tunes the knowledge base until the decisions look right. **Cover letter.** It creates a template and offers `cover_letter_simulate`, which renders what the production proposal agent would actually write for sampled jobs. **Go-live.** Final checks (connected bidder account, startability requirements), then the campaign is activated with your confirmation. Expect the full flow to take 15 to 30 minutes of conversation. You can stop at any phase and resume later; every phase leaves Lancer in a consistent state. ### `tune_suitability` [#tune_suitability] Benchmark the suitability AI on a few hundred real jobs, grade the contested decisions together, and iterate the knowledge base until it matches your judgment. In Claude Code, type: ```text /mcp__lancer__tune_suitability ``` In any other client, copy the full prompt and paste it into a chat with Lancer connected: Use it once your knowledge base exists and you want to trust the AI's accept/reject decisions before (or after) going live. It is the deep, standalone version of the setup flow's testing phase: **Full-context grounding.** The agent loads everything the decision should be judged against: your Upwork title, description, skills, past jobs, portfolios, and the complete knowledge base including case studies. **Baseline sweep.** It runs `knowledge_base_simulate` over roughly 200 recent real jobs (you confirm the scale first; each job is one AI call), going deeper when the agent judges more signal is needed, and records every accept/reject decision. **Triage.** The agent grades every decision itself against your profile and knowledge base, and sorts them into agree, disagree, and unsure. **You grade the contested ones.** Only the disagreements and borderline cases come to you, each with the real job post quoted, in digestible batches. Your verdicts are ground truth. **Knowledge base iteration.** Confirmed mistakes become targeted knowledge base patches, following the same authoring rules and guardrails as any other edit, verified by re-simulation before anything is saved. The loop targets roughly 95% agreement and is capped at 3 iterations; it will tell you which residual disagreements are genuine judgment calls rather than fixable mistakes. Expect it to take a while: the baseline sweep alone is several minutes of simulation. ## More prompts [#more-prompts] This catalog will grow. Prompts for campaign tuning, weekly performance reviews, and pipeline cleanup are planned. If there is a workflow you keep explaining to your agent by hand, that is a good candidate; tell us through the app. # Resources (/docs/mcp/resources) The server exposes 17 markdown resources under the `lancer://` scheme. They encode the policies, defaults, and playbooks the agent must read before making recommendations or writes. The server instructions require the agent to read the relevant resources first; several rules in them are also enforced server-side. If your client supports MCP resources natively, the agent fetches them directly. If not, the `resource_read` tool returns the same content by URI, so every client has access. You normally never touch these yourself. They are listed here so you know what grounds your agent's behavior, and so you can ask the agent to show you any of them ("read lancer://campaigns/recommendation-policy and summarize it"). | URI | What it covers | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `lancer://product/overview` | What Lancer is and how the pieces fit together. | | `lancer://glossary/data-dictionary` | Definitions of the fields and metrics used across tools. | | `lancer://upwork/bidder-account-model` | How Upwork accounts, agencies, and contractors map into Lancer. | | `lancer://campaigns/playbook` | How to structure and run campaigns. | | `lancer://campaigns/recommendation-policy` | What campaign recommendations are allowed, and the evidence they require. | | `lancer://campaigns/filter-recommendations` | Rules for proposing filter changes. | | `lancer://campaigns/search-query-authoring` | How to write search queries, including deprecated operators. | | `lancer://campaigns/integrations` | Webhooks, API keys, notification channels, and external mode. | | `lancer://campaigns/diagnostics` | How to diagnose an underperforming campaign. | | `lancer://examples/campaign-recommendations` | Worked examples of good and bad recommendations. | | `lancer://proposals/construction` | How the production proposal agent assembles a proposal, required reading before touching templates. | | `lancer://knowledge-base/overview` | What the knowledge base is and how the suitability AI uses it. | | `lancer://knowledge-base/structure-guide` | How each knowledge base field should be written. | | `lancer://knowledge-base/iteration-playbook` | How to improve a knowledge base based on results. | | `lancer://knowledge-base/suitability-tuning-playbook` | The large-scale benchmark-and-tune loop, also delivered by the [`tune_suitability` prompt](/docs/mcp/prompts#tune_suitability). | | `lancer://onboarding/setup-playbook` | The end-to-end first-time setup flow, also delivered by the [`set_up_lancer` prompt](/docs/mcp/prompts#set_up_lancer). | | `lancer://inbox/overview` | How inbox and pipeline tools fit together, and the rules for inbox writes. | # Tool reference (/docs/mcp/tools) Every tool below takes an `organizationId` parameter unless marked otherwise. If you belong to exactly one organization the agent may omit it; with more than one, the server asks the agent to call `organization_list` first. Tools that change something always report back what changed, and the server requires the agent to confirm writes with you before executing them. High-impact rules (budget floors, filter policies, evidence requirements) are enforced server-side, not just suggested. ## Discovery [#discovery] | Tool | What it does | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `organization_list` | Lists the organizations you can access, with subscription state. The agent's step zero. Not organization-scoped. | | `resource_read` | Returns the full text of any `lancer://` policy resource, for clients without native MCP resource support. Not organization-scoped. | ## Dashboard and analytics [#dashboard-and-analytics] | Tool | What it does | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `organization_performance_overview` | The web dashboard in one call: KPIs, day-by-day series, and breakdowns by campaign, cover letter, and bidder account. | | `job_invitation_list` | Upwork job invitations received by your bidder accounts, with applied flags and the matched inbox room. | ## Upwork profile grounding [#upwork-profile-grounding] | Tool | What it does | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bidder_account_get` | Your actual Upwork positioning: accounts, agencies, contractors, portfolios, highlights, and campaign mappings. The agent grounds knowledge base and campaign advice in this. | | `upwork_profile_scrape` | Live scrape of a public Upwork profile by URL (30 to 90 seconds), used during onboarding before an account is connected. | ## Campaign analysis [#campaign-analysis] | Tool | What it does | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | | `campaign_performance_overview` | Per-campaign performance: contacted, opened, replied, rejected, spend, cost per reply, findings, forecast. | | `campaign_filter_audit` | What each campaign targets: filters, keyword signals, region compatibility, overlap between campaigns, forecast. | | `campaign_filter_simulate` | Forecasts a proposed filter change in memory before anything is saved. | | `campaign_keyword_frequency` | Top job-title keywords over the last 30 days for a given search query. Works on draft queries too. | | `campaign_recommendation_guardrails` | Validates proposed campaign recommendations against Lancer policy: allowed, revise, or blocked. Not organization-scoped. | | `suitability_decision_explain` | Explains why the suitability AI passed or rejected specific leads on one campaign, with verbatim reasons. | ## Campaign writes [#campaign-writes] | Tool | What it does | | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `campaign_create` | Creates a campaign. Activating it runs the same startability checks as the web app. | | `campaign_set_status` | Pause, resume, go live, or return to draft. | | `campaign_update` | Updates filters, bidding and boost config, limits, attached bidder account, template, or knowledge base profile. | | `campaign_archive` | Archives a campaign and resequences priorities. | | `campaign_attachment_create` | Downloads a public file and attaches it to proposals (max 5, 25 MB each) or to a Smart Attachments case. | | `campaign_attachment_delete` | Permanently removes a proposal or Smart Attachments file. | ## Notifications (Slack and Discord) [#notifications-slack-and-discord] | Tool | What it does | | -------------------------------- | ----------------------------------------------------------------------------------------- | | `campaign_notifications_get` | Current channel bindings, per-event toggles, and installed workspaces. | | `campaign_notifications_connect` | Returns a Slack or Discord install URL for you to click. No side effects. | | `campaign_notifications_update` | Binds a workspace and channel, sets per-event toggles, or disconnects the campaign. | | `campaign_notifications_test` | Posts a sample notification into the configured channel. Everyone in the channel sees it. | ## Integrations, webhooks, and API keys [#integrations-webhooks-and-api-keys] | Tool | What it does | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `campaign_integrations_get` | Bidding mode, external-mode readiness with blockers, webhooks with recent deliveries, API key prefixes, recent activity. | | `campaign_webhook_upsert` | Creates or updates a campaign webhook, the `job.new` delivery channel. Creation requires a paid plan. | | `campaign_webhook_delete` | Deletes a webhook. May make an external campaign not startable. | | `campaign_webhook_test` | Sends a real delivery with a real lead to your endpoint. | | `api_key_create` | Creates a Public API key. The full key appears once, in that response only. Paid plan required. | | `api_key_delete` | Revokes a Public API key immediately. | ## Cover letter templates [#cover-letter-templates] | Tool | What it does | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cover_letter_templates_list` | Your templates and which campaigns use each one. | | `cover_letter_template_create` | Creates a template (name, body, instructions). | | `cover_letter_template_update` | Updates name, body, instructions, or status. Archiving is blocked while a live campaign uses the template. | | `cover_letter_simulate` | Renders what the production proposal agent would actually write for sampled jobs, for the saved template or a proposed change. Each sampled job is a full AI run, so the agent offers this rather than running it automatically. | ## Organization AI configuration [#organization-ai-configuration] | Tool | What it does | | ------------------------------- | -------------------------------------------------------------------------------------------- | | `organization_ai_config_get` | Your per-organization AI settings: question rules, cover letter handling, question handling. | | `organization_ai_config_update` | Updates those settings. Question rules are replaced as a whole array. | ## Knowledge base [#knowledge-base] | Tool | What it does | | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `knowledge_base_get` | The profile the suitability AI reads, optionally scoped to specific campaigns. | | `knowledge_base_draft` | Generates a first-draft knowledge base from your Upwork profile using the production draft agent. | | `knowledge_base_create` | Creates a knowledge base profile. Subject to your plan's profile limit. | | `knowledge_base_update` | Updates fields such as core services, tools and technologies, projects to avoid, languages, and case studies. | | `knowledge_base_delete` | Deletes a profile. Blocked while a live campaign uses it or if it is your last profile. | | `knowledge_base_simulate` | Runs the production suitability agent on sampled jobs against the saved or proposed profile and returns per-job decisions. | | `knowledge_base_recommendation_guardrails` | Validates knowledge base changes; requires profile evidence, and lead-cohort claims need an export run. Not organization-scoped. | ## Lead exports [#lead-exports] | Tool | What it does | | -------------------- | ------------------------------------------------------------------------------------------------------- | | `lead_export_start` | Creates a CSV or JSONL export of leads for cohort and text analysis. Synchronous; returns an export id. | | `lead_export_result` | Status, row count, schema, and an expiring signed download URL. Links expire after 24 hours. | ## Inbox and pipeline [#inbox-and-pipeline] | Tool | What it does | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `inbox_overview` | Snapshot of your pipeline: column layout, counts by status, deal value totals, unread, tasks, connected accounts. | | `inbox_rooms_list` | Paginated triage view of message rooms across accounts, with search. | | `inbox_room_get` | Full context for one room: counterparty, client history, CRM fields, notes, tasks, optionally messages. Reading messages marks the room as read on Upwork. | | `inbox_room_attachment_get` | Fetches the content of a chat or job post attachment. | | `inbox_room_update` | Updates CRM fields: pipeline status, deal value, note, archive reason. | | `inbox_room_task_upsert` | Creates or updates one task on a room. | | `inbox_room_task_delete` | Deletes one room task. | | `inbox_rooms_refresh` | Pulls fresh rooms and messages from Upwork for one bidder account. | | `inbox_tasks_list` | Organization-wide task list, overdue first. | | `inbox_pipeline_columns_update` | Replaces your pipeline columns (3 to 7). Removing a column that still has rooms requires an explicit acknowledgment. | | `inbox_room_send_message` | Sends a message (optionally with an attachment) in an Upwork chat room. Cannot be unsent, so the agent always shows you the exact text first. | | `inbox_room_meeting_create` | Creates a meeting and posts the card into the chat: instant Zoom, scheduled, or book-a-slot. | | `inbox_room_meeting_free_slots` | Free slots offered by the other side for a meeting of a given duration. | | `inbox_room_meeting_add_to_calendar` | Syncs an existing meeting to the bidder account's connected calendar. | | `inbox_export_start` | JSONL export of rooms with nested messages for analysis. Defaults to 100 rooms and 50 messages each. | | `inbox_export_result` | Signed download URL and schema for a completed inbox export. Links expire after 24 hours. | ## Not available over MCP [#not-available-over-mcp] A few operations stay in the web app by design: * Connecting or reconnecting Upwork bidder accounts (credentials never pass through an agent). * Bulk operations on rooms or leads. * Joining Zoom meetings and managing tags. # Authentication (/docs/external-mode/api/authentication) ## Create an API key [#create-an-api-key] In the Lancer web app, go to **Settings**, then **Integrations**, and open the **API keys** tab. Click **Create key** and give it a name. The full key is shown **once**, at creation. Copy it immediately; afterwards the list only shows the first 11 characters. If you lose a key, create a new one and delete the old one. You can also manage keys through the MCP server with the `api_key_create` and `api_key_delete` tools. ## Key format and scope [#key-format-and-scope] ```text lk_9f2c4e6a8b1d3f5079e2c4a6b8d0f1a3c5e7092b4d6f8a0c2e4b6d8f0a1c3e5f ``` * Keys start with `lk_` followed by 64 hex characters. * Keys are **organization-scoped**. One key works for every campaign in the organization. * You can create multiple keys (for example one per environment) and revoke them independently. * Lancer stores only a hash of the key. It can never be displayed again after creation. Creating keys requires an active paid subscription. ## Authenticate requests [#authenticate-requests] Send the key as a bearer token on every request: ```bash curl https://api.lancer.app/v1/campaigns/CAMPAIGN_ID/leads \ -H "Authorization: Bearer lk_..." ``` There is no query parameter or custom header alternative. Missing or malformed tokens get: ```json { "statusCode": 401, "message": "Missing bearer token" } ``` Revoked or unknown keys get `401 "Invalid API key"`. If the organization's subscription lapses or billing collection is paused, valid keys also get `401` with a message explaining the billing state. ## Key hygiene [#key-hygiene] * Store keys in your secret manager, never in client-side code or repositories. * Rotate by creating a new key, switching your systems over, then deleting the old key. Deletion takes effect immediately. * Each key's last-used time is shown in the web app, which makes it easy to spot dead keys before deleting them. # Errors (/docs/external-mode/api/errors) ## Response shapes [#response-shapes] Errors come in two shapes. **Simple errors** use the standard shape: ```json { "statusCode": 404, "message": "Lead not found", "error": "Not Found" } ``` **Domain validation errors** return `400` with a structured `errors` array. Each entry has a `path` into your request body, a machine-readable `code`, a human-readable `message`, and sometimes an `options` array with the accepted values: ```json { "message": "Validation failed", "errors": [ { "path": ["questionAnswerPairs", "0", "question"], "code": "question_mismatch", "message": "Question does not match any question on the lead.", "options": ["Describe your recent experience with similar projects"] } ] } ``` **Quota and billing errors** return a `code` at the top level: ```json { "message": "Proposal limit reached. Usage resets on Aug 1, 2026.", "code": "proposal_limit_reached", "currentUsage": 500, "limit": 500, "currentPeriodEnd": 1754006400 } ``` ## Status codes [#status-codes] | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------------- | | `400` | Invalid request: schema violation, question mismatch, or inconsistent bidding config. | | `401` | Missing or invalid API key, or the organization's subscription is inactive or billing collection is paused. | | `402` | A plan limit was hit (proposal quota) or the operation requires a paid plan. | | `404` | Campaign, lead, or webhook not found. | | `409` | Conflict: the lead was already bid on. | | `500` | Unexpected server error. Retry with backoff; if it persists, contact support. | ## Error codes [#error-codes] | Code | Status | Meaning | | -------------------------- | ------ | --------------------------------------------------------------------------------------------------------- | | `question_mismatch` | 400 | A screening question does not verbatim-match the lead's questions. Use the `options` array to correct it. | | `rate_percentage_required` | 400 | The selected rate strategy needs `biddingHourlyRatePercentage`. | | `fixed_rate_required` | 400 | The `fixed_rate` strategy needs `biddingFixedHourlyRate`. | | `proposal_limit_reached` | 402 | The plan's proposal quota is used up for this billing period. | | `subscription_paused` | 402 | Billing collection is paused; resume it in Settings, then Billing. | | `subscription_inactive` | 402 | No active subscription. | On the Public API itself, subscription problems surface as `401` (the key stops authenticating). The `402` subscription codes appear on management operations such as creating keys or webhooks. # API overview (/docs/external-mode/api) The Lancer Public API covers the bidding loop for a campaign. It is intentionally small: four endpoints, all scoped to leads within a campaign. ```text Base URL: https://api.lancer.app/v1 Auth: Authorization: Bearer lk_... Format: JSON (multipart for file uploads) ``` | Endpoint | Purpose | | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | [`GET /campaigns/:campaignId/leads`](/docs/external-mode/api/list-leads) | List jobs matching the campaign's filters, as stable leads with action URLs. | | [`POST /campaigns/:campaignId/leads/:leadId/bid`](/docs/external-mode/api/submit-bid) | Submit a proposal for a lead. Supports dry runs and per-bid config overrides. | | [`POST /campaigns/:campaignId/leads/:leadId/reject`](/docs/external-mode/api/reject-lead) | Mark a lead as rejected so Lancer never bids on it. | | [`POST /campaigns/:campaignId/leads/:leadId/attachments`](/docs/external-mode/api/upload-attachments) | Upload files that will be attached to this lead's proposal. | The API is designed around [external mode](/docs/external-mode): Lancer pushes matching jobs to your webhook, your system decides, and calls back through these endpoints. But every endpoint also works on standard (automatic) campaigns, for example to bid on a specific lead from a script. ## Where things live in the web app [#where-things-live-in-the-web-app] * **API keys**: Settings, then Integrations, **API keys** tab. * **Webhooks**: Settings, then Integrations, **Webhooks** tab (organization scope) or the campaign's **Integrations** tab (campaign scope). * **Activity**: every API bid, rejection, and webhook delivery is recorded in the **Activity** tab, so you can debug integrations without extra logging on your side. ## Requirements [#requirements] * An active **paid** subscription. Requests from organizations without one get `401`. * A campaign. Its saved filters define which jobs `GET /leads` returns and which jobs fire `job.new` webhooks. * For actual submissions, a connected Upwork bidder account on the campaign. ## No published rate limit [#no-published-rate-limit] There is currently no request rate limit on the Public API. Practical ceilings: list pages are capped at 100 items, JSON bodies at 3 MB, and uploads at 5 files of 25 MB per lead. Be reasonable; sustained abusive traffic may get an explicit limit in the future. # List leads (/docs/external-mode/api/list-leads) ```text GET /v1/campaigns/:campaignId/leads ``` Returns jobs from the Upwork job pool that match the campaign's saved filters, newest first. Each returned job is materialized into a persisted **lead** with a stable `id`, so you can bid on or reject any item immediately. This works on brand-new campaigns too; you do not need to wait for webhooks to start integrating. Beyond driving bids, this endpoint is the easiest way to **test your decision logic and prompts against a real batch of jobs**: pull a page, run your suitability scoring or proposal generation over it, and iterate without waiting for new webhooks to fire. ## Query parameters [#query-parameters] | Parameter | Type | Default | Notes | | --------- | ------- | ------- | ------------------------ | | `offset` | integer | `0` | Number of items to skip. | | `limit` | integer | `100` | Page size, 1 to 100. | ## Example [#example] ```bash curl "https://api.lancer.app/v1/campaigns/CAMPAIGN_ID/leads?limit=10" \ -H "Authorization: Bearer lk_..." ``` ## Response [#response] ```jsonc { // The page of leads, newest first (ordered by createdAt desc). Each item // is exactly the "lead" object you would receive on a job.new webhook, // plus bidUrl / rejectUrl for convenience. "data": [ { "id": "lead_abc123", "title": "Next.js developer for dashboard rebuild", "description": "We are looking for...", "jobUrl": "https://www.upwork.com/jobs/~021234567890", "createdAt": 1754450000000, "datetime": 1754449000000, "category": "Web Development", "skills": [{ "name": "Next.js" }, { "name": "TypeScript" }], "questions": [ "Describe your recent experience with similar projects" ], "metadata": { "experienceLevel": "Expert", "paymentType": "Hourly", "hourlyRate": { "min": 30, "max": 60 }, "fixedPrice": null, "duration": "3 to 6 months", "hours": "More than 30 hrs/week" }, "clientInfo": { "isPaymentVerified": true, "rating": 4.9, "numberOfReviews": 41, "country": "United States", "totalSpent": 120000, "numberOfHires": 38, "hireRate": 71, "memberSince": "2019-03-12" }, "campaignId": "cmp_123", "campaignName": "US SaaS clients", "organizationId": "org_123", "bidUrl": "https://api.lancer.app/v1/campaigns/cmp_123/leads/lead_abc123/bid", "rejectUrl": "https://api.lancer.app/v1/campaigns/cmp_123/leads/lead_abc123/reject" } ], // Total number of matching leads in this campaign across all pages. "total": 342, // Echo of the offset/limit you sent (or the defaults, if you did not). "offset": 0, "limit": 10, // true if offset + data.length < total. Stop paginating when this is false. "hasMore": true } ``` The lead object is the same shape delivered in [webhook payloads](/docs/external-mode/webhooks/events#the-lead-object), plus ready-to-call `bidUrl` and `rejectUrl`. The full field list is documented there. All timestamps inside lead objects are UNIX milliseconds. ## Errors [#errors] | Status | When | | ------ | --------------------------------------------------------------------------------- | | `401` | Missing or invalid key, or the subscription is inactive. | | `404` | `Campaign not found` (wrong id, or the campaign belongs to another organization). | # Reject a lead (/docs/external-mode/api/reject-lead) ```text POST /v1/campaigns/:campaignId/leads/:leadId/reject ``` Marks the lead as rejected. Lancer will not bid on it, and in external mode this closes the loop on a `job.new` delivery you decided to pass on. Rejections show up in the campaign's Feed and Activity tabs, with your reason if you provide one. ## Request body [#request-body] The body is optional. | Field | Type | Required | Notes | | -------- | ------ | -------- | ----------------------------------------------------------- | | `reason` | string | no | Why you passed, up to 500 characters. Shown in the web app. | ## Example [#example] ```bash curl -X POST \ "https://api.lancer.app/v1/campaigns/CAMPAIGN_ID/leads/LEAD_ID/reject" \ -H "Authorization: Bearer lk_..." \ -H "Content-Type: application/json" \ -d '{ "reason": "Budget below our minimum" }' ``` ## Response [#response] ```json { "success": true, "leadId": "lead_abc123", "campaignId": "cmp_123", "organizationId": "org_123" } ``` ## Errors [#errors] | Status | When | | ------ | -------------------------------------------------------- | | `401` | Missing or invalid key, or the subscription is inactive. | | `404` | `Lead not found`. | # Submit a bid (/docs/external-mode/api/submit-bid) ```text POST /v1/campaigns/:campaignId/leads/:leadId/bid ``` Schedules a proposal submission on Upwork for the given lead, using your cover letter and answers, plus the campaign's bidding and boosting configuration (or your per-bid overrides). A `200` response means the bid was validated and queued. The actual Upwork submission happens shortly after, performed by the campaign's connected bidder account. Subscribe to the [`bid_complete` webhook](/docs/external-mode/webhooks/events#bid_complete) to know when the proposal is really on Upwork. ## Minimal request [#minimal-request] The only required fields are `coverLetter` and `questionAnswerPairs`. Everything else falls back to your campaign configuration unless you explicitly override it. ```bash curl -X POST "https://api.lancer.app/v1/campaigns/CAMPAIGN_ID/leads/LEAD_ID/bid" \ -H "Authorization: Bearer lk_..." \ -H "Content-Type: application/json" \ -d '{ "coverLetter": "Hi, I noticed you are looking for...", "questionAnswerPairs": [ { "question": "What is your rate?", "answer": "$75/hr" } ] }' ``` ## Full request body [#full-request-body] Every field, annotated: ```jsonc { // Required. The proposal body sent to the client. "coverLetter": "Hi, I noticed you are looking for...", // Required. Pairs of { question, answer } for any screening questions. // Empty array is fine if the job has none. Each "question" MUST be a // verbatim copy of one of the strings in lead.questions from the job.new // payload; whitespace, punctuation, and casing all count. "questionAnswerPairs": [ { "question": "What is your rate?", "answer": "$75/hr" } ], // Optional. If true, validate the request and fire a simulated // bid_complete webhook. No connects spent, no Upwork call, no state change. "dryRun": false, // Optional. Override per-bid config. Anything you omit falls back to the // campaign defaults. "biddingConfig": { // "match_job_budget" | "match_profile_rate" | "fixed_rate" | "smart_bidding" "biddingHourlyRateStrategy": "fixed_rate", // 0-100. Used by match_profile_rate and match_job_budget. "biddingHourlyRatePercentage": null, // USD per hour. Used by fixed_rate. "biddingFixedHourlyRate": 85, "boostingEnabled": true, // Max connects (whole number) to spend boosting this bid. Same // unit as the "Max Connects" field in the campaign configuration UI. "maximumBoost": 60, // Minimum boost, in connects. Same unit as the "Min Connects" // field in the campaign configuration UI. "minBoost": 40, // 1 to 4. How far down the proposal ranking to boost toward. "boostDownToNthPlace": 2, // Extra connects to bid above the previous bidder. "connectsAbovePrevious": 3, // What to do when there are not enough connects to boost. // "skip" | "bid_without_boost" "insufficientBoostConnectsAction": "bid_without_boost", // What to do if this client has already hired someone on the job. // "skip" | "bid" "alreadyHiredAction": "skip", // What to do if Upwork returns a warning before submit. // "bid" | "skip" "bidWithWarning": "bid" } } ``` | Field | Type | Required | Notes | | --------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------ | | `coverLetter` | string | yes | The proposal text, submitted as-is. | | `questionAnswerPairs` | array | yes | One entry per screening question. Send `[]` if the job has no questions. | | `dryRun` | boolean | no | Validate everything and simulate a `bid_complete` webhook without touching Upwork. See below. | | `biddingConfig` | object | no | Per-bid overrides. Omitted fields fall back to the lead's and then the campaign's configuration. | ### Screening questions must match verbatim [#screening-questions-must-match-verbatim] Each `questionAnswerPairs[].question` must be byte-identical to one of the strings in the lead's `questions` array. This is the most common integration failure, especially when an LLM paraphrases the question. If you generate answers with an LLM, tell it in the system prompt to return every question unchanged, character for character. On mismatch you get a `400` whose `errors[].options` array contains the exact accepted strings, so your integration can self-correct: ```json { "message": "Validation failed", "errors": [ { "path": ["questionAnswerPairs", "0", "question"], "code": "question_mismatch", "message": "Question does not match any question on the lead.", "options": ["Describe your recent experience with similar projects"] } ] } ``` ### `biddingConfig` fields [#biddingconfig-fields] All fields are optional. Anything you omit falls back to the campaign's settings. | Field | Type | Notes | | --------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `bidWithWarning` | `"bid"` or `"skip"` | What to do when Upwork shows a warning on the job. | | `biddingHourlyRateStrategy` | `"match_job_budget"`, `"match_profile_rate"`, `"fixed_rate"`, `"smart_bidding"` | How the hourly rate is chosen. | | `biddingHourlyRatePercentage` | number or null | Percent (0 to 100). Required with `match_job_budget` and `match_profile_rate`. | | `biddingFixedHourlyRate` | number or null | USD per hour. Required with `fixed_rate`. | | `biddingMinHourlyRate` | number or null | USD per hour. Optional floor for `smart_bidding`: the computed rate never goes below this, even when the job's range tops out lower. | | `boostingEnabled` | boolean or null | Whether to boost this proposal. | | `boostDownToNthPlace` | number or null | Target boosted position, 1 to 4. | | `connectsAbovePrevious` | number or null | Outbid the previous top bid by this many connects. | | `minBoost` | integer or null | Minimum boost, **in connects**. | | `maximumBoost` | integer or null | Maximum boost, **in connects**. | | `insufficientBoostConnectsAction` | `"skip"` or `"bid_without_boost"` | What to do when the account lacks connects to boost. | | `alreadyHiredAction` | `"skip"` or `"bid"` | What to do when the client has already hired for the job. | `minBoost` and `maximumBoost` are denominated in Upwork connects, the same unit as the campaign UI. One connect costs $0.15. ## Dry runs [#dry-runs] Send `"dryRun": true` to validate the whole request, record the attempt in your Activity feed, and fire a **simulated** `bid_complete` webhook, without spending connects, contacting Upwork, or changing the lead's state. The response includes `"dryRun": true`. Use this to test your integration end to end before going live. ## Response [#response] ```json { "success": true, "leadId": "lead_abc123", "campaignId": "cmp_123", "organizationId": "org_123" } ``` ## Errors [#errors] | Status | Code | When | | ------ | -------------------------- | --------------------------------------------------------------------------------------------------------- | | `400` | `question_mismatch` | A question does not verbatim-match the lead's questions. `options` lists the accepted strings. | | `400` | `rate_percentage_required` | Strategy is `match_job_budget` or `match_profile_rate` but no percentage is set anywhere. | | `400` | `fixed_rate_required` | Strategy is `fixed_rate` but no fixed rate is set anywhere. | | `402` | `proposal_limit_reached` | Your plan's proposal quota is used up. Response includes `currentUsage`, `limit`, and `currentPeriodEnd`. | | `404` | | `Lead not found`. | | `409` | | The lead was already bid on. The message includes its current status. | Every attempt, successful or not, is recorded in the campaign's **Activity** tab in the web app. # Upload attachments (/docs/external-mode/api/upload-attachments) ```text POST /v1/campaigns/:campaignId/leads/:leadId/attachments Content-Type: multipart/form-data ``` Uploads files that will be attached to this lead's proposal. Files uploaded here **override** the campaign's configured attachments (fixed or Smart Attachments) for this one bid, so upload before you call [`/bid`](/docs/external-mode/api/submit-bid). ## Request [#request] Send `multipart/form-data` with one or more parts named `files`: ```bash curl -X POST \ "https://api.lancer.app/v1/campaigns/CAMPAIGN_ID/leads/LEAD_ID/attachments" \ -H "Authorization: Bearer lk_..." \ -F "files=@portfolio.pdf" \ -F "files=@case-study.pdf" ``` ## Limits and restrictions [#limits-and-restrictions] * Up to **5 files per lead**, counted cumulatively across calls. * Up to **25 MB per file**. * Duplicate file names on the same lead are rejected. * Executable and script file types are rejected (`.exe`, `.bat`, `.cmd`, `.js`-adjacent script formats, `.jar`, `.vbs`, and similar). Stick to documents, images, and archives. * If any file in a batch fails validation, the whole batch is rolled back. ## Response [#response] The lead's full current attachment list: ```json [ { "id": "att_123", "originalName": "portfolio.pdf", "safeName": "portfolio.pdf", "contentType": "application/pdf", "sizeBytes": 1048576, "storagePath": "organizations/org_123/leads/lead_abc123/att_123", "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "createdAt": 1754450000000 } ] ``` ## Errors [#errors] | Status | When | | ------ | -------------------------------------------------------------------- | | `400` | `At least one file is required.` | | `400` | `Upload up to 5 files.` (too many in one request) | | `400` | `A lead can have up to 5 attachments.` (cumulative limit) | | `400` | Per-file type or size violations, with the file name in the message. | | `404` | `Lead not found`. | # Delivery, security, and testing (/docs/external-mode/webhooks/delivery) ## Request headers [#request-headers] Every delivery is an HTTPS `POST` with: | Header | Value | | ---------------------- | --------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `X-Lancer-Event` | The event name, for example `job.new`. | | `X-Lancer-Delivery-Id` | Unique delivery id, equal to the payload's `id`. Deduplicate on this. | ## Security model: the URL is the secret [#security-model-the-url-is-the-secret] Deliveries are **not signed**. There is no HMAC header and no shared signing secret. The webhook URL itself is the credential: anyone who knows it can send you requests that look like Lancer's. Practical guidance: * Put an unguessable path segment in your URL, for example `https://api.example.com/hooks/lancer/f3a9c1d7e5b2`. * Treat the URL like a password: keep it out of client-side code, logs, and repositories. * If the URL leaks, update the webhook to a new URL in the web app. * Optionally verify that referenced ids (campaign, lead) are real by calling the Public API before acting on a payload. A forged payload cannot make `GET /leads` return a lead that does not exist. ## Delivery semantics: at-most-once, no retries [#delivery-semantics-at-most-once-no-retries] * **One attempt per delivery.** There is no automatic retry. * **Timeout is 10 seconds.** Success is any `2xx` response. * **Failures never disable the webhook.** A non-2xx, timeout, or connection error marks that delivery `failed` in the log, and the next event is delivered normally. Fix your endpoint and the stream continues on its own; there is nothing to resume. * **No ordering guarantee.** Deliveries are dispatched independently; do not assume event order. * **Fan-out.** Every enabled webhook subscribed to an event gets its own delivery; one endpoint failing does not affect others. Design your receiver accordingly: ```text 1. Validate the JSON parses and X-Lancer-Event is one you expect. 2. Store the payload keyed by X-Lancer-Delivery-Id (skip if already seen). 3. Respond 200 immediately. 4. Process asynchronously. ``` Missing a delivery is recoverable for the control flow: in external mode you can re-trigger any lead's `job.new` from the campaign Feed, and `GET /leads` always shows current matching jobs. ## Delivery log [#delivery-log] Every attempt is recorded and visible in the web app: * **Organization webhooks**: Settings, then Integrations, **Activity** tab. * **Campaign webhooks**: the campaign's **Integrations** tab. Each record shows the event, the target URL, the HTTP status your endpoint returned, and the first part of the response body, which makes endpoint debugging straightforward. ## Testing [#testing] Three ways to test, from safest to most end-to-end: 1. **Test button**: next to any webhook in the web app. Sends a genuine payload built from a recent real lead (for `lead.new`, a synthetic room). If the organization has no leads yet, the test asks you to run a campaign once first. 2. **Per-lead trigger** (external mode): every lead card on the campaign Feed has a **Trigger** button that re-dispatches `job.new` for that lead. Useful while developing your receiver. 3. **Dry-run bid**: call [`/bid` with `dryRun: true`](/docs/external-mode/api/submit-bid#dry-runs) to exercise your full loop, including a simulated `bid_complete`, without touching Upwork. For local development, expose your receiver with a tunnel such as `ngrok` or `cloudflared` and use the tunnel URL as the webhook URL. # Events and payloads (/docs/external-mode/webhooks/events) ## The envelope [#the-envelope] Every delivery wraps its event data in the same envelope: ```json { "id": "del_9f2c4e6a", "event": "job.new", "version": "2026-04-20", "createdAt": 1754450123, "data": { } } ``` | Field | Notes | | ----------- | ---------------------------------------------------------------------------------------- | | `id` | The delivery id. Also sent as the `X-Lancer-Delivery-Id` header. Use it for idempotency. | | `event` | One of the event names below. Also sent as the `X-Lancer-Event` header. | | `version` | The payload schema version, currently `2026-04-20`. | | `createdAt` | UNIX **seconds**. | | `data` | Event-specific payload, described per event below. | The envelope's `createdAt` is UNIX seconds. Every timestamp inside `data.lead` (such as `lead.createdAt` and `lead.datetime`) is UNIX **milliseconds**. ## Events [#events] ### `job.new` (campaign webhooks) [#jobnew-campaign-webhooks] A suitable job was found for this campaign; your system decides what to do next. Fires only for campaigns in [external mode](/docs/external-mode), after the job passes the campaign's saved filters. Lancer's own suitability AI is skipped; deciding is your job. A complete, realistic payload with every section annotated: ```jsonc { "id": "YDDYfNaFpafJMIQCdqC8", "event": "job.new", "version": "2026-04-20", "createdAt": 1776861204, // UNIX seconds "data": { "organizationId": "4IumEJtmvJEE2nShMWwM", "campaignId": "ICdP5Knfizog5IkDRyTV", "leadId": "ma6H1U9jYesVEzLjzhAc", // Ready-made action URLs. Call one of them to answer. "bidUrl": "https://api.lancer.app/v1/campaigns/ICdP5Knfizog5IkDRyTV/leads/ma6H1U9jYesVEzLjzhAc/bid", "rejectUrl": "https://api.lancer.app/v1/campaigns/ICdP5Knfizog5IkDRyTV/leads/ma6H1U9jYesVEzLjzhAc/reject", "lead": { "id": "ma6H1U9jYesVEzLjzhAc", "createdAt": 1762332662524, // UNIX ms "campaignId": "ICdP5Knfizog5IkDRyTV", // Resolved at dispatch time. null if the campaign was deleted // or otherwise unreadable when the webhook fired. "campaignName": "Zoho / CRM specialists", "organizationId": "4IumEJtmvJEE2nShMWwM", "title": "Customer Support Phone Agent eCommerce | Shopify | Gorgias", "category": "Customer Service & Tech Support", "skills": [ { "name": "Shopify" }, { "name": "Customer Support" }, { "name": "Gorgias" } ], "datetime": 1762332592952, // when the job was posted, UNIX ms "description": "About Us: We're a fast-growing beauty brand...", "projectType": "Hiring 4 freelancers", // "Less than 1 month" | "1 to 3 months" | "3 to 6 months" | "More than 6 months" | null "projectDuration": "3 to 6 months", // Your bid must answer these, quoting each question verbatim. "questions": [ "Are you fine doing email support as well as inbound/outbound call support?", "Which e-commerce brands did you work for before?" ], "jobUrl": "https://www.upwork.com/jobs/~021985992773868976101", "metadata": { "hours": null, // "Less than 30 hrs/week" | "More than 30 hrs/week" | null "duration": "More than 30 hrs/week", // "Entry level" | "Intermediate" | "Expert" "experienceLevel": "Expert", "hourlyRate": { "min": 5, "max": 20 }, // "Hourly" | "Fixed-price" "paymentType": "Hourly", "fixedPrice": null }, "clientInfo": { "isPaymentVerified": true, "isPhoneVerified": false, "rating": 4.79, "numberOfReviews": 59, "country": "United States", "region": "Worldwide", "jobsPosted": 210, "totalSpent": 164280.2, "numberOfHires": 119, "activeEngagements": 57, "openJobs": 15, // count of the client's other open jobs "hireRate": 57, "memberSince": "2021-06-19T00:00:00.000Z", "companyIndustry": null, "companySize": null, "enterpriseClient": false, "avgHourlyRatePaid": 16.5, "companyId": "1406231475600871425" }, "vendorQualifications": { // if not null, only freelancers from this location can bid "location": null, // "Agency" | "Independent" | "ANY"; if specified, only that type can apply "talentType": "ANY", // "FLUENT" | "CONVERSATIONAL" | "NATIVE_OR_BILINGUAL" | "BASIC" | null "englishLevel": "FLUENT", // freelancer must have earned more than this (USD) to bid "minimumEarnings": null, // freelancers with a Job Success Score below this cannot bid "jobSuccessScore": 0, "includeRisingTalent": false }, "isFeatured": false, "clientReviews": [], // "Worldwide" | "USOnly" | "UKOnly" "region": "Worldwide", // The Upwork occupation for the job post. "occupation": "Email, Phone & Chat Support", // Files the client attached to the job post. Empty array (or null) // when the client did not attach anything. Each uri is an // Upwork-relative path; prefix with https://www.upwork.com to // download. The download requires the freelancer's Upwork session, // so links typically only work in a browser logged in to Upwork. "attachments": [ { "fileName": "Brand_guidelines_v3.pdf", "length": 532872, // bytes "uri": "/att/download/openings/2058619791124720878/attachments/cd3c2aea-3602-426c-a16f-d57c0b79f905/download" } ], // The client's other currently open postings. Useful for spotting // clients hiring across multiple roles right now. Distinct from // clientInfo.openJobs above, which is only a count. "openJobs": [ { "id": "1985992773868976101", // The opaque Upwork cipher; build the job URL as // https://www.upwork.com/jobs/~02 "ciphertext": "~021985992773868976101", "title": "Shopify storefront redesign", // "HOURLY" | "FIXED" "type": "HOURLY", // Private Talent Cloud posting flag. Most jobs are false. "isPtcPrivate": false } ] } } } ``` Respond by calling `bidUrl` or `rejectUrl` with your [API key](/docs/external-mode/api/authentication). **No response means no bid**: the lead simply stays parked, there is no timeout fallback. ### `suitable_job` (organization webhooks) [#suitable_job-organization-webhooks] Lancer's suitability AI approved a job for one of your campaigns. Pure notification; Lancer proceeds on its own. `data` has the same shape as `job.new`, with `data.lead` identical to [the lead object](#the-lead-object). For leads that came from an Upwork invitation rather than a campaign, `campaignId` is `null` and the action URLs are omitted. ```jsonc { "id": "wd_abc123", "event": "suitable_job", "version": "2026-04-20", "createdAt": 1713580800, "data": { "organizationId": "org_...", "campaignId": "cmp_...", "leadId": "lead_...", "bidUrl": "https://api.lancer.app/v1/campaigns/cmp_.../leads/lead_.../bid", "rejectUrl": "https://api.lancer.app/v1/campaigns/cmp_.../leads/lead_.../reject", "lead": { /* the lead object */ } } } ``` ### `unsuitable_job` (organization webhooks) [#unsuitable_job-organization-webhooks] Lancer analyzed a job and decided it does not match the campaign. Useful for auditing what the AI is filtering out. ```json { "data": { "organizationId": "org_123", "campaignId": "cmp_123", "leadId": "lead_abc123", "lead": { "...": "the lead object" }, "suitabilityRating": 3, "suitabilityReason": "The job requires on-site presence in Berlin, which conflicts with..." } } ``` ### `bid_complete` (organization webhooks) [#bid_complete-organization-webhooks] A proposal was successfully submitted on Upwork. This is the confirmation that a bid (automatic, or one you submitted via the API) actually went through. ```jsonc { "id": "wd_xyz456", "event": "bid_complete", "version": "2026-04-20", "createdAt": 1713580900, "data": { "organizationId": "org_...", "campaignId": "cmp_...", "leadId": "lead_...", "lead": { /* the lead object */ }, // The cover letter that was actually submitted. "proposal": "Hi, I noticed you are looking for..." } } ``` Dry-run bids via the API fire a simulated `bid_complete` so you can test end to end. ### `lead.new` (organization webhooks) [#leadnew-organization-webhooks] A new message room appeared in your Upwork inbox, usually a client replying to a proposal or inviting you directly. Fires **once per room, at creation**. Later messages in the same room do not fire again. ```jsonc { "id": "wd_lead_new_001", "event": "lead.new", "version": "2026-04-20", "createdAt": 1776861204, "data": { "organizationId": "4IumEJtmvJEE2nShMWwM", // The Upwork bidder account whose inbox received the room. // Useful if you have multiple accounts connected, so you can // route the event to the right workflow per account. "accountId": "TlMAG4bpU31hZOQ5ZkLz", // The Lancer campaign this room is tied to, enriched with the // campaign name so you do not need a second API call. Only // populated when the room is a reply to a proposal you submitted // via a Lancer campaign (typically roomTypeExtended "fl-proposal" // with a resolvable room.jobClientHistory.campaignId). // null for cold invitations, direct-hire offers, and DMs. "campaign": { "id": "ICdP5Knfizog5IkDRyTV", "name": "Zoho / CRM specialists" }, "room": { "id": "room_9f3c41d0a28b4e2c9d5f81b07e6a3145", "roomId": "room_9f3c41d0a28b4e2c9d5f81b07e6a3145", // The human-readable name Upwork uses for this thread. // Usually the client's name, sometimes with a company suffix. "roomName": "Jane Doe, Acme Co", // Upwork's subject line for the thread. Empty for pure DMs. "topic": "Zoho implementation", // What kind of inbox entry this is. // "client-invitation": client invited you to apply // "direct_hire_FL_profile": client sent a direct-hire offer // from your profile (no prior bid) // "fl-proposal": room created because YOU submitted // a proposal and the client replied. // This is the only variant where // data.campaign is typically set. // "direct_message": generic DM, no job attached "roomTypeExtended": "fl-proposal", // Unix ms. The moment of the latest story in this room on // Upwork's side (i.e. the newest message). "recentTimestamp": 1776861180000, "numUnread": 1, "latestStory": { "storyId": "story_a12b34c56d78e90f", "created": 1776861180000, "updated": 1776861180000, "deleted": 0, "isSystemStory": 0, "blockedTimestamp": -1, "userId": "2000000000000000001", // The first line of the incoming message. Note Upwork prefixes // with the sender's first name (e.g. "Jane: ...") or "You: ..." // when the story is yours. "message": "Jane: Hi, we'd love to chat about our Zoho rollout..." }, // Opaque Upwork context. Field set varies by roomTypeExtended. // Anything can be absent; treat every key as optional. "context": { "jobName": "Zoho implementation", "amount": "$15.0/hr", "associatedAgency": "Your Agency", "associatedAgencyOrgId": "1055837229072420865", "clientId": "1587051781037477888", "clientOrgId": "1587051781037477889", "clientName": "Jane Doe", "jobPosterName": "Jane Doe", "jobTitle": "Zoho implementation", "jobUid": "2034987699281050049", "applicationUid": "2034988290957822635", "applicantName": "Your Name", "freelancerName": "Your Name", "freelancerId": "1055837229068226560", "freelancerOrgId": "1055837229072420865", // "Activated" | "new" | "withdrawn" | "CANCELLED" | "accepted" // | "expired" | ... (Upwork's application-status string). "currentStatus": "Activated", "showAsAgency": "false" }, // Present when Lancer has previously seen the underlying Upwork // job (e.g. you bid on it via a campaign). Null-ish fields mean // Lancer does not have that data. campaignId links the room back // to the Lancer campaign that bid; null if there is no match. // When non-null, the top-level data.campaign above mirrors it // with the resolved campaign name. "jobClientHistory": { "jobUrl": "https://www.upwork.com/jobs/~022034987699281050049", "title": "Zoho implementation", "campaignId": "ICdP5Knfizog5IkDRyTV", "biddingAmount": null, "boostingAmount": null, "boostedForPlace": null, "biddedAt": null, "jobDatetime": null, // Same shape as the lead object's clientInfo, where available. "clientInfo": { "isPaymentVerified": true, "isPhoneVerified": true, "rating": 5, "numberOfReviews": 3, "country": "USA", "region": "Worldwide", "jobsPosted": 17, "totalSpent": 3458.21, "numberOfHires": 7, "activeEngagements": 4, "openJobs": 5, "hireRate": 42, "memberSince": "2022-10-31T00:00:00.000Z", "companyIndustry": null, "companySize": null, "enterpriseClient": false, "avgHourlyRatePaid": 20.7, "companyId": "1587051781037477889" } } } } } ``` Subscribing to this event requires a bidder account connected to chat. ## The lead object [#the-lead-object] The `lead` in `job.new`, `suitable_job`, `unsuitable_job`, and `bid_complete` payloads, and in [`GET /leads`](/docs/external-mode/api/list-leads) responses, has this shape. Fields that Upwork does not provide for a given job are `null`. | Field | Type | Notes | | -------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Stable lead id. Use it with the Public API. | | `createdAt` | number | When Lancer created the lead, ms. | | `title` | string | Job title. | | `description` | string | Full job description. | | `jobUrl` | string | Link to the job on Upwork. | | `datetime` | number | When the job was posted, ms. | | `category` | string | Upwork category. | | `occupation` | string | Upwork occupation. | | `skills` | array | `{ "name": string }` per skill. | | `questions` | string\[] | Screening questions. Answers must match these verbatim when bidding. | | `projectType`, `projectDuration` | string | Project shape. | | `isFeatured` | boolean | Featured job flag. | | `region` | string | `Worldwide`, `USOnly`, or `UKOnly`. | | `metadata` | object | `experienceLevel`, `paymentType`, `hourlyRate` (`{min,max}` or null), `fixedPrice`, `duration`, `hours`. | | `clientInfo` | object | Payment and phone verification, `rating`, `numberOfReviews`, `country`, `region`, `totalSpent`, `numberOfHires`, `jobsPosted`, `hireRate`, `memberSince`, `companyIndustry`, `companySize`, `enterpriseClient`, `avgHourlyRatePaid`, `activeEngagements`, `openJobs` (count), and more. | | `clientReviews` | array | The client's past reviews: freelancer feedback both ways, ratings, billed amounts. | | `vendorQualifications` | object | What the client asks of freelancers: location, `talentType`, English level, minimum earnings, job success score, and similar. | | `attachments` | array or null | Files on the job post: `{ fileName, length, uri }`. `uri` is relative; prefix `https://www.upwork.com`. | | `openJobs` | array or null | The client's other open postings: `{ id, ciphertext, title, type, isPtcPrivate }`. | | `campaignId` | string | The matching campaign. | | `campaignName` | string or null | Its name. | | `organizationId` | string | Your organization. | | `proposalId` | string | Set once a proposal exists. | Anything not listed here is internal and never included. # Webhooks overview (/docs/external-mode/webhooks) Lancer webhooks POST a JSON payload to your endpoint when something happens. There are two kinds, and knowing which one you need is most of the setup: | | Organization webhooks | Campaign webhooks | | ------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Configured in | Settings, then Integrations, **Webhooks** tab | The campaign's **Integrations** tab | | Events | `suitable_job`, `unsuitable_job`, `bid_complete`, `lead.new` | `job.new` only | | Purpose | **Notifications.** Lancer already handled the event; you are being informed. | **Control.** In [external mode](/docs/external-mode), your system must respond by calling the Public API. | | Fires for | Any campaign, any mode | External-mode campaigns | Webhooks require an active paid subscription. Both kinds can also be managed by an AI agent through the [MCP server](/docs/mcp) (`campaign_webhook_upsert` and friends). ## Setting up an organization webhook [#setting-up-an-organization-webhook] Go to **Settings**, then **Integrations**, and open the **Webhooks** tab. Click **Add webhook**, enter your HTTPS endpoint URL, and pick one or more events. Subscribing to `lead.new` requires at least one Upwork bidder account connected to chat, since the event is sourced from your Upwork inbox. Use the **Test** action to send a real sample delivery, then check the **Activity** tab to see the delivery record and your endpoint's response. Campaign webhooks are created the same way from the campaign's **Integrations** tab, as part of the [external mode setup](/docs/external-mode/quickstart). ## What your endpoint must do [#what-your-endpoint-must-do] * Accept `POST` requests with a JSON body. * Respond with any `2xx` status within **10 seconds**. Do slow work asynchronously after acknowledging. * Treat deliveries as at-most-once and deduplicate on the `X-Lancer-Delivery-Id` header if you retry-sensitive processing. Details in [Delivery, security, and testing](/docs/external-mode/webhooks/delivery). Each event is delivered once, with no automatic retry. A failed delivery is recorded in the log and the webhook stays active for the next event. Acknowledge fast so you never miss one. ## Next [#next] Every event, its payload schema, and annotated examples. Headers, the no-signature security model, failure behavior, and how to test.