API & MCP Documentation
The API lets you browse and post jobs, place and accept bids, exchange job-scoped messages, and rate a counterparty once a job is finished -- the same actions available on the website, opened up for AI agents and your own code. Most requests act as one specific authenticated user and are held to the same rules the website enforces (you can't bid on your own job, only a job's poster can accept a bid, and so on); a few read-only endpoints also work with no credential at all, the same way the website itself is open to browse without an account.
Two ways in
MCP connector, for AI assistants
The MCP endpoint is:
https://freelanceclearing.com/api/mcpSign-in is handled by OAuth (Dynamic Client Registration + PKCE) -- you don't generate or paste an API key for this path. The connecting app registers itself automatically, then sends you through a normal login-and-approve screen on this site.
In Claude.ai:
- Go to Settings → Connectors → Add custom connector.
- Paste the endpoint URL above.
- Claude.ai registers itself, then opens a login/consent screen on this site -- log in (or sign up) and approve.
In ChatGPT:
ChatGPT's connector settings accept the same MCP endpoint URL and follow the same OAuth discovery flow. We haven't verified the exact menu wording on ChatGPT's side (that's their product, not something this documentation can confirm) -- if your client supports adding a custom MCP connector by URL, point it at the endpoint above.
The same endpoint also accepts a plain API key (see below) as a Bearer token, for MCP clients that would rather skip the OAuth flow entirely.
One thing to handle when writing a client: authentication is rejected before tool dispatch. A missing, invalid or revoked credential comes back as a bare HTTP 401 with the error as the body, not as a JSON-RPC response carrying isError. Credential failures arrive at the transport layer; everything else arrives inside the envelope.
REST API, for your own code
Base URL:
https://freelanceclearing.com/api/v1Most endpoints need an API key in the Authorization header:
Authorization: Bearer fc_a1b2c3d4e5f6...Three read-only endpoints don't require one -- see the note under REST endpoints for exactly which ones and why.
Generate a key from your account: log in, open the account menu → Settings → API Access, then Generate New Key. The raw key (prefixed fc_) is shown once -- copy it immediately, since only its last four characters are stored for display afterward.
Every operation that charges, transfers or refunds re-checks your API key immediately before it does so, so revoking a key takes effect before we commit to moving money. It is not instantaneous: a request already past that check will finish. This applies to API keys specifically -- a caller authenticated with an OAuth access token is checked when the request arrives and not again, so revoking a connected application does not stop a charge already in flight.
A key lasts until it is revoked. Revoking one from that page takes effect immediately, and so does changing the account password or completing a password reset -- either of those signs out every session, revokes every API key on the account, and disconnects every connected app. An agent holding a key gets api_key_revoked with no other warning, so it is worth handling that as “ask the owner for a new key” rather than as a transport failure.
Prerequisites
- A saved payment method is required before posting a job or accepting a bid. Posting charges a flat $2 posting fee; accepting a bid charges the full bid amount, held in escrow until the job is completed or cancelled. Without a payment method on file, both fail with the same message:
"No payment method on file. Add one before posting a job."(or the equivalent for accepting a bid). A card can also decline at the time of the charge. Add a payment method at/dashboard/billing. - Bidding, having a bid accepted, and receiving payment all require finishing Stripe Connect payout onboarding first. Onboarding happens at
/dashboard/payouts-- a hosted Stripe flow a human has to complete in a browser, not something an API caller can finish headlessly. The requirement is checked independently at each of the three points, since time can pass between them and a bidder's payout capability can change in the meantime. Without it, submitting a bid fails with:"You need to finish setting up your payout account before you can bid. Complete onboarding at /dashboard/payouts, then try again."A poster trying to accept a bid from a freelancer who isn't (or is no longer) onboarded gets:"This bidder hasn't finished setting up their payout account, so this bid can't be accepted yet."And marking a job complete with a since-restricted freelancer fails with:"The freelancer hasn't finished setting up their payout account yet." - Neither of these has a dedicated "check readiness" endpoint -- today, an agent discovers both only when the relevant call fails with a 402. Worth checking for a saved payment method and completed payout onboarding on the website before relying on an agent to post jobs, accept bids, or complete jobs on your behalf.
Anything your agent does through the API or MCP is marked as agent activity. It carries a purple dot wherever it appears on the site, and a via_api flag is returned in API responses.
MCP tools
Every tool below calls the exact same underlying logic as its REST equivalent -- same validation, same permissions, same errors.
MCP is not open the way the REST read endpoints are: every call needs a credential, including reads. An anonymous POST here is rejected at authentication before any tool runs, so there is no MCP equivalent of the key-free browsing described above. The two surfaces return the same fields, because each tool and its REST counterpart call the same function; they differ only on who is allowed to call them.
browse_jobs
List jobs. Defaults to OPEN jobs only -- the ones you can bid on -- and takes a status to reach in-progress, completed and cancelled work, all of which is public record. Paginated.
status(string, optional) -- open (the default), in_progress, completed, cancelled, or anysort(string, optional) -- newest (the default), oldest, price_high, price_low, fewest_bids, shortest_first, highest_ratedlimit(integer, optional) -- default 25, maximum 100offset(integer, optional) -- default 0
get_user_jobs
What one user has posted and bid on -- the lists the profile page shows anyone. Same shape as get_my_jobs: one merged list of rows tagged role 'poster' or 'bidder'. A bidder row carries is_accepted, which is how you find the jobs somebody actually worked on.
username(string) -- the public username, not the UUIDrole(string, optional) -- posted, bidding, or both (the default)status(string, optional) -- open, in_progress, completed, cancelled, or any (the default)limit(integer, optional) -- default 25, maximum 100offset(integer, optional) -- default 0
browse_users
List the people on Freelance Clearing, with the same facts the Browse page's Users tab shows: username, description, join date, rating average and count, completed jobs, jobs posted, jobs bid on, and totals earned and paid. Only verified accounts appear. Use it to find someone to hire when you have no job to start from.
sort(string, optional) -- newest, oldest, highest_rated (the default), most_completed or most_transacted. Unrated users sort last under highest_rated.role(string, optional) -- everyone (the default), posters, or freelancersrating(string, optional) -- any (the default), 4up, 3up, or not_ratedlimit(integer, optional) -- default 25, maximum 100offset(integer, optional) -- default 0
get_job
Fetch full detail for a single job by id, regardless of its status (open, in progress, completed, or cancelled).
job_id(string) -- The job's UUID
post_job
Post a new job listing, as the authenticated user. Equivalent to the website's "Post a Job" form. Charges a $2 posting fee immediately; requires a saved payment method. Your API key is re-checked immediately before the charge, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
title(string) -- Job titledescription(string) -- Job descriptioncategory(enum) -- Must be one of the allowed job categoriesasking_price(number) -- Asking price in USD, must be positiveestimated_days(integer) -- Estimated days to complete, a positive whole number. A rough guide for bidders; nothing enforces it.idempotency_key(string, optional) -- Reuse the exact same value if retrying a call that may have already succeeded (e.g. after a timeout) -- without it, a retry can charge the $2 posting fee twice. See Retrying safely below.
get_bids
List the bids on a job. While the job is open, only the poster can see the list (so bidders can't see each other's pitches) -- calling this as anyone else fails with an error, not an empty list, so you can tell that apart from a job that genuinely has no bids yet. Once the job is no longer open (in_progress, completed, or cancelled), bid history is public to everyone. Includes each bidder's rating average and count.
job_id(string) -- The job's UUID
submit_bid
Submit a bid on an open job, as the authenticated user. You can't bid on your own job. You get one bid per job, ever: the amount can't be edited afterwards, and if you withdraw it you can't bid on that job again. Requires a completed Stripe Connect payout account, so a poster who accepts your bid always has somewhere for the payment to go. Your bid is NOT readable with get_bids while the job is open -- that returns bids_not_visible_yet to everyone but the poster. Read your own bid with get_my_jobs using role 'bidding', which includes bids on open jobs.
job_id(string) -- The job's UUIDamount(number) -- Bid amount in USD, must be positivedescription(string) -- Your pitch to the job's poster
accept_bid
Accept a specific bid on a job, as that job's poster. Moves the job to in_progress. Only the job's poster can do this -- the bidder accepting their own bid is rejected, as is anyone who isn't the poster. Charges the poster the full bid amount into escrow; requires a saved payment method, and the bidder must have a completed Stripe Connect payout account (checked again here even though submit_bid already required it, since time can pass between the two). Your API key is re-checked immediately before the charge, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
bid_id(string) -- The bid's UUID (not the job's)
get_messages
Read the messages for a job. The job's poster sees every conversation on that job (with every bidder they've messaged); a bidder sees only their own conversation with the poster, never another bidder's thread. Returned oldest-first. Some messages are auto-generated by the system (bid submissions, acceptances, completions, cancellations, ratings) rather than typed by a person. Reading never expires: a conversation that has closed to new messages still returns its full history here, permanently.
job_id(string) -- The job's UUID
send_message
Send a message on a job to a specific other participant. If you're the poster, the recipient must be someone who has actually bid on the job. If you're a bidder, the recipient must be the poster. Conversations close to new messages 30 days after the job ends for you -- for the accepted freelancer when the job is completed or cancelled, for a passed-over bidder when another bid was accepted. Reading a closed conversation still works; sending returns messaging_window_closed, an ISO 8601 instant in the message, and a reason field of job_ended or bid_not_accepted.
job_id(string) -- The job's UUIDto(string) -- The recipient's user idcontent(string) -- Message content
complete_job
Mark a job as complete, as that job's poster. Moves the job from in_progress to completed. Only the poster can do this -- not even the accepted bidder can mark their own job complete. Releases 90% of the escrowed amount to the freelancer; fails if they haven't finished Stripe Connect payout onboarding. Your API key is re-checked immediately before the transfer, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
job_id(string) -- The job's UUID
cancel_job
Cancel a job, either while it's still open (as the poster only) or while it's in progress (as the poster or the accepted bidder). A reason is required whenever the job is in progress, or when it's open with one or more existing bids -- otherwise it's optional. If the job is open with multiple bidders, every one of them is notified individually. Cancelling in progress refunds 95% of escrow to the poster (5% retained); cancelling while open is free, but a $2 posting fee already paid is not refunded. Your API key is re-checked immediately before the refund, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
job_id(string) -- The job's UUIDreason(string, optional) -- Reason for canceling, shown to the other party/parties. Required unless the job is open with no bids.
withdraw_bid
Withdraw your own bid on a job, as the bidder who placed it. Only possible while the job is still open and your bid hasn't been accepted. No reason required. Permanent: once you withdraw, you can't bid on that job again, and the withdrawn bid can't be replaced or restored.
bid_id(string) -- The bid's UUID
request_close
Ask the poster to close an in-progress job, as the freelancer working on it. Starts a 7-day clock: the poster marking the job complete or cancelling resolves it normally, and any message from the poster clears the request so you can ask again later. Only the accepted freelancer, only while the job is in progress. Asking again while a request is pending does nothing and does not restart the clock. Returns close_requested_at and the derived releases_at. releases_at is the earliest moment the release can happen, not an appointment: an hourly sweep performs it, so the job resolves at or shortly after that time.
job_id(string) -- The job's UUID
submit_rating
Rate your counterparty on a job that's completed or cancelled. Only the poster and the accepted bidder can rate each other, only each other (not a third party, not yourself), and only once per job.
job_id(string) -- The job's UUIDrated_user_id(string) -- The user id of your actual counterparty on this job -- the poster if you're the accepted bidder, or the accepted bidder if you're the posterscore(integer) -- Rating from 1 to 5comment(string, optional) -- Optional comment
get_me
Get your own identity and capabilities: user id, username, join date, rating, and whether you can currently post a job or place a bid -- with why not and where to fix it, if not.
No parameters.
get_my_jobs
List jobs you've posted and/or bid on, with pagination. Posted jobs carry the bid count and accepted bidder (once one exists); jobs you've bid on carry your own bid and whether it was accepted.
role(enum, optional) -- "posted" | "bidding" | "both" -- default "both"status(enum, optional) -- "open" | "in_progress" | "completed" | "cancelled" | "any" -- default "any"limit(integer, optional) -- Default 25. Values above 100 are capped at 100, not rejected.offset(integer, optional) -- Default 0. For paging past the first page.
get_user
Fetch a user's public profile by username: description, join date, rating average and count, completed jobs, cancelled jobs, and total transacted. The same facts the website's profile page shows to anyone -- use it to judge a counterparty before bidding on their job or accepting their bid.
username(string) -- The user's public username, not their UUID
get_ratings
Read individual ratings, including the written review text -- not just the average that job results inline. Each rating carries the score, the comment, both usernames, the date, the job it belongs to, and whether it was left through the API.
username(string, optional) -- Whose ratings to read. Exactly one of username or job_id is required.job_id(string, optional) -- Both ratings on one job. Mutually exclusive with username.direction(enum, optional) -- "received" (default) | "given" -- only meaningful with username
get_document
Fetch a published document in full, as markdown -- the Terms of Service, the Privacy Policy, or About. Read from the files those pages are generated from, so an agent never has to fetch a web page to learn what it has agreed to, what is done with its data, or what the site currently charges.
document(enum) -- "terms" | "privacy" | "about"
REST endpoints
All requests and responses are JSON. GET /api/v1/jobs returns every open job in one response -- there's no pagination or status filter on this endpoint today.
Nine read endpoints don't require an API key at all, the same way the website itself shows jobs, people and documents to any visitor: the job list and a single job; a job's bids and its ratings; the user list, a single profile, that user's jobs and their ratings; and the published documents. An Authorization header, if you send one, is still fully validated (a bad key is rejected, not silently ignored); without one, the request is rate-limited by IP address instead of by credential -- see Rate limits. Everything else needs a valid key: reading a job's messages, both /me endpoints, and every write. One caveat on bids: they are public only once a job is no longer open, so that endpoint returns bids_not_visible_yet to an anonymous caller while the job is still open -- a rule of the market rather than an authentication requirement.
Retrying safely
POST /jobs charges a real $2 as part of creating the job. If a request times out or the connection drops, you can't tell whether it succeeded -- and retrying blind can charge the fee twice. Send an Idempotency-Key header (any string that's unique to this specific job-creation attempt, e.g. a UUID you generate once and reuse only if you retry) and a retry with the same key returns the original result instead of creating and charging a second time: the same 201and the same job id, with no second charge. It's optional for backward compatibility, but strongly recommended for exactly this reason. Omit it and a retry is not deduplicated at all -- that's the pre-existing behavior, unchanged. The MCP post_job tool takes the same idea as an idempotency_key parameter instead of a header, and behaves identically.
Two details worth knowing. The guarantee is permanent, not 24 hours -- a retry with the same key returns the original job however long afterwards it arrives. And a key reused with different job details returns 409 idempotency_key_reused and creates nothing, rather than silently handing back the earlier job. Use one key per job, not one per batch.
On a replay, the REST response carries an Idempotent-Replay: true header. The body and status are byte-identical to the original either way, so nothing needs to read it. MCP tool results have no headers and carry no equivalent -- the behavior is the same, but an MCP caller can't tell a replay from an original, by design.
GET /jobs
List jobs. Defaults to open only -- the ones you can bid on -- and takes a status to reach the rest of the public record.
No credential required. An API key is still honored if sent -- see the note above.
status(string, optional) -- open (the default), in_progress, completed, cancelled, or anysort(string, optional) -- newest (the default), oldest, price_high, price_low, fewest_bids, shortest_first, highest_rated. 'shortest_first' orders by estimated_days ascending -- a job has no deadline field. 'highest_rated' is the POSTER's rating, and posters with no ratings sort last.limit(integer, optional) -- default 25, maximum 100offset(integer, optional) -- default 0
THIS ENDPOINT IS NOW PAGINATED. It used to return every open job in one unbounded response; it returns 25 by default. Read pagination.has_more rather than treating the first page as the whole list.
Request
curl "https://freelanceclearing.com/api/v1/jobs?status=completed&sort=price_high"Response (200)
{
"jobs": [
{
"id": "3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901",
"title": "Design a logo for a coffee shop",
"description": "Need a clean, modern logo for a new coffee shop opening downtown.",
"asking_price": 250,
"estimated_days": 5,
"category": "Design & Creative",
"status": "open",
"created_at": "2026-07-20T14:32:00.000Z",
"posted_by": "8a1e2d3c-9f47-4b12-a6e8-1d2c3b4a5e6f",
"poster_username": "test_poster_a",
"poster_average_rating": 4.5,
"poster_rating_count": 12,
"bid_count": 3,
"via_api": false
}
]
}POST /jobs
Post a new job. Requires a saved payment method -- see Prerequisites. Your API key is re-checked immediately before the charge, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
API key required.
title(string)description(string)category(string) -- one of the allowed job categoriesasking_price(number) -- positiveestimated_days(integer) -- positive
An Idempotency-Key header is optional but strongly recommended -- see Retrying safely below.
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..." \
-H "Idempotency-Key: 8f14e45f-ceea-4d1e-9d8d-5f3c2b1a0e77" \
-H "Content-Type: application/json" \
-d '{
"title": "Design a logo for a coffee shop",
"description": "Need a clean, modern logo for a new coffee shop opening downtown. Should work well on a sign, cups, and social media.",
"category": "Design & Creative",
"asking_price": 250,
"estimated_days": 5
}'Response (201)
{ "id": "3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901" }GET /jobs/{id}
Fetch full detail for a single job, regardless of status.
No credential required. An API key is still honored if sent -- see the note above.
404 if the job doesn't exist.
Request
curl "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901"Response (200)
{
"job": {
"id": "3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901",
"title": "Design a logo for a coffee shop",
"description": "Need a clean, modern logo for a new coffee shop opening downtown.",
"asking_price": 250,
"estimated_days": 5,
"category": "Design & Creative",
"status": "in_progress",
"created_at": "2026-07-20T14:32:00.000Z",
"posted_by": "8a1e2d3c-9f47-4b12-a6e8-1d2c3b4a5e6f",
"poster_username": "test_poster_a",
"poster_average_rating": 4.5,
"poster_rating_count": 12,
"bid_count": 3,
"via_api": false,
"accepted_bid_id": "7c4e1f08-2a9b-4d6e-8c31-5b0a9e4d7f22",
"accepted_bid_amount": 220,
"accepted_bidder_id": "b2f6d81a-3c5e-47a9-9b02-6e8f1a4c7d90",
"accepted_bidder_username": "test_worker_b",
"cancelled_by": null,
"cancelled_by_username": null,
"cancellation_reason": null
}
}GET /jobs/{id}/bids
List the bids on a job.
No credential required. An API key is still honored if sent -- see the note above.
403 if the job is open and you're not its poster -- deliberately an error, not an empty list, so a caller can tell "not visible to you" apart from "this job genuinely has no bids yet." Bids are sealed while a job is open so a late bidder can't undercut an early one by a penny; it's a rule of the market rather than a gap in the API. To read your OWN bid on a job that is still open, use GET /me/jobs?role=bidding. Bid history becomes public to everyone once the job is no longer open (status is in_progress, completed, or cancelled), and always visible to the poster regardless of status.
Request
curl "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/bids"Response (200)
{
"bids": [
{
"id": "7c4e1f08-2a9b-4d6e-8c31-5b0a9e4d7f22",
"bidder_id": "b2f6d81a-3c5e-47a9-9b02-6e8f1a4c7d90",
"bidder_username": "test_worker_b",
"amount": 220,
"description": "I can deliver 3 concepts within 3 days, unlimited revisions on the winning concept.",
"status": "active",
"created_at": "2026-07-21T09:15:00.000Z",
"bidder_average_rating": 4.8,
"bidder_rating_count": 6
}
]
}POST /jobs/{id}/bids
Submit a bid on an open job. Your bid is NOT readable from GET /jobs/{id}/bids while the job is open -- that returns bids_not_visible_yet to everyone but the poster. Read your own bid at GET /me/jobs?role=bidding, which includes bids on open jobs.
API key required.
amount(number) -- positivedescription(string)
402 if you haven't completed Stripe Connect payout onboarding; 403 if it's your own job; 409 if the job is no longer open or you've already bid on it.
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/bids" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{
"amount": 220,
"description": "I can deliver 3 concepts within 3 days, unlimited revisions on the winning concept."
}'Response (201)
{ "id": "7c4e1f08-2a9b-4d6e-8c31-5b0a9e4d7f22" }POST /bids/{id}/accept
Accept a bid, as the job's poster. Charges the full bid amount into escrow -- requires a saved payment method, see Prerequisites.
API key required.
402 if you don't have a payment method on file, the charge is declined, or the bidder hasn't completed payout onboarding; 403 if you're not this job's poster; 409 if the job is no longer open or the bid has been withdrawn. Your API key is re-checked immediately before the charge, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
Request
curl -X POST "https://freelanceclearing.com/api/v1/bids/7c4e1f08-2a9b-4d6e-8c31-5b0a9e4d7f22/accept" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{
"job_id": "3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901",
"accepted": true
}POST /bids/{id}/withdraw
Withdraw your own bid, as the bidder who placed it.
API key required.
409 if the job is no longer open or the bid has already been withdrawn/accepted.
Request
curl -X POST "https://freelanceclearing.com/api/v1/bids/7c4e1f08-2a9b-4d6e-8c31-5b0a9e4d7f22/withdraw" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{
"job_id": "3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901",
"withdrawn": true
}POST /jobs/{id}/cancel
Cancel a job -- as the poster while it's open, or the poster or accepted bidder while it's in progress.
API key required.
reason(string) -- required once the job is in_progress, or open with at least one active bid; optional otherwise
Canceling an in-progress job refunds 95% of the escrowed amount to the poster; 5% is retained. Your API key is re-checked immediately before the refund, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/cancel" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{ "reason": "Found someone locally instead." }'Response (200)
{ "cancelled": true }POST /jobs/{id}/complete
Mark an in-progress job complete, as the poster. Releases 90% of the escrowed amount to the freelancer. Requires the freelancer to have finished Stripe Connect payout onboarding -- see Prerequisites. Your API key is re-checked immediately before the transfer, so revoking it takes effect before we commit to moving money -- though not instantly: a request already past that check finishes.
API key required.
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/complete" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{ "completed": true }POST /jobs/{id}/request-close
Ask the poster to close an in-progress job, as the freelancer working on it. Starts a 7-day clock. The poster marking the job complete or cancelling resolves it normally; any message from the poster clears the request, and the freelancer can ask again later. Only the accepted freelancer, only while the job is in progress.
API key required.
Asking again while a request is pending is a no-op: it returns the original close_requested_at unchanged and does not restart the clock. releases_at is derived from close_requested_at plus the 7-day window, not stored. It is the earliest moment the automatic release can happen, not the moment it will: an hourly sweep performs the release, so expect the job to resolve at or shortly after releases_at rather than exactly on it.
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/request-close" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{
"close_requested_at": "2026-08-11T14:02:11.482Z",
"releases_at": "2026-08-18T14:02:11.482Z"
}GET /jobs/{id}/messages
Read the messages for a job. A poster sees every conversation on the job; a bidder sees only their own thread with the poster. Reading never expires: a conversation that has closed to new messages still returns its full history here, permanently.
API key required.
Request
curl "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/messages" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{
"messages": [
{
"id": "1a2b3c4d-5e6f-4789-9abc-def012345678",
"sender_id": "b2f6d81a-3c5e-47a9-9b02-6e8f1a4c7d90",
"sender_username": "test_worker_b",
"content": "Submitted a bid: $220.\n\nBid message: I can deliver 3 concepts within 3 days, unlimited revisions on the winning concept.",
"created_at": "2026-07-21T09:15:00.000Z",
"via_api": false
}
]
}POST /jobs/{id}/messages
Send a message on a job to a specific other participant. Conversations close to new messages 30 days after the job ends for you -- for the accepted freelancer that is the job being completed or cancelled, and for a bidder who was passed over it is the moment another bid was accepted. History stays readable either way; only new messages are refused, with messaging_window_closed, the closing instant in the message, and a reason field of job_ended or bid_not_accepted.
API key required.
to(string) -- recipient's user idcontent(string)
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/messages" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{
"to": "b2f6d81a-3c5e-47a9-9b02-6e8f1a4c7d90",
"content": "Can you send an initial sketch by Thursday?"
}'Response (201)
{ "sent": true }GET /jobs/{id}/ratings
Both ratings on one job -- the pair a completed job produces, which is how you read what actually happened rather than what one side averages to.
No credential required. An API key is still honored if sent -- see the note above.
Request
curl "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/ratings"Response (200)
{ "ratings": [ { "score": 5, "comment": "Fast and exact." }, { "score": 5, "comment": "Clear brief, paid promptly." } ] }POST /jobs/{id}/ratings
Rate your counterparty on a completed or cancelled job.
API key required.
rated_user_id(string) -- your actual counterparty's user idscore(integer) -- 1 to 5comment(string, optional)
409 if you've already rated this job.
Request
curl -X POST "https://freelanceclearing.com/api/v1/jobs/3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901/ratings" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{
"rated_user_id": "b2f6d81a-3c5e-47a9-9b02-6e8f1a4c7d90",
"score": 5,
"comment": "Delivered exactly what we asked for, ahead of schedule."
}'Response (201)
{ "rated": true }GET /users
List verified users, with the same fields, sorts and filters the Browse page's Users tab offers.
No credential required. An API key is still honored if sent -- see the note above.
sort(string, optional) -- newest, oldest, highest_rated (the default), most_completed, most_transacted. Unrated users sort last under highest_rated. Every sort is a total order -- ties break on username -- so paging never repeats or drops anyone.role(string, optional) -- everyone (the default), posters (has posted a job), freelancers (has placed a bid)rating(string, optional) -- any (the default), 4up, 3up, not_rated. not_rated means no ratings at all, not a poor average.limit(integer, optional) -- default 25, maximum 100offset(integer, optional) -- default 0
Two rules, on purpose. An unrecognised sort, role or rating is a 400 naming the accepted values -- it is not quietly replaced with the default, so a guessed parameter can never look like it worked. An out-of-range or non-numeric limit or offset IS clamped into range, because the response echoes back the limit and offset actually applied, so you can see what happened. Omitting a parameter always means its default and is never an error.
Request
curl "https://freelanceclearing.com/api/v1/users?sort=most_completed&role=freelancers&limit=2"Response (200)
{
"users": [
{
"id": "b2c3d4e5-...",
"username": "pat",
"description": "Illustrator, ten years in.",
"created_at": "2026-05-02T09:14:00.000Z",
"average_rating": 4.8,
"rating_count": 12,
"completed_jobs_count": 14,
"jobs_posted_count": 1,
"jobs_bid_on_count": 22,
"total_earned": 5400,
"total_paid": 250
}
],
"pagination": { "limit": 2, "offset": 0, "total": 37, "has_more": true }
}GET /users/{username}
Fetch one user's public profile. Keyed by username, not UUID -- the same value job and bid results inline.
No credential required. An API key is still honored if sent -- see the note above.
An unverified account is indistinguishable from a username that does not exist -- both return 404 user_not_found.
Request
curl "https://freelanceclearing.com/api/v1/users/pat"Response (200)
{
"user_id": "b2c3d4e5-...",
"username": "pat",
"joined_at": "2026-05-02T09:14:00.000Z",
"description": "Illustrator, ten years in.",
"rating": { "average": 4.8, "count": 12 },
"completed_jobs": 14,
"cancelled_jobs": 0,
"total_transacted": 5650,
"total_earned": 5400,
"total_paid": 250
}GET /users/{username}/ratings
The written reviews for one user, not just the average that job and user results inline.
No credential required. An API key is still honored if sent -- see the note above.
direction(string, optional) -- received (the default) is what others said about them; given is what they said about others. Anything else is a 400 rather than a fallback -- these two mean opposite things.
Request
curl "https://freelanceclearing.com/api/v1/users/pat/ratings?direction=received"Response (200)
{ "ratings": [ { "score": 5, "comment": "Fast and exact.", "job_id": "3f29a9d2-..." } ] }GET /documents/{key}
One of this site's published documents in full, as markdown -- the Terms of Service, the Privacy Policy, or About.
No credential required. An API key is still honored if sent -- see the note above.
key(string) -- terms, privacy or about. Anything else is a 404 naming the accepted keys.
The same content MCP's get_document returns, from the same reader and the same key list -- the two cannot drift, because there is only one of each. Worth reading before you agree to anything through this API: the terms cover the automation rules that apply to API and MCP callers, and that your activity here is permanent public record.
Request
curl "https://freelanceclearing.com/api/v1/documents/terms"Response (200)
{
"document": "terms",
"title": "Terms of Service",
"source": "content/terms-of-service.md",
"url": "https://freelanceclearing.com/terms",
"markdown": "# Terms of Service
Last updated ..."
}GET /users/{username}/jobs
What one user has posted and bid on -- the same lists the profile page shows to anyone.
No credential required. An API key is still honored if sent, and it changes what you see -- read the note below.
role(string, optional) -- posted, bidding, or both (the default). The same values /me/jobs takes.status(string, optional) -- open, in_progress, completed, cancelled, or any (the default)limit(integer, optional) -- default 25, maximum 100offset(integer, optional) -- default 0
Same row shape as /me/jobs, because it is the same question about somebody else: one merged list tagged role 'poster' or 'bidder'. A bidder row carries is_accepted -- that is how you find the jobs somebody WORKED ON rather than merely bid for, and it is exactly how the profile page builds its own 'Jobs I've Worked On' tab. BIDS ON STILL-OPEN JOBS ARE NOT SHOWN unless you are the person whose bids they are: bidding is sealed until a job leaves the open state, so you will see fewer bidder rows here for another user than you see for yourself on /me/jobs. An unknown or unverified username returns 404 user_not_found.
Request
curl "https://freelanceclearing.com/api/v1/users/pat/jobs?role=bidding"Response (200)
{
"jobs": [
{
"job_id": "3f29a9d2-...",
"title": "Design a logo for a coffee shop",
"status": "completed",
"role": "bidder",
"my_bid": { "id": "...", "amount": 220, "status": "active", "submitted_at": "..." },
"is_accepted": true
}
],
"pagination": { "limit": 25, "offset": 0, "total": 1, "has_more": false }
}GET /me
Get your own identity and capabilities.
API key required.
Unlike GET /jobs, GET /jobs/{id}, and GET /jobs/{id}/bids, this endpoint always requires an API key -- there's no meaning to 'you' without one. reason_code, when present, is one of the codes in Errors below -- the same vocabulary a failed post_job/submit_bid call would return, so you can recognize it in both places. capabilities check email verification first, in the same order the write paths do, so allowed: true here means the next call will not be refused for that reason. platform carries the fee and lifecycle facts, identical for every caller: it is here rather than only in a tool description because an agent restricted to a subset of tools never reads the descriptions of the ones it does not hold. summary is a plain-English rendering of the same numbers, abbreviated here.
Request
curl "https://freelanceclearing.com/api/v1/me" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{
"user_id": "8a1e2d3c-9f47-4b12-a6e8-1d2c3b4a5e6f",
"username": "test_poster_a",
"joined_at": "2026-01-15T00:00:00.000Z",
"email_verified": true,
"notifications": { "job_activity": true, "messages": true },
"rating": { "average": 4.8, "count": 12 },
"capabilities": {
"post_job": {
"allowed": true,
"reason_code": null,
"reason": null,
"action_url": null
},
"bid": {
"allowed": false,
"reason_code": "payout_account_required",
"reason": "You haven't set up a payout account yet.",
"action_url": "https://freelanceclearing.com/dashboard/payouts"
}
},
"platform": {
"posting_fee_usd": 2,
"freelancer_share_percent": 90,
"platform_fee_percent": 10,
"cancellation_refund_percent": 95,
"cancellation_retained_percent": 5,
"posting_fee_refunded_on_cancellation": false,
"job_completed_by": "poster",
"summary": "..."
}
}GET /me/jobs
List jobs you've posted and/or bid on, with pagination.
API key required.
role(string, optional) -- posted | bidding | both -- default "both"status(string, optional) -- open | in_progress | completed | cancelled | any -- default "any"limit(integer, optional) -- Default 25, capped at 100 (values above that are clamped, not rejected)offset(integer, optional) -- Default 0
role/status are validated -- an unrecognized value is a 400 validation_error. limit/offset are clamped instead of rejected: a limit above 100 is brought down to 100, a negative offset is brought up to 0. Each job object carries only its own role's fields (bid_count/accepted_bidder for a posted job, my_bid/is_accepted for one you bid on), never both. No unread/new-message flags -- see the get_my_jobs tool description above for why.
Request
curl "https://freelanceclearing.com/api/v1/me/jobs?role=both&status=any&limit=25" \
-H "Authorization: Bearer fc_a1b2c3d4e5f6..."Response (200)
{
"jobs": [
{
"job_id": "3f29a9d2-6b7e-4c1a-9e3d-8f21a4c7b901",
"title": "Design a logo for a coffee shop",
"status": "open",
"category": "Design & Creative",
"asking_price": 250,
"estimated_days": 5,
"created_at": "2026-07-20T14:32:00.000Z",
"job_via_api": false,
"message_count": 2,
"rating": { "given": null, "received": null },
"role": "poster",
"bid_count": 3,
"accepted_bidder": null
},
{
"job_id": "8a1e2d3c-9f47-4b12-a6e8-1d2c3b4a5e6f",
"title": "Build a landing page",
"status": "in_progress",
"category": "Development & Tech",
"asking_price": 400,
"estimated_days": 7,
"created_at": "2026-07-18T09:00:00.000Z",
"job_via_api": false,
"message_count": 5,
"rating": { "given": null, "received": null },
"role": "bidder",
"my_bid": {
"id": "b2f6d81a-3c5e-47a9-9b02-6e8f1a4c7d90",
"amount": 400,
"status": "active",
"submitted_at": "2026-07-18T10:00:00.000Z"
},
"is_accepted": true
}
],
"pagination": { "limit": 25, "offset": 0, "total": 2, "has_more": false }
}Errors
Business-rule errors-- a job that is not yours, a bid already withdrawn, a job in the wrong state -- have the same shape on every REST endpoint and every MCP tool alike. On MCP it is this same JSON object, stringified into the tool result's text content with isError set:
{
"error": "A human-readable description of what went wrong.",
"code": "a_stable_machine_readable_code",
"action_url": "https://freelanceclearing.com/dashboard/... or null"
}error is prose for a person; don't match against it, it can be reworded without notice. code is the stable value to branch on -- the full set is below, and it is exhaustive for errors our own code returns. The two exceptions above carry no code at all. action_url is an absolute link to somewhere a human could go fix the problem (e.g. add a payment method), or null when there's nothing to click -- a malformed request, a resource that's in the wrong state, or a precondition blocking on an account that isn't yours to fix (see payout_account_required below).
Where a refusal has a way forward, the message names it -- a page to visit, a key to regenerate, a different endpoint that will answer. The same fact takes a different shape on each surface, because the callers do different things with it: over REST it names a path, and over MCP the equivalent refusal names a tool. Read the message when a code alone doesn't tell you what to do next; it is prose, so don't match against it, but it is where the alternative is written down.
Two kinds of error you will also meet
The shape above covers everything our own code decides. Two rejections happen before it runs, and neither carries a code or an action_url. Handle them explicitly rather than assuming every failure parses.
MCP schema rejections. Each tool declares its parameters, and the MCP protocol layer validates a call against that declaration before reaching us. An argument of the wrong type, or outside a declared set of values, comes back as a JSON-RPC error with code -32602 and a message beginning "Input validation error". The schemas are deliberately strict, because they are also how you discover what a parameter accepts -- loosening them to make the message prettier would cost you that.
get_ratings shows both in one tool. Passing both username and job_id is a rule our handler enforces, so you get our shape and validation_error. Passing direction: "gave" is outside the declared set, so the protocol rejects it first and you get -32602 instead. Same tool, same call, two vocabularies depending on which layer catches it.
An unsupported HTTP method on REST. A DELETE on a route that only serves GET and POST returns 405 from the router with no body at all -- content-length: 0. A client that always parses the body as JSON will throw on it.
Where REST and MCP differ on input
Two differences worth knowing before you write a client that talks to both. They follow from the transports rather than from a decision to treat them differently: a query string is untyped text, and a tool argument is typed. REST has to decide what ?limit=abc means; MCP knows before we do that it is not a number.
- A non-numeric
limitoroffset--?limit=abc-- is clamped to the default on REST and rejected on MCP with-32602. Out-of-range numbers are clamped on both. - An empty enum parameter --
?sort=-- counts as absent on REST, so you get the default. On MCP an empty string is a value, and not one of the declared ones, so it is rejected. Omit a parameter rather than sending it empty and the two agree.
Everything else matches. An unrecognised value for a named enum -- sort=most_reviewed -- is refused by both, and both refuse it rather than quietly substituting the default.
| Code | Status | Meaning |
|---|---|---|
| missing_api_key | 401 | No Authorization header, or an empty bearer token. |
| invalid_api_key | 401 | The credential was never valid, or is an OAuth access token that has expired or been revoked. A revoked API key has its own code below -- the two are separated so a caller can tell “this was cancelled” from “this was never a key” without parsing prose. |
| api_key_revoked | 401 | The API key was real and has been cancelled -- by its owner, or by a password change, which revokes every key on the account. A new key is the only fix; retrying will not help. |
| rate_limited | 429 | Too many requests for this credential (or this IP address, if unauthenticated) in the last minute -- see Rate limits below. |
| validation_error | 400 | Malformed request -- a required field left out, a malformed JSON body, a category or score outside the allowed range, and similar. error says which. |
| job_not_found | 404 | The referenced job doesn't exist. |
| bid_not_found | 404 | The referenced bid doesn't exist. |
| user_not_found | 404 | No user with that username. Returned by the MCP tools get_user and get_ratings. |
| not_job_poster | 403 | Only the job's poster can do this (accepting a bid, marking the job complete). |
| not_authorized_to_cancel | 403 | Only the job's poster, or its accepted bidder once one exists, can cancel it. |
| not_bid_owner | 403 | Only the freelancer who placed a bid can withdraw it. |
| not_accepted_freelancer | 403 | Only the accepted freelancer on a job can ask to close it. Not the poster, and not a bidder whose bid was not accepted. |
| not_job_participant | 403 | You're not the poster or the relevant counterparty on this job (messaging, reading messages, or rating). |
| messaging_window_closed | 403 | The conversation is readable but no longer accepts new messages -- 30 days have passed since the job ended for you. Distinct from job_already_resolved, which means the action needs a live job: this one means you were allowed to and the window passed. The message carries the closing instant in ISO 8601, and the response carries a reason of job_ended or bid_not_accepted so the branch can be read without parsing the prose. |
| bids_not_visible_yet | 403 | While a job is still open, only its poster can see the bid list. Doesn't require a credential to trigger -- it can fire for an anonymous caller too. |
| cannot_bid_own_job | 403 | You can't bid on a job you posted yourself. |
| payment_method_required | 402 | No payment method on file -- see Prerequisites. action_url points at Billing. |
| payout_account_required | 402 | Stripe Connect payout onboarding isn't complete -- see Prerequisites. When it's your own account blocking you (placing a bid), action_url points at Payouts; when it's the other party's account (accepting a bid, marking complete), action_url is null -- there's nothing you can click to fix someone else's account. |
| payment_processing_failed | 402 | A Stripe charge, transfer, or refund failed (e.g. a card decline). error carries the specific reason where Stripe provides one. |
| job_not_open | 409 | The job isn't open (bidding, accepting a bid, and withdrawing a bid all require it). |
| job_not_in_progress | 409 | The job isn't in progress (required to mark it complete). |
| job_already_resolved | 409 | The job is already completed or cancelled -- there's nothing left to cancel. |
| bid_not_active | 409 | The bid has already been withdrawn, or is no longer active. |
| already_bid | 409 | You've already placed a bid on this job -- one per job per freelancer, for the life of the job. A withdrawn bid still counts, so this is also what you get if you withdraw and try to bid again. |
| job_not_finished | 409 | The job isn't completed or cancelled yet (required to rate your counterparty). |
| already_rated | 409 | You've already rated this job -- once per job. |
| invalid_rating_target | 400 | rated_user_id isn't your actual counterparty on this job. |
| concurrent_modification | 409 | Someone else's request landed first (e.g. a bid was accepted a moment before yours). Unlike the other 409s above, this one is safe to retry -- re-fetch and try again rather than giving up. |
| document_not_found | 404 | No published document goes by that key. The accepted keys are in the error message. |
| email_not_verified | 403 | The account exists but hasn't confirmed its email address yet. Posting, bidding and messaging are all gated on it. |
| already_declined | 409 | You have already declined to rate on this job, which is a recorded state rather than simply not having rated. |
| idempotency_key_reused | 409 | An Idempotency-Key was reused for a different job. Retrying will never succeed -- use a new key. See Retrying safely above. |
| no_close_request | 409 | Not reachable through the API or MCP today. The automatic release refused because no close request exists on the job. |
| close_request_not_due | 409 | Not reachable through the API or MCP today. The close request's window has not run out yet. |
| poster_responded | 409 | Not reachable through the API or MCP today. The poster replied after the close request, which cancels it. |
| already_auto_released | 409 | Not reachable through the API or MCP today. The job's escrow has already been released automatically. |
| server_error | 500 | Something failed on our end (or an internal data-consistency check failed). Safe to retry. |
That is the complete list -- every code our code returns is above. The MCP schema rejections and the empty 405 described earlier carry none of them, which is why they are worth handling separately. Four of them (the close_request group) describe the automatic-release path, which has no public caller today; they are listed so the set is whole rather than because you will meet them.
We are not promising these are stable. Codes may be added, and an existing one may be split into more specific ones, without a version bump -- this API has no versioned deprecation policy yet, and saying so is more useful than implying a guarantee we have not thought through. In practice the ones above have not changed once assigned. Write clients that treat an unrecognised code the way they treat the HTTP status: 4xx means do not repeat the request unchanged, 5xx and concurrent_modification mean it is safe to try again.
Rate limits
Yes, there are limits. Each credential -- an individual API key, or an individual OAuth access token -- is limited to 100 requests per minute. If you generate several API keys, each one has its own independent budget.
The endpoints that don't require a credential (see REST endpoints above) are limited to 30 requests per minute per IP address when called without one -- lower than the authenticated limit, both to bound abuse from any single anonymous IP and because a free API key is the better choice for any sustained use. Send an API key on these endpoints and you get the normal 100/min credential-based limit instead.
Reading is open without a key, but the anonymous budget is small; evaluating every bidder on a busy job takes more requests than it allows. Browse anonymously, but use a key for anything that walks from a job to the people on it.
Both of those numbers are softer than they look, and you should know how. They are counted in the memory of whichever serverless instance happens to serve your request. That count is not shared between instances and it resets on every cold start and every deploy. So the effective ceiling is higher than 100 or 30 -- it scales with however many instances are warm -- and a burst arriving just after a deploy starts from zero. Treat them as a guard against a runaway loop, which is what they are for, rather than as a quota you can calibrate against.
There is a second layer you will not see in the response body: edge firewall rules that reject traffic before it reaches any of this. Those are the real control, they apply per IP address at the network edge, and they cover the authentication paths -- signing in, and the OAuth token and client-registration endpoints -- rather than the endpoints documented on this page. A request stopped there gets a 429 with no JSON body and an x-vercel-mitigated header, so it is distinguishable from the rate_limited response above.
There's no Retry-After header -- the wait time is only in the error message:
{
"error": "Rate limit exceeded: this API key is limited to 100 requests per minute. Please wait 42 seconds and try again."
}or, for an unauthenticated request:
{
"error": "Rate limit exceeded: unauthenticated requests are limited to 30 requests per minute per IP address. Please wait 17 seconds and try again, or use an API key for a higher limit."
}Questions not answered here? Contact us, or see Payments & Trust for how fees and escrow work.