> ## Documentation Index
> Fetch the complete documentation index at: https://productlane.mintlify.site/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating from API v1 to v2

> What changed and how to update your integration. v1 sunsets on November 20, 2026.

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

| Area         | v1                                                           | v2                                                                           |
| ------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| Base URL     | `https://productlane.com/api/v1`                             | `https://productlane.com/api/v2`                                             |
| API keys     | `pl_v1_*` (Unkey)                                            | `pl_v2_*` (mint a new one - secrets shown once)                              |
| Workspace id | In path on some endpoints                                    | Always inferred from key                                                     |
| Field naming | Mixed camel/snake                                            | `snake_case` everywhere                                                      |
| Dates        | superjson-wrapped `Date`                                     | ISO 8601 strings                                                             |
| Thread state | `state`: `NEW`/`PROCESSED`/`COMPLETED`/`SNOOZED`/`UNSNOOZED` | `status`: `open`/`snoozed`/`done` + read-only `tab`                          |
| Pagination   | Mixed (offset / bare arrays)                                 | Cursor on every list: `{ data, page: { cursor, has_more, limit } }`          |
| Errors       | Raw tRPC shape                                               | `{ error: { code, message, details?, request_id } }` + `X-Request-Id` header |
| Webhooks     | None                                                         | HMAC-signed deliveries, 6 retries                                            |
| Scopes       | Full access                                                  | Per-key scopes (`threads:read`, `admin`, …)                                  |
| Rate limits  | None                                                         | 1000 reads/min, 60 writes/min, headers exposed                               |

## Thread status mapping

| v1 `state`  | v2 `status` | Notes                                                                          |
| ----------- | ----------- | ------------------------------------------------------------------------------ |
| `NEW`       | `open`      | Setting `open` on a `NEW` thread keeps it `NEW` (inbox "new" tab stays intact) |
| `UNSNOOZED` | `open`      |                                                                                |
| `SNOOZED`   | `snoozed`   | `snoozed_until` is required                                                    |
| `PROCESSED` | `done`      |                                                                                |
| `COMPLETED` | `done`      | Send `closed_loop: true` to mark as COMPLETED                                  |

## Endpoint changes

| v1                                              | v2                                                           |
| ----------------------------------------------- | ------------------------------------------------------------ |
| `GET /threads/{id}?includeConversation=true`    | `GET /threads/{id}?expand=messages,comments`                 |
| n/a                                             | `GET /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/a                                             | `GET /me`, `GET /members`, `GET /portal/*`, `POST /webhooks` |

## Concrete before/after

```diff theme={null}
# 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

```json theme={null}
{
  "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:

```md theme={null}
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.
```
