Skip to main content

Documentation Index

Fetch the complete documentation index at: https://productlane.mintlify.dev/docs/llms.txt

Use this file to discover all available pages before exploring further.

v1 keeps working until November 20, 2026. After that, only /api/v2 responds. If you’re integrating for the first time, skip to v2.

What changed

Areav1v2
Base URLhttps://productlane.com/api/v1https://productlane.com/api/v2
API keyspl_v1_* (Unkey)pl_v2_* (mint a new one - secrets shown once)
Workspace idIn path on some endpointsAlways inferred from key
Field namingMixed camel/snakesnake_case everywhere
Datessuperjson-wrapped DateISO 8601 strings
Thread statestate: NEW/PROCESSED/COMPLETED/SNOOZED/UNSNOOZEDstatus: open/snoozed/done + read-only tab
PaginationMixed (offset / bare arrays)Cursor on every list: { data, page: { cursor, has_more, limit } }
ErrorsRaw tRPC shape{ error: { code, message, details?, request_id } } + X-Request-Id header
WebhooksNoneHMAC-signed deliveries, 6 retries
ScopesFull accessPer-key scopes (threads:read, admin, …)
Rate limitsNone1000 reads/min, 60 writes/min, headers exposed

Thread status mapping

v1 statev2 statusNotes
NEWopenSetting open on a NEW thread keeps it NEW (inbox “new” tab stays intact)
UNSNOOZEDopen
SNOOZEDsnoozedsnoozed_until is required
PROCESSEDdone
COMPLETEDdoneSend closed_loop: true to mark as COMPLETED

Endpoint changes

v1v2
GET /threads/{id}?includeConversation=trueGET /threads/{id}?expand=messages,comments
n/aGET /threads/{id}/messages, GET /threads/{id}/comments
GET /contacts/{idOrEmail}GET /contacts/{id} or GET /contacts/by-email?email=…
GET /companies/by-external-id/{wksId}/{extId}GET /companies?external_id=…
GET /changelogs/{workspaceId}GET /changelogs
GET /docs/articles/{workspaceId}GET /docs/articles
n/aGET /me, GET /members, GET /portal/*, POST /webhooks

Concrete before/after

# Create a thread
- POST /api/v1/threads
- { "painLevel": "HIGH", "contactEmail": "a@b.com", "state": "NEW" }
+ POST /api/v2/threads
+ { "pain_level": "HIGH", "contact_email": "a@b.com", "status": "open" }

# List threads
- GET /api/v1/threads?workspaceId=wks_abc&state=NEW&take=50&skip=0
+ GET /api/v2/threads?status=open&tab=new&limit=50

# Snooze
- PATCH /api/v1/threads/thr_123  { "state": "SNOOZED", "snoozedUntil": "..." }
+ PATCH /api/v2/threads/thr_123  { "status": "snoozed", "snoozed_until": "..." }

# Contact by email
- GET /api/v1/contacts/a@b.com
+ GET /api/v2/contacts/by-email?email=a@b.com

# Company by external_id
- GET /api/v1/companies/by-external-id/wks_abc/crm-1
+ GET /api/v2/companies?external_id=crm-1

Removed thread fields

Gone from responses (they leaked internals): version, yDocText, linearAttachmentId, recordingId, slackReplyId, chatThreadId, replied, slaDeadlineAt, showRecordCall, contactIsUnverified, isDeleted, workspace, workspace.slackSettings. Per-integration ids (intercomId, frontId, …) are now under external_ids.

Error envelope

{
  "error": {
    "code": "validation_failed",
    "message": "Validation failed: title is required.",
    "details": [{ "path": "title", "message": "Required" }],
    "request_id": "req_3727ca7b4f0fff3c"
  }
}
Common codes: validation_failed (400), unauthenticated (401), scope_required (403), not_found (404), conflict (409), unsupported_key_version (410 - passed a v1 key), rate_limited (429), internal_error (500).

What did not change

Bearer token auth, pain levels (HIGH/MEDIUM/LOW/UNKNOWN), JSON bodies, HTTP status semantics.

Migrate with AI

Paste this prompt into Claude/Cursor/Copilot to apply the migration to your codebase:
You are migrating a codebase from Productlane API v1 to v2.

# Scope

Find every call to `https://productlane.com/api/v1` (or any path starting with `/api/v1`) and rewrite it for v2. Touch only request construction and response parsing - don't refactor surrounding business logic.

# Rules

1. **Base URL**: `/api/v1``/api/v2`.
2. **API key**: replace any `pl_v1_*` key references with the env var the project uses for the v2 key (ask if unclear; do not invent one). If a v1 key is hardcoded, leave a `TODO` comment.
3. **Drop `workspaceId` from paths.** Examples:
   - `GET /changelogs/{workspaceId}``GET /changelogs`
   - `GET /docs/articles/{workspaceId}``GET /docs/articles`
   - `GET /companies/by-external-id/{workspaceId}/{externalId}``GET /companies?external_id={externalId}`
4. **Field casing**: convert every request body field and every response field read from v2 endpoints to `snake_case` (`painLevel``pain_level`, `contactEmail``contact_email`, `lastInboundMessageAt``last_inbound_message_at`, `imageUrl``image_url`, etc.).
5. **Dates**: remove any superjson `Date` wrapping - v2 sends/receives ISO 8601 strings. Replace `{ __type: "Date", value: ... }` with the string.
6. **Thread `state``status`** with this mapping when writing:
   - `NEW`/`UNSNOOZED``"open"`
   - `SNOOZED``"snoozed"` (must include `snoozed_until` ISO 8601 string)
   - `PROCESSED``"done"`
   - `COMPLETED``"done"` + `closed_loop: true`
     When reading, map `tab`/`status` back to whatever internal representation the caller uses. Don't read `state` from v2 responses - it isn't there.
7. **Pagination**: rewrite list-call loops.
   - v1 shape `{ items, nextPage, hasMore, count }` (or bare array for changelogs) → v2 shape `{ data, page: { cursor, has_more, limit } }`.
   - Loop until `page.has_more === false`, passing `?cursor=<previous page.cursor>` on each next call. Drop `skip`/`take`; use `limit` (default 50, max 200).
8. **Single thread + conversation**: `GET /threads/{id}?includeConversation=true``GET /threads/{id}?expand=messages,comments`. If the call only needs the thread, drop the param entirely.
9. **Get-thread doesn't include messages/comments by default.** If code was relying on them being there without `includeConversation`, add the `expand=messages,comments` param OR switch to dedicated `GET /threads/{id}/messages` / `GET /threads/{id}/comments` (paginated).
10. **Contact lookup**: `GET /contacts/{idOrEmail}` splits.
    - If the value looks like an email (contains `@`) → `GET /contacts/by-email?email={value}`.
    - Otherwise → `GET /contacts/{id}` unchanged.
11. **Company external_ids/domains** on PATCH replace the full array. If code intends to append, fetch first, append, then PATCH.
12. **Contact create/update**: at most one of `company_id`, `company_name`, `company_external_id`. If the v1 call passes more than one, keep `company_id` (or whichever the existing code seems to prefer) and drop the others.
13. **Removed thread fields**: stop reading any of these from v2 responses: `version`, `yDocText`, `linearAttachmentId`, `recordingId`, `slackReplyId`, `chatThreadId`, `replied`, `slaDeadlineAt`, `showRecordCall`, `contactIsUnverified`, `isDeleted`, `workspace`, `workspace.slackSettings`. Replace per-integration ids (`intercomId`, `frontId`, `hubspotId`, `plainId`, `zendeskId`, `productboardId`, `slackChannelId`) with `external_ids.{intercom,front,hubspot,plain,zendesk,productboard,slack_channel}`.
14. **Error handling**: every v2 error body has shape `{ error: { code, message, details?, request_id } }`. If existing code parses tRPC-style errors, replace with `body.error.code` / `body.error.message`. Add the `X-Request-Id` header to any log lines that log the error.
15. **Rate limits**: where existing code retries, respect `Retry-After` on `429 rate_limited`. If there's no retry logic, don't add it - leave a `TODO` comment instead.
16. **Don't add features**: no new webhook handlers, no new scope checks, no new endpoints. Only migrate calls that exist.

# Process

1. Run a search for `/api/v1`, `pl_v1_`, `painLevel`, `state: "NEW"`, `state: "SNOOZED"`, `state: "PROCESSED"`, `state: "COMPLETED"`, `state: "UNSNOOZED"`, `includeConversation`, `nextPage`, and `superjson` to find all call sites.
2. For each site, apply the rules above. Keep changes minimal and local.
3. Where the v1 behavior was ambiguous (e.g. callers reading removed fields), leave a `TODO(productlane-v2): …` comment explaining what manual decision is needed. Don't guess.
4. After changes, re-run the search above to confirm nothing v1-shaped remains.
5. Print a short summary: files changed, call sites migrated, TODOs left.

Start now.