---
title: "Link device to the current session's account (device-signed + session)"
method: POST
path: "/api/v2/account/link/attach"
tags: ["v2 Account"]
---

# Link device to the current session's account (device-signed + session)

`POST /api/v2/account/link/attach`

## Request

- Base URL: `https://pdf2md.dev`
- URL: `https://pdf2md.dev/api/v2/account/link/attach`
- Auth: HTTP bearer

Device-signed (ES256) AND session-authenticated (cookie). Links the VERIFIED device to the account of the CURRENT web session directly (no OAuth round-trip) and migrates the device's anonymous jobs to that account. Idempotent. The web app calls this on /app/ load in account mode to self-heal a "session present but device unlinked" desync (a sign-in that skipped the device-link ticket, or a checkout-intent session) — otherwise device-signed job creation silently lands on the anonymous device and never appears in the account-scoped list. Both proofs are required: the device signature proves the device; the session proves the account.

## Response `200`

Linked

- V2AccountStatus: EP-023-T1: device-to-account link status (device-signed GET/POST /api/v2/account*).
  - `linked` boolean, required: true — the device is linked to an account
  - `account_id` string, uuid
  - `email` string
  - `display_name` string, nullable
  - `avatar_url` string, nullable
  - `tier` 'anonymous' | 'free' | 'lifetime' | 'builder' | 'pro' | 'business' | 'enterprise': EP-069: `lifetime` is the one-time unlock, ranked BETWEEN free and builder — it raises the interactive limits and carries 600 pages/month, but grants no API keys, hosted MCP, webhooks or batch, so a subscription is genuinely more and its holder may still buy one.
  - `affected_jobs` integer: How many jobs were re-attached/returned on link/unlink
  - `limits` V2Limits: Live actor entitlements (resolved from the subscription, §7.3)
    - `tier` 'anonymous' | 'free' | 'lifetime' | 'builder' | 'pro' | 'business' | 'enterprise': EP-069: `lifetime` is the one-time unlock, ranked BETWEEN free and builder — it raises the interactive limits and carries 600 pages/month, but grants no API keys, hosted MCP, webhooks or batch, so a subscription is genuinely more and its holder may still buy one.
    - `max_active_slots` integer
    - `max_concurrent_conversions` integer
    - `max_file_size_bytes` integer
    - `processing_soft_budget_sec` integer: The tier's visible "time limit": the engine runs up to it and returns a partial result + the tier disclaimer (truncated=true), rather than a rejection. Invariant: < processing_timeout_sec. Shown on pricing and in the disclaimer.
    - `processing_timeout_sec` integer: The worker's internal hard-backstop (= soft + ~5 min); not a marketing limit.
    - `ready_ttl_sec` integer
    - `priority_base` integer: §7.7, step-20 scale (anonymous=100)
    - `pool_eligibility` 'free_only' | 'free_and_paid'
    - `monthly_pages_soft_limit` integer, nullable: EP-069: the monthly page allowance. SOFT on subscription tiers (past it the actor drops to the free pool for the rest of the cycle, never refused). HARD on anonymous (150), free (400) and lifetime (600) — those are refused with 409 quota_exceeded. Resolved for THIS actor: an API-key caller does not get the lifetime unlock (EP-070-T6).
    - `monthly_pages_used` integer: EP-070-T7: pages converted this UTC calendar month. Present here as well as on /api/v2/limits because the extension cannot reach that path (it needs an API key or a session cookie, and a cross-site extension fetch has neither).
    - `webhooks_enabled` boolean
    - `slots_in_use` integer: slots occupied right now
    - `rate_limits` object: per-class: api_requests_per_min, job_creates_per_min, upload_bytes_per_hour, download_requests_per_min, mcp_tool_calls_per_min
  - `slots_in_use` integer, nullable: EP-025-T2 (additive): active jobs currently owned by the linked account.
  - `subscription` object, nullable: EP-025-T5 (additive): subscription status/period for the extension Account & Plan section. Omitted when the device is anonymous and has neither a subscription nor a live lifetime unlock.
    - `status` 'free' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'paused'
    - `cancel_at_period_end` boolean
    - `current_period_end` string, date-time, nullable
    - `trial_end` string, date-time, nullable
    - `lifetime` boolean: EP-069-T13: the account holds a LIVE one-time unlock. Server-computed so four client surfaces do not each infer it and disagree.
    - `manageable` boolean: EP-069-T13/EP-070-T5: there is something the provider portal can change or cancel. FALSE for an account whose only purchase is the unlock — it has no schedule and cannot lapse, so offering "cancel" there is a support ticket at best. Built by ONE server-side builder shared by /me and /account: the two used to disagree, and the endpoint the extension reads never set it, so every recurring subscriber lost their Manage billing button.

## Other responses

- `401`: Missing/invalid API key or session

---

[API](https://skmtc.dev/pdf2md/apis/pdf-to-markdown-api-facade.md) · [All operations](https://skmtc.dev/pdf2md/apis/pdf-to-markdown-api-facade/llms.txt) · [OpenAPI document](https://skmtc.dev/pdf2md/apis/pdf-to-markdown-api-facade/revisions/4636782fb8b6?raw)
