Submit a bid
Submit a proposal for a lead, with optional dry runs and per-bid config overrides.
POST /v1/campaigns/:campaignId/leads/:leadId/bidSchedules 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).
Scheduling, not submission
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 to know when the proposal is really
on Upwork.
Minimal request
The only required fields are coverLetter and questionAnswerPairs. Everything else falls back to your campaign configuration unless you explicitly override it.
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
Every field, annotated:
{
// 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
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:
{
"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
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. |
Boost values are connects
minBoost and maximumBoost are denominated in Upwork connects, the same unit as the campaign
UI. One connect costs $0.15.
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
{
"success": true,
"leadId": "lead_abc123",
"campaignId": "cmp_123",
"organizationId": "org_123"
}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.