# Madnion LinkedIn account health and safe recovery

Updated 22 October 2026. No campaign activation is implied by account recovery.

## Health, permissions and membership are separate

`getAccountStatus` now includes `permissions` at the top level and in `data`.
`getCapabilitiesV2` includes it in `data`. `permissions.granted_actions` reports
the enabled Madnion app's action grants; `configured_actions` additionally applies
the selected account/customer's write authorization and protection holds.
`missing_actions` means unsupported-by-grant Madnion actions, not missing LinkedIn
OAuth scopes. `provider_scopes.reported` is false, with granted/missing null.

`permissions.access.messaging`, `invitations`, `inbox` and `discovery` contain
`required_actions`, `app_granted`, `configured` and explicit `blockers`.
`provider_access_verified` remains null: configuration is not proof of permission
to reach a particular recipient, message, company page or subscription product.
Use live `products_connection_status` for observed product session state; missing
product fields do not establish subscription ineligibility. Health `unknown`
means failed/invalid live lookup, not a missing Madnion permissions array.

Service requests still require the active bound owner, their active customer
membership and explicit app/account/customer authorization. A 403 now includes
`code: bound_owner_inactive` or `bound_owner_membership_inactive` as appropriate.
Do not label either refusal as a LinkedIn disconnect. Restore membership through
the app's authorized membership sync, not by changing the account owner.

Scheduled incremental `app-workspace-sync` calls should use `members_mode: merge`.
Omitting a workspace's `members` now updates metadata only. Explicit `members: []`
in replace mode still removes all members; explicit revocations stay effective.
Invalid rosters or failed member upserts cannot silently prune existing members.

For Sales Navigator import pagination and saved-list links, see
[native V2 search examples](/linkedin-v2-search.md).

## Authentication and scope

POST `https://api.madnion.com/functions/v1/custom-proxy` with
`Content-Type: application/json` and `slug: "madnion-linkedin"`.
Use `Authorization: Bearer <OWNER_MADNION_SSO_ACCESS_TOKEN>` for owner operations.
Fresh and refreshed SSO access tokens use Madnion's configured identity-service
URL as issuer; legacy `advisorpro-sso` access tokens remain accepted. Signature,
HS256 algorithm, access-token type, expiry, app audience and subject are verified;
refresh tokens and OIDC ID tokens are not owner API credentials.
Backend reads and assisted link creation may instead use
`x-service-account-key: <APP_SERVICE_KEY>` with the `integrations` scope.
Never expose this key in a browser. Do not supply `central_user_id` to impersonate
an owner. Every call checks the app, assigned credential, customer-workspace
authorization and active owner membership. `app_workspace_id` is the external
customer-workspace key, not an operator workspace UUID.

## Authoritative health

Use `getAccountStatus` for a scoped, live account lookup. Discovery now returns the
same health fields per authorized account. `getCapabilitiesV2` includes both live
health and configuration; its `configured` actions do not prove product access.
`getCapacityV2` includes live health; quotas never authorize sending by themselves.

```json
{
  "slug": "madnion-linkedin",
  "action": "getAccountStatus",
  "app_id": "YOUR_APP_ID",
  "app_workspace_id": "YOUR_CUSTOMER_WORKSPACE_KEY",
  "key_name": "KEY_NAME_FROM_DISCOVERY",
  "account_id": "acc_EXISTING"
}
```

Account status results have `account_id`, `binding_status`, `request_id`, live
health fields and a nested `health` object. `data` repeats the health fields for
clients that consume the data envelope. `binding_status` is historical Madnion
state, not proof of current connectivity. Discovery retains missing bindings so
apps can show recovery/history, but never labels those accounts connected.

| Field | Meaning |
| --- | --- |
| `status`, `connection_status` | `running`, `partial`, `degraded`, `disconnected`, `errored`, `missing`, or `unknown` |
| `provider_account_exists` | `true` for a verified account response; `false` only for a scoped account 404/410; otherwise `null` |
| `connected` | At least one product is operational and no reported lock; partial/degraded can be true |
| `healthy` | All configured products running and explicitly unlocked; not an endpoint-permission guarantee |
| `products_connection_status` | Provider-reported product map: `running`, `disconnected`, `errored`; absent means unknown, not zero/not subscribed |
| `reconnect_required` | Authentication required, including a missing record needing recovery; null when health is unknown |
| `same_account_reconnect_available` | In-place reconnect: false for a missing record; true for an existing disconnected/partial unlocked account; null when unknown |
| `deleted_account_recovery_available` | Explicit signed-owner replacement recovery can be offered for a confirmed missing binding with exact member identity evidence |
| `reason`, `recovery_action` | Machine-readable explanation and next step |
| `checked_at`, `health_source` | Observation time and live evidence source |

### Missing connection

`getAccountStatus` retains HTTP 404/410. Capabilities/capacity return HTTP 404 when
their account lookup confirms absence. The error is `provider_account_missing`:

```json
{
  "success": false,
  "error": "provider_account_missing",
  "status": "missing",
  "connection_status": "missing",
  "provider_account_exists": false,
  "connected": false,
  "healthy": false,
  "reconnect_required": true,
  "same_account_reconnect_available": false,
  "reason": "provider_account_missing",
  "recovery_action": "owner_verified_relink",
  "deleted_account_recovery_available": true,
  "retry_allowed": false
}
```

This means the connection record is unavailable under its assigned credential;
it does not mean the LinkedIn profile itself was deleted. Do not fall back to a
fresh connection, drop `account_id`, delete history, or retry a new-account call.
For an eligible binding, use the explicit owner-consented recovery below. It
creates a new provider ID while preserving the historical record and ownership;
it never pretends the deleted provider ID has returned. Missing exact identity
evidence or a conflicting existing connection still fails closed.

Only a scoped ACCOUNT lookup can establish absence. Missing posts, chats or
attachments do not disconnect the account. Confirmed absence reconciles the V2
binding and exact legacy mapping without deleting history, ownership or holds.
Legacy account discovery/status use the exact mapped V2 account's live health
when available; `health_source: "live_mapped_v2"` identifies that evidence.

### Other failures and product health

Timeouts, invalid upstream credentials, 403, 429, malformed responses and 5xx yield
`unknown`, nullable connectivity/existence, and `provider_health_unavailable`.
They do not establish deletion. Live checks have a five-second provider budget;
discovery uses bounded fanout. Status lookups do not fall back to cached success.

`partial` means at least one product needs authentication while another works.
`degraded`/`errored` indicate service interruption, not necessarily expired
credentials. Inspect the product map rather than declaring every product offline.
No automatic distinction between Sales Navigator subscription absence, permission
revocation and session expiry is promised: use explicit product error evidence
when returned; otherwise display "unavailable — reason unconfirmed". An owner SSO
401 is distinct from an upstream health-check credential failure.

## Same-account reconnect

Require grants `createConnectionLinkV2`, `completeAccountV2`, `getAccountStatus`
and `listAccountsV2`; the connection actions also require app `allow_writes`.
These do not grant campaign sending. A backend additionally needs explicit account
read authorization for this customer workspace; service write permission is not
needed to prepare a recovery link. Completion always requires the signed owner.

```json
{
  "slug": "madnion-linkedin",
  "action": "createConnectionLinkV2",
  "app_id": "YOUR_APP_ID",
  "app_workspace_id": "YOUR_CUSTOMER_WORKSPACE_KEY",
  "key_name": "KEY_NAME_FROM_DISCOVERY",
  "account_id": "acc_EXISTING",
  "callback_url": "https://your-app.example/linkedin/callback"
}
```

For backend-assisted link creation add `owner_user_id` containing the intended
owner's active Madnion central user ID. An administrator must not use their own
LinkedIn login. Deliver the link securely to the consenting owner; do not log it.

A successful link response is:

```json
{
  "url": "https://connect.madnion.com/...",
  "expiresAt": "<ISO timestamp>",
  "mode": "reconnect",
  "account_id": "acc_EXISTING",
  "completion_requires_owner": true
}
```

Before allocating a link or state, Madnion checks the existing account. A missing
record returns HTTP **409**, `provider_account_missing`, the same missing health,
and no `url` unless the signed owner explicitly requests `replace_deleted`
recovery as documented below. Unknown health returns 503 (429 for upstream
throttling), no link. There is no automatic replacement fallback.

The callback must use a registered app origin and have no configured query or
fragment. Pass the returned state and exact account ID using the owner's session:

```json
{
  "slug": "madnion-linkedin",
  "action": "completeAccountV2",
  "app_id": "YOUR_APP_ID",
  "app_workspace_id": "YOUR_CUSTOMER_WORKSPACE_KEY",
  "key_name": "KEY_NAME_FROM_DISCOVERY",
  "account_id": "acc_EXISTING",
  "state": "<EXACT_RETURNED_STATE>"
}
```

State is single-use, expires in 15 minutes, and is bound to app, owner, credential
and customer workspace. Completion re-fetches the account, checks the same account
ID and exact provider member ID, and rejects another person's account, locked
accounts, expired state or wrong workspace. A successful same-account reconnect
keeps the ID, legacy mapping and Madnion history. It does not restore data deleted
upstream or remove publishing holds/cooldowns. Recheck status after completion;
campaigns and SDR must remain paused unless separately authorized.

## Self-serve recovery of a deleted connection

AdvisorPro can offer a "Recover deleted connection" button to the signed-in
account owner. An administrator may direct Richard to that page, but cannot use
their own token, LinkedIn login or a service key to consent on Richard's behalf.
No new action grants are required: use the existing connection/complete grants.

Send this with **Richard's Madnion SSO access token**:

```json
{
  "slug": "madnion-linkedin",
  "action": "createConnectionLinkV2",
  "app_id": "app_HNTaxkT8g6pU1jJ8RFaOQIw1",
  "app_workspace_id": "044uma",
  "key_name": "UnipileV2",
  "account_id": "acc_01m5a9gtadfk1vsb2ddxw8ntkd",
  "callback_url": "https://app.advisorpro.ai/auth/callback",
  "recovery_mode": "replace_deleted",
  "owner_consent": true,
  "acknowledge_new_provider_id": true
}
```

Explain before consent: the deleted provider ID cannot be reused; a new connection
will be linked to the same owner and preserved history. Normal provider connection
fees may apply. Do not set the consent fields automatically in a background job.

A successful response has `mode: "recover_deleted"`, `old_account_id`, `url`,
`expiresAt`, `account_id: null`, `new_provider_account_id_expected: true`,
`completion_requires_owner: true`, and `campaigns_paused: true`.

Madnion first confirms the original is missing under its assigned credential,
reserves one pending recovery, checks the complete provider inventory for the
exact member identity, and creates an isolated recovery scope. A conflicting
identity connection stops the flow without exposing foreign account details.
Links expire after 15 minutes. Concurrent/repeated active attempts are rejected
as `recovery_in_progress_or_unavailable`; do not automatically retry allocation.
After expiry a new owner request can reuse the recovery scope and resume a
matching provisional connection instead of creating a duplicate. A user who
authenticates the wrong LinkedIn identity is rejected; no unrelated connection
is deleted automatically.

On callback call **completeAccountV2** with the exact returned state and the
**new provider account ID**, using Richard's signed session and workspace `044uma`.
The historical account ID is taken from the server-side state, never substituted
from callback input. Madnion verifies the original is still missing, the new
account belongs to the recovery scope, its LinkedIn member ID exactly matches
Richard's preserved identity, it is unlocked/ready, and no duplicate exists.

Completion atomically returns `mode: "recovered"`, `old_account_id`,
`account.id` (the new ID), `history_preserved: true`, `recovery_hold: true`,
`campaigns_paused: true`, `webhook_mode: "shadow"`, `rebuild_inbox_ids: true`,
and `provider_history_restored: false`.

Keep the old account and legacy records for history. Store the explicit
old-to-new mapping; use the new ID for future reads and rebuild inbox/chat IDs.
Discovery/status expose `replaces_account_id` and `replaced_by_account_id`; a
repeated recovery request against a completed original returns
`account_already_recovered` with its replacement ID. Old provider chat/post IDs
are not automatically reusable, and upstream-deleted data is not restored.

Only existing active, explicitly authorized customer-workspace read scopes are
copied, and only where owner membership is still active. No blanket workspace
access is added. All service-write authorizations are disabled on both sides;
the new connection remains in shadow mode with a persistent recovery hold.
Even owner-authenticated sends and publishing are refused with
`account_recovery_hold`. Shared canonical LinkedIn identity keeps the governor's
history/budgets; recovery does not reset allowances or clear cooldowns. No
campaign or SDR is resumed. Hold release is a separate, explicitly approved
readiness step, not part of this recovery API.

## Read-only verification checklist

1. Compare discovery, status, capabilities and capacity for the same account/key/workspace.
2. Missing means `provider_account_exists: false`, never connected/healthy.
3. An ordinary missing-account reconnect returns 409 and no URL. Only explicit
   signed-owner `replace_deleted` consent may allocate a recovery link.
4. Keep missing bindings and their audit/history; show recovery required.
5. Use in-place reconnect after provider restoration, or the explicit deleted
   recovery flow. For replacement, verify the member ID is unchanged, the new
   account mapping is explicit, history remains and recovery_hold stays true.
6. Return UTC time, Madnion request ID, HTTP status and sanitized health fields.
   Never return tokens, auth URLs or callback state in support messages.
