v1 keeps working until November 20, 2026. After that, only /api/v2 responds.
If you're integrating for the first time, skip to v2.
Area | v1 | v2 |
|---|---|---|
Base URL |
|
|
API keys |
|
|
Workspace id | In path on some endpoints | Always inferred from key |
Field naming | Mixed camel/snake |
|
Dates | superjson-wrapped | ISO 8601 strings |
Thread state |
|
|
Pagination | Mixed (offset / bare arrays) | Cursor on every list: |
Errors | Raw tRPC shape |
|
Webhooks | None | HMAC-signed deliveries, 6 retries |
Scopes | Full access | Per-key scopes ( |
Rate limits | None | 1000 reads/min, 60 writes/min, headers exposed |
v1 | v2 | Notes |
|---|---|---|
|
| Setting |
|
| |
|
|
|
|
| |
|
| Send |
v1 | v2 |
|---|---|
|
|
n/a |
|
|
|
|
|
|
|
|
|
n/a |
|
# Create a thread
- POST /api/v1/threads
- { "painLevel": "HIGH", "contactEmail": "[email protected]", "state": "NEW" }
+ POST /api/v2/threads
+ { "pain_level": "HIGH", "contact_email": "[email protected]", "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/[email protected]
+ GET /api/v2/contacts/[email protected]
# Company by external_id
- GET /api/v1/companies/by-external-id/wks_abc/crm-1
+ GET /api/v2/companies?external_id=crm-1
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 integration_ids. external_ids is a caller-owned list you set yourself, matching companies.
{
"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).
Bearer token auth, pain levels (HIGH/MEDIUM/LOW/UNKNOWN), JSON bodies, HTTP status semantics.
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. **external_ids and company domains** on PATCH replace the full array. `external_ids` is writable on companies, threads and contacts. 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 `integration_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.