API — Leads, Offers & Bids
The core marketplace. Sell leads carry Offers; buy leads carry BuyBids. The buy-lead endpoints mirror the sell-lead ones exactly.
Sell leads
| Method | Path | Gating | Notes |
|---|---|---|---|
| GET | /sell-leads | requireAuth | Browse ACTIVE leads. Filters: productCategory, subProduct, state, district, trustTier, sort, page, limit. |
| POST | /sell-leads | requireActiveUser + sellQuota | Create. Returns hints.{belowSalePrice, alreadyListed}. |
| GET | /sell-leads/:id | requireAuth | Detail + offers; identity masking for Light Users. |
| PATCH | /sell-leads/:id | requireActiveUser + ownership + sellQuota | Edit once (editedOnce=true); second edit → 409. |
| DELETE | /sell-leads/:id | requireActiveUser + ownership + sellQuota | Soft close. |
| POST | /sell-leads/:id/view | requireAuth (Active) | Record a deduped LeadView; consumes 1 view quota. Idempotent. |
| POST | /sell-leads/:id/offers | requireActiveUser + engagementQuota + mustHaveViewed | Create offer. |
| POST | /sell-leads/:id/book | requireActiveUser + engagementQuota | Wraps view + offer in one call. |
| POST | /sell-leads/:id/offers/:offerId/counter | ownership(lead) | Owner counters a PENDING offer (48h timer). |
| POST | /sell-leads/:id/offers/:offerId/respond | ownership(offer) | Offeror responds to a COUNTERED offer: { decision: "accept"|"reject" }. |
| POST | /sell-leads/:id/offers/:offerId/accept | ownership(lead) | Owner accepts (partial multi-accept). |
| POST | /sell-leads/:id/offers/:offerId/reject | ownership(lead) | { reason? }. |
| POST | /sell-leads/:id/offers/:offerId/withdraw | ownership(offer) | Offeror withdraws PENDING or COUNTERED. |
| POST / DELETE | /sell-leads/:id/shortlist | requireAuth | Add / remove shortlist (idempotent). |
Buy leads
Same shape, with bids instead of offers:
| Method | Path | Gating |
|---|---|---|
| GET | /buy-leads | requireAuth |
| POST | /buy-leads | requireActiveUser + buyQuota |
| GET | /buy-leads/:id | requireAuth (buyer identity masked from non-owners) |
| PATCH / DELETE | /buy-leads/:id | requireActiveUser + ownership + buyQuota |
| POST | /buy-leads/:id/view | requireAuth (Active) |
| POST | /buy-leads/:id/bids | requireActiveUser + engagementQuota + mustHaveViewed — sellerBatchId required (a bid names the seller's lot) |
| POST | /buy-leads/:id/book | requireActiveUser + engagementQuota |
| POST | /buy-leads/:id/bids/:bidId/{counter,respond,accept,reject,withdraw} | as sell side |
| POST / DELETE | /buy-leads/:id/shortlist | requireAuth |
Key business rules
Engagement gate (must view first)
POST /:id/offers and /:id/bids reject with 422 MUST_VIEW_FIRST unless a LeadView row exists for (userId, leadId). The natural UI flow (Open lead → detail → Make Offer) records the view; /book and Buy Now auto-record it.
Offer / bid state machine
PENDING → ACCEPTED | REJECTED | COUNTERED | WITHDRAWN; COUNTERED → ACCEPTED | REJECTED (or expiry) | WITHDRAWN. No counter-of-counter — an owner counters each engagement once. A COUNTERED engagement past its 48h counterValidUntil is auto-rejected by the hourly expire-counters job.
Partial multi-acceptance
Max 2 acceptances per lead; total quantityAccepted ≤ quantityKg. The lead auto-closes when either cap is hit and auto-rejects the remaining PENDING/COUNTERED engagements.
Contact sharing
On ACCEPTED, both parties get the counterparty's phone (in-app + SMS) and an Order is materialised. Phone numbers never appear in list/browse responses.
Identity visibility
- Light Users see product, quantity, price, location — not names/phones/roles.
- On sell leads, an Active User sees the seller name/role only if they are the seller or have a prior ACCEPTED deal.
- On buy leads, the buyer's identity stays hidden until the caller's own bid is ACCEPTED.
Quota costs
| Action | Axis |
|---|---|
| Post / edit / delete own lead | Post (sellLeadsUsed / buyLeadsUsed) |
Open someone else's lead (/view) | View (deduped lifetime) |
| Place offer / bid | Engagement (named after the lead's polarity) |
| Counter / respond / withdraw / accept / reject | none |
