API Reference

Programmatic access to your leads, products, and scans. Available on Pro and Agency plans.

Base URLhttps://leadsrover.io

Authentication

All requests require a Bearer token. Create API keys in Settings > API.

curl -H "Authorization: Bearer lr_sk_..." \
  https://leadsrover.io/api/v1/leads

Keys have scopes: read (default) for GET requests, and write for mutations. Max 5 keys per account, revokable instantly.

Response format

All responses use a JSON envelope.

Success

{
  "data": { ... }
}

Error

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Lead not found",
    "doc_url": "https://leadsrover.io/docs/api#errors"
  }
}

Paginated endpoints include a pagination object with total, limit, offset, and hasMore.

Rate limits

Read (GET)1,000 req / min
Write (POST, PATCH, DELETE)60 req / min

Exceeding the limit returns 429 with a RATE_LIMITED error code.

Errors

400BAD_REQUESTInvalid parameters
401UNAUTHORIZEDMissing or invalid API key
403FORBIDDENInsufficient scope or plan
403LIMIT_REACHEDAI generation quota exhausted
404NOT_FOUNDResource not found or not owned by you
409CONFLICTResource conflict (e.g. scan running, draft exists)
429RATE_LIMITEDToo many requests
500INTERNAL_ERRORServer error

OpenAPI & agents

A machine-readable OpenAPI 3.1 spec covering every endpoint is available at /api/v1/openapi.json. Plug it into a ChatGPT Action, Claude, or any agent framework to give your AI assistant direct access to your leads.

Pair it with the agent guide — workflow instructions written for AI agents (triage loop, feedback semantics, the DM draft lifecycle). Paste its URL or contents into your agent's system instructions.

# Endpoint schemas
curl https://leadsrover.io/api/v1/openapi.json

# Workflow instructions for your agent
curl https://leadsrover.io/api/v1/agent-guide
GET/api/v1/me

Returns your account info and current plan.

{
  "data": {
    "user": {
      "id": "abc123",
      "name": "Jane Doe",
      "email": "jane@example.com",
      "timezone": "America/New_York",
      "createdAt": "2026-01-15T10:30:00.000Z"
    },
    "plan": {
      "type": "pro",
      "status": "active",
      "currentPeriodEnd": "2026-04-15T10:30:00.000Z",
      "limits": {
        "maxProducts": 5,
        "scansPerMonth": -1,
        "aiCommentsPerMonth": 1000
      }
    }
  }
}
GET/api/v1/products

Lists all your products.

{
  "data": [
    {
      "id": 42,
      "name": "Acme CRM",
      "url": "https://acme.com",
      "pain": "Manual lead tracking is slow",
      "solution": "AI-powered CRM for small teams",
      "targetUsers": "Solo founders and small sales teams",
      "isActive": true,
      "createdAt": "2026-02-01T08:00:00.000Z",
      "updatedAt": "2026-03-20T14:30:00.000Z"
    }
  ]
}
GET/api/v1/products/:id

Returns a single product by ID. Same shape as the list endpoint.

POST/api/v1/products/:id/scan

Trigger an on-demand scan. Requires write scope. Scans run asynchronously.

Response (202)

{
  "data": {
    "productId": 42,
    "status": "scanning",
    "message": "Scan enqueued successfully"
  }
}
GET/api/v1/leads

Lists leads across all products with filtering and pagination.

productId
number
Filter by product ID
status
string
new (default) matches the in-app feed: fresh leads only, excluding dislikes. contacted = contact history. all = full history, for syncs.
minQuality
number
Minimum quality score (0-100)
since
ISO 8601
Only leads discovered after this date
include
string
Extra fields: content (full Reddit post body)
sort
string
Sort by: discoveredAt (default), qualityScore
order
string
Sort order: desc (default), asc
limit
number
Results per page (1-100, default 50)
offset
number
Pagination offset (default 0)

Example

curl -H "Authorization: Bearer lr_sk_..." \
  "https://leadsrover.io/api/v1/leads?minQuality=70&status=new&limit=10"

Response

{
  "data": [
    {
      "id": 1234,
      "productId": 42,
      "subreddit": "smallbusiness",
      "redditPostId": "t3_abc123",
      "title": "Need a CRM that doesn't cost a fortune",
      "authorUsername": "startup_jane",
      "postUrl": "https://reddit.com/r/smallbusiness/...",
      "qualityScore": 85,
      "intent": "Looking for affordable CRM for 3-person team",
      "upvotes": 12,
      "commentCount": 8,
      "postedAt": "2026-03-27T14:30:00.000Z",
      "discoveredAt": "2026-03-27T15:00:00.000Z",
      "status": "new",
      "contactedAt": null,
      "language": "en",
      "matchedKeyword": "affordable crm",
      "feedback": null,
      "dislikeReason": null
    }
  ],
  "pagination": {
    "total": 47,
    "limit": 10,
    "offset": 0,
    "hasMore": true
  }
}
GET/api/v1/leads/:id

Returns a single lead with its full post content. Same shape as the list endpoint, plus a content field with the full Reddit post body.

PATCH/api/v1/leads/:id

Update a lead's status. Requires write scope. Marking a lead contacted records contact history and rejects any queued (unsent) drafts for it.

Request

{
  "status": "contacted",
  "contactMethod": "dm"
}

Response

{
  "data": {
    "id": 1234,
    "status": "contacted"
  }
}

Valid statuses: new contacted. Optional contactMethod: dm comment

If you queued a DM via POST /leads/:id/draft, do not mark the lead contacted yourself — that happens automatically when the draft is sent.

DELETE/api/v1/leads/:id

Delete a lead (soft delete — it disappears from all listings). Requires write scope. Deleting is workflow cleanup, not quality feedback: to teach the scorer a lead was bad, submit a dislike first, then delete.

{
  "data": {
    "id": 1234,
    "deleted": true
  }
}

Deleting an already-deleted lead returns 404.

POST/api/v1/leads/:id/feedback

Like or dislike a lead. Requires write scope. Dislikes with a concrete reason feed the learning loop and improve future scoring for the product. Submitting again replaces previous feedback.

feedback
string
Required: like, dislike
reason
string
Required for dislikes: wrong_audience, seller_side, no_active_need, wrong_category, other
note
string
Optional context for the dislike (max 240 chars)

Request

{
  "feedback": "dislike",
  "reason": "seller_side",
  "note": "Poster is an agency pitching services"
}

Response

{
  "data": {
    "leadId": 1234,
    "feedback": "dislike",
    "dislikeReason": "seller_side",
    "note": "Poster is an agency pitching services"
  }
}
DELETE/api/v1/leads/:id/feedback

Clear your feedback on a lead. Idempotent — succeeds even when no feedback exists. Requires write scope.

{
  "data": {
    "leadId": 1234,
    "feedback": null
  }
}
POST/api/v1/leads/:id/generate-dm

Generate a DM for a lead using your saved reply settings (including custom instructions). Returns text only — it does not create a draft. Review or rewrite the text, then queue it via POST /leads/:id/draft. Counts against your AI generation quota. Requires write scope.

{
  "data": {
    "leadId": 1234,
    "dm": "Hey, saw your post about tracking leads manually...",
    "quota": { "used": 14, "limit": 1000 }
  }
}

Returns 403 LIMIT_REACHED when the generation quota is exhausted.

GET/api/v1/products/:id/leads

Lists leads for a specific product. Same filters as GET /leads except productId.

POST/api/v1/leads/:id/draft

Queue a DM with your own text. Requires write scope. The draft is created approved and your browser extension sends it on its next poll — no in-app review step. Queuing is the send decision, so make sure the text is final before calling this.

Draft lifecycle: approved (created) → posted or failed (extension send). Your browser with the LeadsRover extension must be running for the send to happen. A successful send marks the lead contacted automatically — never PATCH the lead to contacted while a draft is still approved, or the draft is rejected as stale.
content
string
Required: the DM text to send (max 10,000 chars)
dmSubject
string
Optional DM subject line (max 200 chars)

Request

{
  "content": "Hey, saw your post about...",
  "dmSubject": "About your CRM search"
}

Response (201)

{
  "data": {
    "id": 567,
    "leadId": 1234,
    "status": "approved",
    "draftType": "dm",
    "dmRecipient": "startup_jane",
    ...
  }
}

One draft per lead — returns 409 CONFLICT if a draft already exists (any status), the lead was already contacted, or the author cannot receive DMs.

GET/api/v1/drafts

Lists your DM drafts. Use it to track drafts you created through the lifecycle: approved → posted or failed.

status
string
Filter: pending, approved, rejected, posted, failed
productId
number
Filter by product ID
leadId
number
Filter by lead ID
limit
number
Results per page (1-100, default 50)
offset
number
Pagination offset (default 0)
{
  "data": [
    {
      "id": 567,
      "productId": 42,
      "leadId": 1234,
      "status": "posted",
      "draftType": "dm",
      "generatedContent": "Hey, saw your post about...",
      "editedContent": null,
      "dmSubject": "About your CRM search",
      "dmRecipient": "startup_jane",
      "generatedAt": "2026-06-11T10:00:00.000Z",
      "reviewedAt": "2026-06-11T12:00:00.000Z",
      "postedAt": "2026-06-11T12:05:00.000Z",
      "createdAt": "2026-06-11T10:00:00.000Z",
      "errorMessage": null
    }
  ],
  "pagination": { "total": 3, "limit": 50, "offset": 0, "hasMore": false }
}

Conversations

The inbox: once a lead replies to a DM, the thread becomes a conversation you can triage and answer. Designed so an agent can run the inbox: list what needs a reply, read the thread, send an answer.

GET/api/v1/conversations

Lists conversations for your account. filter=needs_reply (the default) is the daily queue: threads where the lead is waiting on a human reply.

filter
string
needs_reply (default), awaiting_reply, converted, archived, all
limit
number
Results per page (1-100, default 50)
offset
number
Pagination offset (default 0)
{
  "data": [
    {
      "id": 88,
      "recipientUsername": "startup_jane",
      "recipientRedditId": "t2_abc123",
      "leadId": 1234,
      "productName": "Acme CRM",
      "replyStateKey": "needs_you",
      "replyStateLabel": "Needs you",
      "replyStateExplanation": "They asked a question about pricing.",
      "lastMessage": {
        "body": "How much is the team plan?",
        "isFromUs": false,
        "sentAt": "2026-06-13T09:30:00.000Z"
      },
      "lastMessageAt": "2026-06-13T09:30:00.000Z",
      "convertedAt": null,
      "archivedAt": null,
      "snoozedUntil": null,
      "sendable": true,
      "queuedReply": null
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0, "hasMore": false }
}
GET/api/v1/conversations/:id

Full message history for one conversation, oldest first, plus the lead — the Reddit post that triggered it, including the full post body in content. That post is precious context for replying. Read it before answering. isFromUs tells you which side sent each message; lead is null if the conversation isn't tied to a lead.

{
  "data": {
    "conversation": {
      "id": 88,
      "recipientUsername": "startup_jane",
      "leadId": 1234,
      "sendable": true,
      "queuedReply": {
        "message": "The team plan is $49/mo for 5 seats.",
        "status": "pending"
      },
      ...
    },
    "lead": {
      "id": 1234,
      "subreddit": "startups",
      "title": "How did you get your first 100 customers?",
      "content": "I launched 3 months ago and I'm stuck at zero...",
      "postUrl": "https://reddit.com/r/startups/comments/...",
      "qualityScore": 72,
      "intent": "...",
      ...
    },
    "messages": [
      {
        "id": 1,
        "body": "Hey, saw your post about CRMs...",
        "mediaUrl": null,
        "isFromUs": true,
        "sentAt": "2026-06-12T18:00:00.000Z",
        "source": "manual"
      },
      {
        "id": 2,
        "body": "How much is the team plan?",
        "mediaUrl": null,
        "isFromUs": false,
        "sentAt": "2026-06-13T09:30:00.000Z",
        "source": null
      }
    ]
  }
}
POST/api/v1/conversations/:id/reply

Send a reply. Requires write scope. Queuing is the send decision — there is no second review step, so make sure the text is final and your user has confirmed it.

How it sends. The reply is queued and your browser extension sends it on its next poll (~5 min), so you get a 202 and it only goes out if the extension is installed, online, and logged into the right Reddit account.
message
string
Required: the reply text to send

Request

{
  "message": "The team plan is $49/mo for 5 seats."
}

Response (202 queued)

{
  "data": {
    "conversationId": 88,
    "queued": true
  }
}

Returns 409 CONFLICT if the chat room is not ready or a reply is already queued for the conversation. While queued (and not yet confirmed sent), the reply is visible as queuedReply on the conversation and can be cancelled with PATCH { "cancelQueuedReply": true }.

PATCH/api/v1/conversations/:id

Update a conversation's state. Requires write scope. Archive a dead end (declined, not a fit, can't convert) to drop it out of the inbox — archived conversations no longer appear in the needs_reply queue.

archived
boolean
true to archive (leaves the inbox), false to unarchive
converted
boolean
Mark the conversation as a won/converted lead
snoozedUntil
string
Future ISO 8601 timestamp to snooze until, or null to clear
cancelQueuedReply
boolean
true cancels a queued-but-unsent reply while keeping the conversation active. Archiving or snoozing also cancels it. A queuedReply of null in the response confirms nothing is queued anymore.
{ "archived": true }