---
title: "Link Identity Rows"
method: POST
path: "/iap/identity-link/"
tags: ["Identity"]
---

# Link Identity Rows

`POST /iap/identity-link/`

## Request

- Base URL: `https://api.zerosettle.io/v1`
- URL: `https://api.zerosettle.io/v1/iap/identity-link/`
- Auth: API key in header `X-ZeroSettle-Key`

Moves a guest id's experiment and analytics rows to the signed-in id, from the app, with the publishable key. Use it to keep a pre-login funnel and experiment assignment when the person logs in.

**Without `link_token`** this endpoint never moves purchases and never touches a customer record. It moves experiment impressions, assignments, exposures, outcome rows and funnel events from `previous_user_id` (or `previous_install_id`) to `user_id`. It skips an id that a live account owns, and an id that was already merged away, and answers `status: "skipped"` with zero counts. Where `user_id` already holds a row for the same experiment, that row wins and the guest's row is left in place. Nothing is dropped.

**With `link_token`** it is a full reconcile, the same as Present Link Token: purchases move too, the same limits and refusals apply, and the response also carries `reconciliation_id`. Ids sent beside a token must both be present and match the token, or neither.

**With a `zs_anon_` `previous_user_id`** (an id minted by iOS 1.7.0, Android 1.4.0 and Flutter 1.9.0 or later) and no token, the endpoint folds the anonymous id into the account. If `user_id` has no history yet (no purchases, entitlements, Stripe customer or funnel events), everything moves: purchases, entitlements, events and experiment assignments, and `identity.reconciled` fires with `actor` of `identity_link`. If `user_id` already has history, nothing moves and the answer is `switched`. A retry returns the same result. Ids without the prefix behave as described above. `user_id` must not start with `zs_anon_`: that prefix is reserved and answers `400 invalid_user_id`. The merge is authorized by the publishable key alone, so make user ids hard to guess.

Without a token it is rate limited to 60 calls a minute per client IP and 6,000 a minute per app. Over a limit the response is `429` with `Retry-After`.

## Request body

- object
  - `previous_user_id` string: The guest id to move rows from. Send this or `previous_install_id`.
  - `previous_install_id` string: The raw SDK install id, as an alternative to `previous_user_id`.
  - `user_id` string: The signed-in id to move rows to.
  - `link_token` string: Optional. A link token from Create Link Token. With it, this is a full reconcile.

## Response `200`

Processed.

- object
  - `status` 'ok' | 'skipped' | 'merged' | 'switched': `skipped` when the guest id belongs to a live account or was merged away and no token was sent. Counts are then zero. `merged` and `switched` are only for a `zs_anon_` `previous_user_id`: `merged` means the anonymous id was folded into the account (also on a retry), `switched` means the account already had history, so nothing moved.
  - `impressions_moved` integer
  - `impressions_dropped` integer
  - `assignments_moved` integer
  - `assignments_dropped` integer
  - `sticky_assignments_moved` integer
  - `sticky_assignments_dropped` integer
  - `sticky_assignments_collided` integer
  - `sticky_assignments_protected` integer
  - `exposures_moved` integer
  - `exposures_dropped` integer
  - `ledger_rows_moved` integer
  - `funnel_events_moved` integer
  - `reconciliation_id` unknown

## Other responses

- `400`: `user_id` and one of `previous_user_id` or `previous_install_id` are required, or the body is not valid JSON. `invalid_user_id` when `user_id` starts with the reserved `zs_anon_` prefix.
- `401`: `invalid_link_token`, when a `link_token` was sent and is not valid. An invalid publishable key is also `401`.
- `409`: `reconcile_refused`. Only with a `link_token`: nothing moved and the token is spent.
- `429`: `rate_limited`.

## Changes

- **2026-10-27** `da7e5908b285`: 4 breaking, 5 warning
  - added `#/components/schemas/Error, #/components/schemas/IdentityError` to the response body `oneOf` list for the response status `400`
  - the response's body type changed from `object` to no type for status `400`
  - the response property `reconciliation_id` became nullable for the status `200`
  - removed the required property `error` from the response with the `400` status
  - …5 more
- **2026-10-26** `8b59573fcc02`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/zerosettle/apis/zerosettle-iap-api/changes/iap/identity-link/post.md)

---

[API](https://skmtc.dev/zerosettle/apis/zerosettle-iap-api.md) - [All operations](https://skmtc.dev/zerosettle/apis/zerosettle-iap-api/llms.txt) - [OpenAPI document](https://skmtc.dev/zerosettle/apis/zerosettle-iap-api/revisions/4cd9e227d9b8?raw)
