Quickstart

Go from zero to your first externally controlled bid, step by step.

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

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

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

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

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:

{
  "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 without prefix>
          "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

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:

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:

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

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 to test your logic against real data without waiting for webhooks.

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 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 at the organization level.

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 before calling /bid.
  • Per-bid overrides: rate strategy and boosting can be set per bid via biddingConfig; anything you omit falls back to the campaign's settings.

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.

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.

On this page