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.

The guide works for both external modes, External proposal (external_proposal, Lancer screens, you write) and External decision & proposal (external, you screen and write). Not sure which you want? The overview has a short comparison.

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.
  • For External proposal, a knowledge base profile selected on the campaign, since Lancer's suitability AI keeps reading it.

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 an external mode and add the webhook

Open your campaign and go to its Integrations tab:

  1. Set Bidding mode to External proposal or External decision & proposal. If the campaign was active and in Standard mode 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. External proposal additionally needs a knowledge base profile on the campaign before it can start (Standard mode's requirement, kept because the suitability AI still runs); External decision & proposal does not.

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.

Both bypass suitability: they send the job whether or not Lancer's AI has scored it, so in External proposal mode suitabilityRating and suitabilityReason may be null on a test payload even though every real delivery carries them. Make sure your receiver tolerates that.

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",
      // Lancer's suitability verdict, 0 to 100 and the reasoning behind it.
      // Populated in External proposal mode (only suitable jobs, so always
      // >= 80, are sent). null in External decision & proposal mode, and on
      // Test/Trigger payloads for jobs Lancer has not scored yet.
      "suitabilityRating": 92,
      "suitabilityReason": "Shopify support role for an established eCommerce brand with a verified, high-spend client; matches the profile's Gorgias and Shopify experience.",
      "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.

Sample n8n workflows

Two importable workflows to start from, one per mode:

  • External proposal: Webhook, then an AI step that writes the cover letter (using Lancer's suitabilityReason as context), then /bid with dryRun: true. No qualifier, because Lancer already screened the job.
  • External decision & proposal: Webhook, then an AI qualifier that decides, then a writer, a hosted proposal page, and /bid or /reject.

Import either from n8n's Add workflow menu, add your OpenRouter and Lancer API key credentials, and paste the webhook's production URL into Lancer.

Go live

  1. Remove "dryRun": true from your bid call.
  2. Check the campaign's filters one more time. In External decision & proposal they are the only thing deciding which jobs reach you; in External proposal they bound what the suitability AI gets to see. The total from List leads tells you the current matching volume.
  3. Start the campaign. The campaign page lists anything still missing:
    • External proposal: a connected bidder account, at least one saved filter, one active job.new webhook, and a knowledge base profile with at least 50 characters of core services.
    • External decision & proposal: a connected bidder account, at least one saved filter, and one active job.new webhook.

From now on every matching job (External decision & proposal) or every suitable job (External proposal) 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 can replace from Standard mode: a suitability layer that decides whether the job is worth bidding on, and a proposal layer that writes the content. The suitability layer is exactly what External proposal mode keeps inside Lancer: if you run that mode, drop isSuitable and go straight to writeProposal for every job.new you receive.

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 and External proposal
// mode. In External proposal mode Lancer has already done this, so
// return true here (or read lead.suitabilityReason for context).
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