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

# API: POST /track/open

> Hợp đồng API đầy đủ của endpoint giải quyết Deep Link: xác thực bằng publishable key, request, response, các giá trị matchMethod và missReason.

# API: POST /track/open

<Info>
  **Bạn sẽ học**: Toàn bộ hợp đồng của endpoint `POST /api/v1/track/open` — cách xác thực, các trường request, server chọn đường xử lý ra sao, hình dạng response, và ý nghĩa từng giá trị `matchMethod` / `missReason`.
</Info>

## Endpoint & môi trường

```
POST https://api.li2.ai/api/v1/track/open
```

Đây là **base URL production duy nhất** — dùng chung cho mọi organization, không có host sandbox/staging riêng. Bạn test ngay trên production bằng custom domain đã verify của mình và một bản cài thật từ store (TestFlight/internal-test) — xem [Kiểm thử trên production](./mobile-integration#kiểm-thử-trên-production).

Endpoint đi qua các middleware: CORS, **rate limit (1000 request/phút/IP)**, và `AuthMobileApikeyMiddleware`.

## Xác thực

Gửi **publishable key** trong header:

```
X-Li2-Key: li2_pk_xxxxxxxxxxxxxxxxxxxx
```

* **Lấy publishable key ở đâu**: dashboard → **Settings → Analytics → Publishable Key** (dạng `li2_pk_...`). Key được tạo tự động khi bật Analytics; chi tiết tại [Thiết lập Conversion Tracking](/vi/conversion-tracking/setup).
* Fallback cho caller không đặt được custom header (ví dụ `sendBeacon`): tham số query `?li2_key=...`.
* **Không** dùng server API key (`X-Li2-API-Key`) cho endpoint này — sẽ trả về **401**. Endpoint mobile chỉ chấp nhận key dạng `li2_pk_*`.

<Note>
  **Vì sao publishable key an toàn khi nhúng trong app**: với request từ web (có `Origin`/`Referer`), server kiểm tra origin theo whitelist của key. Với app native (không có `Origin`/`Referer`), server bỏ qua bước origin và **kiểm tra phía server rằng domain được track thuộc đúng organization của key**. Key không thể dùng để track domain tùy ý hay truy cập dữ liệu org khác.
</Note>

## Request

```json Request theme={null}
{
  "deepLink": "https://your-domain.com/slug?li2_cid=<16-ký-tự-base62>",
  "li2Domains": ["your-domain.com"],
  "clipboardStatus": "read | empty | denied | optout",
  "installReferrer": "<chuỗi-referrer-thô-từ-Play>"
}
```

| Trường            | Bắt buộc     | Ghi chú                                                                                                                                                                                                                                     |
| ----------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `deepLink`        | Có điều kiện | URL hợp lệ, có host và path slug khác rỗng                                                                                                                                                                                                  |
| `li2Domains`      | Có điều kiện | Mảng hostname (không kèm scheme/path) mà app được entitle. Thường chỉ **một**; gửi nhiều nếu app trải trên nhiều custom domain. Khi clipboard rỗng/từ chối, trường này cho server biết domain/org nào để ghi nhận `deferred_miss` đúng chỗ. |
| `clipboardStatus` | Không        | Chỉ nhận `read`, `empty`, `denied`, `optout`; giá trị khác → 400                                                                                                                                                                            |
| `installReferrer` | Có điều kiện | Chuỗi referrer thô từ Google Play                                                                                                                                                                                                           |

Phải có **ít nhất một** trong `deepLink`, `li2Domains`, hoặc `installReferrer`.

## Server chọn đường xử lý như thế nào

| Điều kiện                                   | Đường xử lý            | Ý nghĩa                                                         |
| ------------------------------------------- | ---------------------- | --------------------------------------------------------------- |
| Có `installReferrer`                        | Android deferred       | Giải mã referrer, lấy `li2_cid`, đối chiếu deferred             |
| Có `deepLink` **và** có `li2_cid`           | iOS clipboard deferred | Đối chiếu deferred theo clipboard token                         |
| Có `deepLink`, **không** có `li2_cid`       | Immediate              | App đã cài, link mở bình thường                                 |
| Chỉ có `li2Domains` (kèm `clipboardStatus`) | Ghi nhận miss          | iOS báo clipboard rỗng / từ chối / bỏ qua → ghi `deferred_miss` |

Trước khi xử lý, server kiểm tra org có gói Pro (`deep_link_attribution`) và suy ra `platform` (`ios`/`android`) từ User-Agent.

## Response

```json Response theme={null}
{
  "clickId": "1a2b3c4d5e6f7g8h",
  "link": {
    "id": "<touch-point-uuid>",
    "domain": "your-domain.com",
    "key": "slug",
    "url": "https://destination.example.com/path"
  },
  "matchMethod": "universal_link | app_link | clipboard | install_referrer",
  "missReason": "no_candidate | ...",
  "platform": "ios | android"
}
```

* Khi **khớp**: `link` được điền, `missReason` **bị bỏ qua**.
* Khi **trượt** (deferred không đối chiếu được): `link` là `null`, `missReason` được điền.
* `clickId` luôn có: dùng id của click gốc khi khớp, hoặc một id ngẫu nhiên khi trượt (đồng thời ghi một dòng `deferred_miss`).
* `link.id` là **UUID của touch point** (cấu hình link trong Li2), **không** phải id của short link hiển thị hay của click. Điều hướng người dùng bằng `link.url`; `link.key` là slug, `link.domain` là host.

## Ví dụ theo từng luồng

Mỗi tab là một lệnh `curl` chạy được (thay `your-domain.com` và `li2_pk_...` bằng giá trị của bạn) kèm response mẫu.

<Note>
  **`platform` và `matchMethod` suy ra từ User-Agent.** Server đọc UA của request để phân loại `ios`/`android`. Một lệnh `curl` mặc định (UA `curl/8.x`) khớp **không** nền tảng nào → trả về `platform: null` và `matchMethod` rỗng. Vì vậy các ví dụ dưới đây đều kèm header `-A` giả lập UA thiết bị để response khớp như minh họa. Từ app thật, UA của `URLSession`/`okhttp` đã chứa sẵn dấu hiệu nền tảng nên bạn không cần làm gì thêm.
</Note>

<Tabs>
  <Tab title="Immediate (đã cài app)">
    App đã cài, mở qua Universal Link / App Link — chỉ gửi `deepLink`, **không** có `li2_cid`:

    ```bash Request theme={null}
    curl -X POST https://api.li2.ai/api/v1/track/open \
      -H "Content-Type: application/json" \
      -H "X-Li2-Key: li2_pk_xxxxxxxxxxxxxxxxxxxx" \
      -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" \
      -d '{ "deepLink": "https://your-domain.com/sale" }'
    ```

    ```json Response · 200 theme={null}
    {
      "clickId": "1a2b3c4d5e6f7g8h",
      "link": {
        "id": "0e7c9b12-...",
        "domain": "your-domain.com",
        "key": "sale",
        "url": "https://shop.example.com/sale"
      },
      "matchMethod": "universal_link",
      "platform": "ios"
    }
    ```

    Trên Android, cùng request này trả về `"matchMethod": "app_link"` và `"platform": "android"`.
  </Tab>

  <Tab title="iOS deferred (khớp)">
    Lần mở đầu sau khi cài: clipboard chứa link có `li2_cid` → gửi cả `deepLink` lẫn `clipboardStatus: "read"`:

    ```bash Request theme={null}
    curl -X POST https://api.li2.ai/api/v1/track/open \
      -H "Content-Type: application/json" \
      -H "X-Li2-Key: li2_pk_xxxxxxxxxxxxxxxxxxxx" \
      -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" \
      -d '{ "deepLink": "https://your-domain.com/sale?li2_cid=Ab3xK9pQ2mN7vR1t", "clipboardStatus": "read" }'
    ```

    ```json Response · 200 theme={null}
    {
      "clickId": "Ab3xK9pQ2mN7vR1t",
      "link": {
        "id": "0e7c9b12-...",
        "domain": "your-domain.com",
        "key": "sale",
        "url": "https://shop.example.com/sale"
      },
      "matchMethod": "clipboard",
      "platform": "ios"
    }
    ```
  </Tab>

  <Tab title="Android deferred (khớp)">
    Lần mở đầu sau khi cài: gửi nguyên chuỗi Install Referrer thô (server tự giải mã `li2_dl`):

    ```bash Request theme={null}
    curl -X POST https://api.li2.ai/api/v1/track/open \
      -H "Content-Type: application/json" \
      -H "X-Li2-Key: li2_pk_xxxxxxxxxxxxxxxxxxxx" \
      -A "Dalvik/2.1.0 (Linux; U; Android 14; Pixel 8 Build/UP1A)" \
      -d '{ "installReferrer": "li2_dl=https%3A%2F%2Fyour-domain.com%2Fsale%3Fli2_cid%3DAb3xK9pQ2mN7vR1t&utm_source=li2" }'
    ```

    ```json Response · 200 theme={null}
    {
      "clickId": "Ab3xK9pQ2mN7vR1t",
      "link": {
        "id": "0e7c9b12-...",
        "domain": "your-domain.com",
        "key": "sale",
        "url": "https://shop.example.com/sale"
      },
      "matchMethod": "install_referrer",
      "platform": "android"
    }
    ```

    <Note>
      **Người dùng không cần bấm "Open" ngay trên Play Store.** Google Play lưu chuỗi referrer ngay lúc cài và trả về khi app gọi `InstallReferrerClient` ở **lần mở đầu tiên — bất kể mở bằng cách nào** (nút *Open*, icon ngoài màn hình chính vài giờ sau, hay từ một thông báo). Điều kiện thật sự là người dùng phải tới Play Store **qua chính link Li2** (URL mang tham số referrer); nếu họ tự tìm app trên store thì sẽ không có `li2_dl` → trượt với `no_candidate`.
    </Note>
  </Tab>

  <Tab title="Deferred (trượt)">
    Clipboard rỗng / bị từ chối / bị bỏ qua → chỉ gửi `li2Domains` + `clipboardStatus`. Vẫn trả `200`, nhưng `link` là `null` và có `missReason`:

    ```bash Request theme={null}
    curl -X POST https://api.li2.ai/api/v1/track/open \
      -H "Content-Type: application/json" \
      -H "X-Li2-Key: li2_pk_xxxxxxxxxxxxxxxxxxxx" \
      -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" \
      -d '{ "li2Domains": ["your-domain.com"], "clipboardStatus": "empty" }'
    ```

    ```json Response · 200 (trượt) theme={null}
    {
      "clickId": "f0e1d2c3b4a59687",
      "link": null,
      "matchMethod": "",
      "missReason": "clipboard_empty",
      "platform": "ios"
    }
    ```
  </Tab>
</Tabs>

## Giá trị `matchMethod`

| Giá trị            | Ý nghĩa                                                 |
| ------------------ | ------------------------------------------------------- |
| `universal_link`   | iOS, app đã cài (immediate)                             |
| `app_link`         | Android, app đã cài (immediate)                         |
| `clipboard`        | iOS deferred, khớp qua `li2_cid` trên clipboard         |
| `install_referrer` | Android deferred, khớp qua Google Play Install Referrer |

## Giá trị `missReason`

| Giá trị                | Ý nghĩa                                                       |
| ---------------------- | ------------------------------------------------------------- |
| `no_candidate`         | Tra cứu clipboard không có kết quả (hoặc bản ghi đã hết hạn)  |
| `clipboard_denied`     | Người dùng chặn quyền dán trên iOS                            |
| `clipboard_empty`      | Clipboard rỗng (không có link Li2)                            |
| `opt_out`              | Người dùng chủ động bỏ qua                                    |
| `cross_tenant_blocked` | Hostname thuộc organization khác — bị chặn theo cô lập tenant |

## Mã trạng thái

| Status | Khi nào                                                                                                                                    |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `200`  | Thành công (kể cả khi trượt — `link: null` + `missReason`)                                                                                 |
| `400`  | Body sai định dạng / `clipboardStatus` không hợp lệ                                                                                        |
| `401`  | Thiếu hoặc sai `X-Li2-Key` (hoặc gửi nhầm server API key)                                                                                  |
| `403`  | Org không có gói Pro; hoặc (luồng immediate) domain không thuộc org / deep link bị tắt                                                     |
| `404`  | Không tìm thấy slug (touch point)                                                                                                          |
| `429`  | Vượt **1000 request/phút/IP**. Server đặt header `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`; hãy backoff theo đó. |

<Note>
  Cô lập tenant ở **luồng deferred** (clipboard/referrer) không trả 403 mà trả **`200` + `missReason: cross_tenant_blocked`** (`link: null`) — để không lộ việc hostname có thuộc org khác hay không. Chỉ luồng **immediate** mới trả `403` khi domain không thuộc org.
</Note>

## Hình dạng body lỗi

Response **thành công** là object phẳng (`{ clickId, link, ... }` như trên — không bọc envelope). Mọi response **lỗi** dùng chung **một** envelope:

```json Error theme={null}
{
  "code": 403,
  "message": "Deep link attribution requires the Pro plan",
  "data": null,
  "error_code": "FEATURE_NOT_ALLOWED"
}
```

* `code` = HTTP status; `message` = mô tả tiếng Anh dễ đọc; `data` luôn `null` khi lỗi.
* `error_code` là **mã ổn định, máy đọc được** — hãy branch theo nó thay vì so khớp chuỗi `message`. Hầu hết lỗi phía client (4xx) đều kèm `error_code`; lỗi server (5xx) trả cùng envelope nhưng **không** có `error_code` (chỉ cần retry/backoff). **Ngoại lệ quan trọng**: lỗi xác thực sinh ở tầng middleware (key thiếu/sai, origin sai) **không** kèm `error_code` — xem cảnh báo bên dưới.

### Các `error_code` của endpoint

| HTTP | `error_code`             | Khi nào                                                                             |
| ---- | ------------------------ | ----------------------------------------------------------------------------------- |
| 400  | `INVALID_JSON`           | Body không parse được                                                               |
| 400  | `MISSING_REQUIRED_FIELD` | Thiếu cả `deepLink`, `li2Domains` lẫn `installReferrer`                             |
| 400  | `INVALID_FIELD_FORMAT`   | `clipboardStatus`/`li2Domains` sai định dạng, hoặc `deepLink` không phải URL hợp lệ |
| 400  | `INVALID_CLICK_ID`       | `li2_cid` không đúng 16 ký tự base62                                                |
| 401  | `MISSING_API_KEY`        | Thiếu / không xác thực được `X-Li2-Key`                                             |
| 403  | `FEATURE_NOT_ALLOWED`    | Org không có gói Pro                                                                |
| 403  | `CROSS_ORG_ATTRIBUTION`  | (luồng immediate) domain không thuộc org của key                                    |
| 404  | `LINK_NOT_FOUND`         | Không tìm thấy slug (touch point)                                                   |

<Warning>
  **Lỗi xác thực ở tầng middleware không có `error_code`.** Bảng trên là các mã do *controller* phát ra sau khi đã qua xác thực. Nhưng khi `X-Li2-Key` **thiếu hoặc sai**, hoặc **origin không hợp lệ** (luồng web SDK), request bị chặn ngay ở middleware (`AuthMobileApikeyMiddleware`) — và những phản hồi đó dùng envelope cũ **không có trường `error_code`**:

  ```json theme={null}
  { "code": 401, "message": "Missing X-Li2-Key header", "data": null }
  ```

  Hệ quả thực tế cho client: với **lỗi xác thực (401/403 từ middleware)** hãy branch theo **HTTP status**; chỉ với **lỗi validation/nghiệp vụ (4xx từ controller)** mới có `error_code` để branch. Cụ thể: `MISSING_API_KEY` (401) chỉ xuất hiện ở một nhánh nội bộ của controller, **không** phải mã bạn nhận khi gửi key sai — trường hợp đó là 401 không kèm `error_code`. Tương tự, `CROSS_ORG_ATTRIBUTION` là mã của controller (luồng immediate, domain không thuộc org); còn 403 do origin sai ở middleware thì không kèm `error_code`.
</Warning>

## Bước tiếp theo

<CardGroup cols={2}>
  <Card title="Tích Hợp iOS & Android" icon="mobile" href="./mobile-integration">
    Xem cách gọi endpoint này từ app native.
  </Card>

  <Card title="Tổng Quan Deep Link" icon="link" href="./overview">
    Quay lại bức tranh tổng thể về immediate vs deferred.
  </Card>
</CardGroup>
