# Go2Web API Reference

> The complete English reference for the Go2Web REST API, for LLMs and AI agents — generated from the same source as the API Docs at https://go2w.co/docs/api (human docs, in Thai) and the Postman collection, so they always match · every path is under the version prefix `/api/v1` · short index: https://go2w.co/llms.txt

## Overview

Go2Web (https://go2w.co) is a URL shortener. The Go2Web API is a JSON REST API over HTTPS that lets your systems create and manage short links just like the web app (everything done through the API shows up in the Dashboard immediately). Available on the Starter, Pro, Business and Enterprise plans (Free has no API access).

- Request bodies are JSON with the header `Content-Type: application/json`, up to 1 MiB
- All timestamps are RFC 3339 — when sending, include a timezone offset, e.g. `2026-12-01T00:00:00+07:00` or `2026-11-30T17:00:00Z` (the same instant) · responses use the server's offset (currently `+07:00`) and may include fractional seconds, e.g. `2026-11-12T16:30:00.123456+07:00` — parse them as times, never compare strings (except stats / dashboard `day` / `hour`, which are Bangkok wall-clock times with a `Z` suffix — see Get link stats)
- Links are referenced by `short_code`; projects and domains by `id` (integer)
- Each request may take at most 15 seconds to process (server timeout)
- Examples in this file use cURL — Go, Python, PHP, Node.js and JavaScript examples are in the API Docs: https://go2w.co/docs/api
- Machine-readable spec: OpenAPI 3.1 at https://go2w.co/openapi.json (generated from the same source as this file)
- MCP server for AI assistants (Claude Code, Claude Desktop, Cursor, …): `https://api.go2w.co/api/v1/mcp` with the same API key — see MCP server
- Version 1 of the API lives under /api/v1 — within this version changes are additive only: new endpoints, new response fields, new optional request fields and new error codes · fields are never renamed or removed (except the one dated change below), and published codes are never renamed or given a new meaning
- Clients must ignore unknown fields (do not use strict decoders such as Go's DisallowUnknownFields or pydantic's extra="forbid"), and fall back to the HTTP status when they meet an unknown code
- One-time breaking change (announced 2026-11-13): the stats bucket keys under /api/v1 are now lowercase — daily / hourly of GET /api/v1/links/:code/stats and daily_clicks / hourly_clicks of GET /api/v1/dashboard changed from {"Day", "Clicks"} / {"Hour", "Clicks"} to {"day", "clicks"} / {"hour", "clicks"} · a client reading the capitalized keys must switch to the lowercase ones (during the switch, read both, e.g. item.day ?? item.Day) · at the same time short_url in top_links / recent_links of GET /api/v1/dashboard changed from always using the main domain to the link's custom domain when it has one (same field, new value) · no other field has been renamed or removed

**Endpoints**

- Account — Check which account an API key belongs to, and view plan / quota and the dashboard summary (read-only)
  - `GET /api/v1/me` — Get profile (whoami)
  - `GET /api/v1/dashboard` — Get dashboard summary
- Links — Create, search, update and delete short links, one at a time or in bulk
  - `POST /api/v1/links` — Create link
  - `GET /api/v1/links` — List links
  - `GET /api/v1/links/:code` — Get link
  - `PATCH /api/v1/links/:code` — Update link
  - `DELETE /api/v1/links/:code` — Delete link
  - `POST /api/v1/links/bulk` — Bulk create links
  - `POST /api/v1/links/bulk-update` — Bulk move links
  - `POST /api/v1/links/bulk-delete` — Bulk delete links
- Stats & Targeting Rules — Per-link click stats, and redirect rules by country / device / language / referer / user agent / IP
  - `GET /api/v1/links/:code/stats` — Get link stats
  - `GET /api/v1/links/:code/rules` — Get targeting rules
  - `PUT /api/v1/links/:code/rules` — Replace targeting rules
- Projects — Group links into projects or campaigns
  - `GET /api/v1/projects` — List projects
  - `POST /api/v1/projects` — Create project
  - `PATCH /api/v1/projects/:id` — Rename project
  - `DELETE /api/v1/projects/:id` — Delete project
- Bio Links — Linktree-style bio link pages ("link in bio"): one page per handle at https://go2w.co/@handle with link buttons, headings, social icons, product cards, images, image galleries, PromptPay QR codes and LINE OA add-friend buttons, on the domain and in the font you choose, each block optionally scheduled, plus page-view, traffic-source and per-button click stats
  - `GET /api/v1/bio-pages` — List bio pages
  - `POST /api/v1/bio-pages` — Create bio page
  - `GET /api/v1/bio-pages/:id` — Get bio page
  - `PATCH /api/v1/bio-pages/:id` — Update bio page
  - `DELETE /api/v1/bio-pages/:id` — Delete bio page
  - `PUT /api/v1/bio-pages/:id/items` — Replace bio page items
  - `GET /api/v1/bio-pages/:id/stats` — Get bio page stats
- Domains — Add your custom domain, verify ownership, then use it for links via domain_id · list the platform domains every account can use for links via platform_domain_id
  - `GET /api/v1/domains` — List domains
  - `GET /api/v1/platform-domains` — List platform domains
  - `POST /api/v1/domains` — Create domain
  - `POST /api/v1/domains/:id/verify` — Verify domain
  - `DELETE /api/v1/domains/:id` — Delete domain
  - `GET /api/v1/domains/deleted` — List deleted domains
  - `POST /api/v1/domains/:id/restore` — Restore domain

## Base URL

```text
https://api.go2w.co
```

Every path is appended to the Base URL, e.g. `https://api.go2w.co/api/v1/links` (`https://go2w.co/api/v1/…` works the same) · created short links live on the default platform domain (e.g. `https://go2w.co/{code}`), on another platform domain when `platform_domain_id` is set (`GET /api/v1/platform-domains`), or on your custom domain when `domain_id` is set

## Authentication

Send your API key with every request in either header (they are equivalent):

```http
Authorization: Bearer glk_YOUR_API_KEY
X-API-Key: glk_YOUR_API_KEY
```

- API keys start with `glk_` followed by 48 hex characters · create one at https://go2w.co/developers/keys (sign-in required) · the full key is shown only once, at creation (only a hash is stored) · up to 10 keys per account (name them per system, e.g. CI, Production) · revoke at any time (revoking takes effect immediately and cannot be undone)
- An API key acts on behalf of its owner's account and can only see and change that account's data
- API keys work with every endpoint in this file (Account, Links, Stats & Targeting Rules, Projects, Bio Links, Domains)
- Session-only endpoints: `PATCH /api/me`, `POST /api/me/plan`, `/api/me/api-keys`, `/api/me/passkeys`, `/api/admin/* (except POST /api/admin/auth/login)`. These endpoints need a web sign-in session — they are not available under /api/v1 (404 not_found); on the web app's /api, calling them with an API key returns 403 session_only · /api/admin/* needs an admin-console session (POST /api/admin/auth/login) — a dashboard session gets 403
- Requires the Starter plan or higher — if the account is downgraded, every API key gets `403` `API Access requires the Starter plan or higher` until it upgrades again (API keys are not deleted)
- Keep API keys server-side only (environment variable / secret manager) — never put them in browser or mobile-app code or commit them to git; browsers on other origins cannot call the API directly anyway (CORS)
- If a key leaks, revoke it on the API Keys page right away · a lost key cannot be shown again: create a new one, then revoke the old one (see below)

**Rotating an API key without downtime**

Revoking is immediate and cannot be undone — never revoke a key that production still uses; rotate in this order:

1. Create a new API key (give it a distinct name, e.g. "Production 2026-12") — the old key keeps working meanwhile
2. Deploy the new key to every system that uses the old one (environment variable / secret manager)
3. Watch the old key's "Last used" on the API Keys page — it updates at most once a minute, so wait several minutes until it stops changing (if it still changes, something still uses the old key)
4. Revoke the old key — this takes effect immediately and cannot be undone; anything still using it gets 401 invalid_api_key

## Quickstart

1. Requires the Starter plan or higher — Free accounts cannot create API keys (see plans at https://go2w.co/#pricing). Create an API key at https://go2w.co/developers/keys (it is shown only once) and set it as the environment variable `GO2WEB_API_KEY` (every example reads the API key from it):

```bash
export GO2WEB_API_KEY="glk_YOUR_API_KEY"
```

2. Test it with `GET /api/v1/me` — `200` with your email and plan = ready to go · `401` `invalid api key` (`invalid_api_key`) = the API key is invalid or has been revoked · `401` `missing bearer token` (`missing_credentials`) or `invalid or expired token` (`invalid_token`) = `GO2WEB_API_KEY` is not set in the shell you ran it from (see Errors):

```bash
curl "https://api.go2w.co/api/v1/me" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

3. Create your first link with `POST /api/v1/links` (see Create link) — the `201` response includes a `short_url` you can share right away

## Errors

API errors are JSON in a single shape — `error` is a human-readable message and `code` is a stable machine-readable code (except a 502/504 from the proxy/edge in front of the API, which may be HTML: check the status code first, then parse the body allowing for non-JSON):

```json
{"error":"invalid api key","code":"invalid_api_key"}
```

- The `error` message is for humans — always English under /api/v1, but the wording may change without notice, so never compare message text in code. Branch on `code` (or on the HTTP status when the code is unknown to you), then show / log `error`
- The code list may grow (codes are added, never renamed or removed) — handle an unknown code by its HTTP status
- Any endpoint may also return the 401 / 403 / 429 / 5xx errors in the table below, in addition to its own errors

Every endpoint with a request body may return `400` `invalid json body` when the body is not valid JSON, is larger than 1 MiB, or a field has the wrong type (e.g. `""` instead of `null` for a time field) — not repeated for each endpoint

| Status | Meaning | Example `error` | Example `code` |
|---|---|---|---|
| `400` | Bad Request — The request body is not JSON, a field has an invalid format, or a limit is exceeded — the error message names the offending field | `invalid json body` | `invalid_json` |
| `401` | Unauthorized — Invalid or revoked API key → `invalid api key` (`invalid_api_key`) · no API key or an empty token (e.g. GO2WEB_API_KEY not set for cURL / Go / PHP) → `missing bearer token` (`missing_credentials`) · a Bearer token that does not start with glk_ is checked as a session JWT → `invalid or expired token` (`invalid_token` — e.g. Node.js / JavaScript sends "Bearer undefined" when the env var is not set) | `invalid api key` | `invalid_api_key` |
| `403` | Forbidden — The account's plan has no API Access (`no_api_access`, e.g. downgraded to Free), a plan quota is used up (`link_quota_exceeded`, `domain_limit_reached`, `bio_page_limit_reached`), the account is suspended (`account_suspended`), or a suspended bio page cannot be deleted or have its handle changed (`bio_page_blocked`) — calling a session-only endpoint of the web app's /api with an API key returns `session_only` | `API Access requires the Starter plan or higher` | `no_api_access` |
| `404` | Not Found — The resource does not exist or belongs to another account (the API does not reveal which), or the path does not exist under /api/v1 (including session-only endpoints) | `link not found` | `not_found` |
| `409` | Conflict — alias / short_code is already taken (`alias_taken`) or is a reserved word (`alias_reserved`), the domain has already been added (`domain_taken`), or a bio page handle is already taken (`handle_taken`) / reserved (`handle_reserved`) | `alias already taken` | `alias_taken` |
| `422` | Unprocessable Entity — The destination is unsafe according to Google Safe Browsing (`unsafe_url`), or the domain's DNS is not ready yet (`dns_record_not_found`, `dns_record_mismatch`) | `destination is flagged as unsafe by Google Safe Browsing (MALWARE)` | `unsafe_url` |
| `429` | Too Many Requests — Over the account's rate limit (API keys: Starter 60 · Pro 300 · Business 1,200 · Enterprise 3,000 requests per minute by plan) → `rate_limited` · after 20 invalid API keys from one IP within a minute, further API-key requests from it (valid keys included) → `invalid_key_lockout` — wait for the Retry-After header (seconds until the reset), then retry | `rate limit exceeded, please slow down` | `rate_limited` |
| `500` | Server errors (5xx) — 500 Internal Server Error (`internal`), plus 502 (`bad_gateway` — an external service such as Cloudflare failed) and 503 (`timeout` — processing took longer than 15 seconds; returns `{"error":"request timeout","code":"timeout"}`) — retry later · 502/504 from the proxy/edge in front of the API may be HTML, not JSON: check the status code before parsing the body | `internal server error` | `internal` |

### Error codes

Every `code` the API returns. Specific codes come first within each status; the coarse code (e.g. `bad_request`) is used when no specific code applies.

| Code | Status | Meaning |
|---|---|---|
| `invalid_json` | `400` | The request body is not valid JSON, is larger than 1 MiB, or a field has the wrong type (e.g. "" instead of null for a time field) |
| `domain_not_verified` | `400` | domain_id points to a domain that is not verified yet |
| `bad_request` | `400` | A field has an invalid format, exceeds a limit, or references an id that does not exist — the error message names the field |
| `missing_credentials` | `401` | No API key was sent (no Authorization: Bearer / X-API-Key header, or an empty token) |
| `invalid_api_key` | `401` | The API key is invalid or has been revoked |
| `invalid_token` | `401` | A Bearer token that does not start with glk_ is checked as a session JWT and fails (e.g. "Bearer undefined") |
| `unauthorized` | `401` | Authentication failed for another reason |
| `no_api_access` | `403` | The account's plan has no API Access (e.g. downgraded to Free) — API keys are kept and work again after upgrading |
| `account_suspended` | `403` | The account has been suspended by an operator — every API key and session of the account is refused until it is reinstated (not counted as a failed key) |
| `link_quota_exceeded` | `403` | The plan's monthly link quota is used up |
| `domain_limit_reached` | `403` | The plan's domain limit is reached |
| `bio_page_limit_reached` | `403` | The plan's bio page limit is reached |
| `media_limit_reached` | `403` | The account already holds 500 uploaded images — delete unused ones first (image uploads are web-only, not part of /api/v1) |
| `bio_page_blocked` | `403` | The bio page is suspended by Go2Web (blocked: true) — it cannot be deleted and its handle cannot change until the block is lifted (other fields stay editable) |
| `plan_feature_required` | `403` | The feature needs a higher plan (custom URL, password, link / Bio Links block scheduling, targeting rules, custom domains, Bio Links tracking pixels, and Bio Links branding removal / custom previews) — Pro and above include all of them, so through the API it only appears when a Starter account sets Targeting Rules, tracking pixels, or a bio page's hide_branding / og_* fields (Starter has none of the three); otherwise it mostly appears on the web with the Free plan |
| `session_only` | `403` | The endpoint needs a web sign-in session — only under the web app's /api (under /api/v1 these endpoints do not exist, so they return 404) |
| `key_limit_reached` | `403` | The account already has 10 API keys (when creating a key on the API Keys page) |
| `forbidden` | `403` | Not allowed for another reason |
| `not_found` | `404` | The resource does not exist, belongs to another account, or the path does not exist under /api/v1 |
| `alias_taken` | `409` | alias / short_code is already taken (short codes are unique across the whole system) |
| `alias_reserved` | `409` | alias / short_code is a reserved word |
| `domain_taken` | `409` | The domain has already been added (by any account) |
| `handle_taken` | `409` | The bio page handle is already taken (handles are unique across the whole system) |
| `handle_reserved` | `409` | The bio page handle is a reserved word |
| `conflict` | `409` | Conflict for another reason |
| `unsafe_url` | `422` | Google Safe Browsing reports the destination as unsafe |
| `dns_record_not_found` | `422` | Verify domain: the TXT record is not found yet |
| `dns_record_mismatch` | `422` | Verify domain: the TXT record value does not match the token |
| `unprocessable` | `422` | Cannot be processed for another reason |
| `rate_limited` | `429` | Over the account's rate limit — wait for the Retry-After header, then retry |
| `invalid_key_lockout` | `429` | This IP has sent 20 invalid API keys (401) this minute — every further API-key request from it (valid keys included) gets 429 until the minute ends · an IPv6 client counts per /64 · requests from Cloudflare Workers (which all share one client IP) count per presented key instead of per IP |
| `internal` | `500` | Server error — retry later |
| `not_implemented` | `501` | The feature is not enabled on this server |
| `bad_gateway` | `502` | An external service (e.g. Cloudflare) failed — safe to retry |
| `timeout` | `503` | Processing took longer than 15 seconds — retry |

## Rate limits

Fixed 60-second windows aligned to the clock minute (they reset at second 0 of every minute). Requests made with an API key share one budget per account (all of the account's API keys together), sized by plan — web-app usage has its own separate bucket (120 requests per minute per account), so heavy API traffic never rate-limits the web app:

| Plan | Requests per minute (API keys) |
|---|---|
| Starter | 60 |
| Pro | 300 |
| Business | 1,200 |
| Enterprise | 3,000 (default — custom limits by contract) |

Free accounts cannot use API keys (`403` `no_api_access` before any limit applies). Do not hard-code these numbers: `X-RateLimit-Limit` on every response already tells you your account's limit. Every response to an authenticated request carries these headers, for the bucket that applied:

- `X-RateLimit-Limit` — Maximum requests per minute of the bucket that applied to this request (e.g. 300 on Pro)
- `X-RateLimit-Remaining` — Requests left in the current minute (0 once you get a 429)
- `X-RateLimit-Reset` — When the current window ends, as Unix epoch seconds = second 0 of the next minute
- `Retry-After` — Only on 429: seconds to wait until the reset (1–60)

- Over the limit you get `429` with code `rate_limited` — wait for `Retry-After` (or until `X-RateLimit-Reset`) before retrying; retrying earlier just gets another `429`
- After 20 invalid API keys (401) from one IP within a minute, every further API-key request from that IP — valid keys included — gets `429` with code `invalid_key_lockout` until the minute ends (only rejected keys count toward the 20) · requests already in flight when the limit is reached still get their own answer (401), so a parallel burst can go slightly over 20 · a request whose API key is not accepted (a rejected key's `401`, `403` `no_api_access`, this `429`) carries the X-RateLimit-* headers of this per-IP bucket (limit 20)
- Pace yourself with `X-RateLimit-Remaining` · for large jobs use a bulk endpoint such as `POST /api/v1/links/bulk` (up to 100 URLs per request)

```bash
# --retry retries on 429 (and 500/502/503/504), waiting for the Retry-After header automatically (curl 7.66+)
# --fail suppresses error response bodies while retrying (still failing after 3 retries = exit code 22)
curl -sS --fail --retry 3 "https://api.go2w.co/api/v1/me" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

## Pagination

Only `GET /api/v1/links` is paginated — List projects, List domains and Targeting Rules return everything in one response

- `page` starts at 1 (default 1) · `per_page` 1–100 (default 20) · values below 1 become 1, `per_page` above 100 becomes 100, and a non-numeric value uses the default
- The response includes `total` (number of matching items) and the effective `page` and `per_page` — page count = ceil(total / per_page)
- Sorted from newest to oldest; can be combined with `q` and `project_id`

```bash
# requires jq — fetches 100 at a time until total is reached, printing one link per line
page=1
while :; do
  res=$(curl -sS --fail-with-body "https://api.go2w.co/api/v1/links?page=$page&per_page=100" \
    -H "Authorization: Bearer $GO2WEB_API_KEY") || { echo "$res" >&2; break; }
  echo "$res" | jq -c '.links[] | {short_code, long_url}'
  total=$(echo "$res" | jq '.total')
  per_page=$(echo "$res" | jq '.per_page')
  [ $((page * per_page)) -ge "$total" ] && break
  page=$((page + 1))
done
```

## MCP server

A remote MCP (Model Context Protocol) server lets AI assistants — Claude Code, Claude Desktop, Cursor and other MCP clients — manage the account's links through tools, with the same API key and the same rules as this REST API (plan, monthly link quota, Safe Browsing, rate limits). Endpoint:

```text
https://api.go2w.co/api/v1/mcp
```

- Transport: stateless Streamable HTTP — every request is a self-contained POST with no Mcp-Session-Id, answered as application/json (not SSE) · GET / DELETE return 405 · tools only (no resources / prompts)
- Authentication: the same API key as the REST API, in Authorization: Bearer glk_… or X-API-Key — web session JWTs are not accepted · requires the Starter plan or higher (Free accounts get 403 no_api_access) · it can do only what the API key can: the key owner's data
- Rate limit: every HTTP request to the MCP endpoint (initialize, tools/list and each tools/call) counts as one request in the same per-account API-key budget as REST (Starter 60 · Pro 300 · Business 1,200 · Enterprise 3,000 per minute) and carries the X-RateLimit-* headers · JSON-RPC batches are not accepted (an array body gets 400 bad_request) — one HTTP request carries one message · rejected keys count toward the same per-IP failed-key lockout
- Results: a tool returns structuredContent with the same JSON as the response of its REST endpoint under /api/v1 (e.g. stats use lowercase day / hour / clicks), plus the same JSON as text content for clients that ignore structuredContent · the exception is delete_link: REST answers 204 with no body, the tool returns {deleted: true, short_code}
- Errors: a failed tool call always returns isError: true with text {"error", "code"}, the same as REST (English message, the same codes) · arguments that do not match the input schema or cannot be decoded (wrong type, number out of range, a time that is not RFC 3339) are a tool error too, with code bad_request and a message starting with "invalid arguments:" · the HTTP-level errors with an {"error", "code"} body are the 400 (batch) / 401 / 403 / 429 in the table below; 400 (wrong Accept header), 405, 413 (body over 1 MiB) and 415 (Content-Type is not application/json) come from the MCP SDK as plain text

### Connect a client

**Claude Code**

Run in a terminal after setting GO2WEB_API_KEY (Quickstart step 1) — the shell substitutes the key and Claude Code stores it in its local config (local scope = this project only · add --scope user for every project) · check with claude mcp list or /mcp

```bash
claude mcp add --transport http go2web https://api.go2w.co/api/v1/mcp \
  --header "Authorization: Bearer $GO2WEB_API_KEY"
```

**Claude Code (.mcp.json)** — `.mcp.json`

Share it with the team through .mcp.json at the repo root — Claude Code substitutes ${GO2WEB_API_KEY} from each person's environment, so the file can be committed without a key in it

```json
{
  "mcpServers": {
    "go2web": {
      "type": "http",
      "url": "https://api.go2w.co/api/v1/mcp",
      "headers": {
        "Authorization": "Bearer ${GO2WEB_API_KEY}"
      }
    }
  }
}
```

**Cursor** — `~/.cursor/mcp.json`

Put it in ~/.cursor/mcp.json (every project) or a project's .cursor/mcp.json and replace glk_YOUR_API_KEY with your API key — the file holds the key, so never commit it

```json
{
  "mcpServers": {
    "go2web": {
      "url": "https://api.go2w.co/api/v1/mcp",
      "headers": {
        "Authorization": "Bearer glk_YOUR_API_KEY"
      }
    }
  }
}
```

**Claude Desktop** — `claude_desktop_config.json`

Claude Desktop's Connectors have no field for an API key (header), so connect through mcp-remote (needs Node.js) — Settings → Developer → Edit Config, add this server, replace glk_YOUR_API_KEY and restart Claude Desktop

```json
{
  "mcpServers": {
    "go2web": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://api.go2w.co/api/v1/mcp",
        "--header",
        "Authorization:${GO2WEB_AUTH_HEADER}"
      ],
      "env": {
        "GO2WEB_AUTH_HEADER": "Bearer glk_YOUR_API_KEY"
      }
    }
  }
}
```

**Test (curl / MCP Inspector)**

Check that your API key works with MCP: call tools/list directly with curl (a tool list = ready to go) or use the MCP Inspector CLI

```bash
# list the tools (a JSON-RPC result with 9 tools = your API key works)
curl -s https://api.go2w.co/api/v1/mcp \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2025-06-18" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# or with the MCP Inspector CLI (Node.js)
npx -y @modelcontextprotocol/inspector --cli https://api.go2w.co/api/v1/mcp --transport http \
  --header "Authorization: Bearer $GO2WEB_API_KEY" --method tools/list
```

### Tools

Arguments are validated against each tool's JSON Schema (unknown arguments are rejected). A result is the JSON of the matching REST endpoint — except where that endpoint answers `204` with no body: the tool then returns the small object its description gives:

| Tool | Arguments | Description | REST equivalent |
|---|---|---|---|
| `create_link` | `url` (string, required), `name` (string), `alias` (string), `project_id` (integer), `domain_id` (integer), `starts_at` (string (RFC 3339)), `expires_at` (string (RFC 3339)), `max_clicks` (integer), `sensitive` (boolean), `password` (string (≤ 72 bytes)) | Create short link — Creates a short link to a destination URL and returns the Link object (share its short_url right away) · without alias a random 7-character short code is generated · counts toward the plan's monthly link quota, and the destination is checked with Google Safe Browsing | `POST /api/v1/links` |
| `list_links` | `q` (string), `project_id` (integer), `status` (string (active \| inactive \| expired \| capped \| scheduled \| blocked)), `page` (integer), `per_page` (integer) | List short links — Lists the account's links, newest first, paginated (page 1–1,000,000 · per_page 1–100, default 20) and returns {links, total, page, per_page, status_counts} · search with q, filter with project_id and status · status_counts counts the links matching q and project_id (not status), in all and per status | `GET /api/v1/links` |
| `get_link` | `short_code` (string, required) | Get short link — Gets one link by short_code: its destination (long_url), short_url, settings and click_count | `GET /api/v1/links/:code` |
| `update_link` | `short_code` (string, required), `new_short_code` (string), `name` (string), `url` (string), `project_id` (integer \| null), `domain_id` (integer \| null), `starts_at` (string (RFC 3339) \| null), `expires_at` (string (RFC 3339) \| null), `max_clicks` (integer \| null), `sensitive` (boolean), `password` (string (≤ 72 bytes)), `is_active` (boolean) | Update short link — Partially updates a link: send short_code plus only the fields to change — every field left out keeps its value · project_id, domain_id, starts_at, expires_at and max_clicks accept null to clear them · rename with new_short_code (the old short URL stops working at once) · password "" removes the password | `PATCH /api/v1/links/:code` |
| `delete_link` | `short_code` (string, required) | Delete short link — Deletes a link by short_code — its short URL stops working immediately and it leaves every list (cannot be undone through the API) · returns {deleted: true, short_code} (the REST endpoint answers 204 with no body, so this tool returns this small object instead) | `DELETE /api/v1/links/:code` (`204`, no body — the tool's result is in its description) |
| `get_link_stats` | `short_code` (string, required), `from` (string (YYYY-MM-DD)), `to` (string (YYYY-MM-DD)) | Get link click stats — Click stats of a link for the dates from–to (YYYY-MM-DD, Asia/Bangkok; default the last 30 days, at most 366 days): daily [{day, clicks}], hourly [{hour, clicks}] for a single day, devices / os (ios, android, windows, macos, linux, chromeos, other, unknown) / browsers / referrers / countries / user_agents (the raw User-Agent header, (unknown) = none) (every value with a click, no top-N cut) and bot_clicks · day / hour are the bucket start as Asia/Bangkok wall-clock time written with a Z suffix — read them as Bangkok time and do not convert time zones (2026-11-12T21:00:00Z = 21:00–21:59 Bangkok) | `GET /api/v1/links/:code/stats` |
| `list_projects` | none | List projects — Lists the account's projects (link folders / campaigns) with their link_count — use an id as project_id | `GET /api/v1/projects` |
| `create_project` | `name` (string, required) | Create project — Creates a project (name 1–80 characters) and returns the Project object | `POST /api/v1/projects` |
| `list_domains` | none | List custom domains — Lists the account's custom domains — only a domain with verified: true can be used as domain_id (links without one use go2w.co) | `GET /api/v1/domains` |

### MCP errors

HTTP-level errors come before any MCP message runs (the client cannot connect, or the call fails). These come as JSON `{"error", "code"}` like the REST API (the MCP SDK's own 400 / 405 / 413 / 415 for a wrong Accept header, method, body size or Content-Type are plain text):

- `400` A JSON-RPC batch (an array body) was sent — send one message per HTTP request — `JSON-RPC batching is not supported: send one request per HTTP POST` · code `bad_request`
- `401` No API key was sent — `an API key is required: send Authorization: Bearer glk_... or X-API-Key: glk_...` · code `missing_credentials`
- `401` A web session JWT was sent instead of an API key — `the MCP endpoint accepts API keys only (glk_...), not session tokens` · code `invalid_api_key`
- `401` The API key is invalid or has been revoked — `invalid api key` · code `invalid_api_key`
- `403` The account's plan has no API access (Free) — `API Access requires the Starter plan or higher` · code `no_api_access`
- `429` This minute's budget is used up — wait for Retry-After — `rate limit exceeded, please slow down` · code `rate_limited`
- `429` 20 rejected API keys from this IP this minute — `rate limit exceeded, please slow down` · code `invalid_key_lockout`

A tool call that fails returns a result with `isError: true` whose text is the error body with the same codes as REST (invalid arguments: code `bad_request`, message starting with `invalid arguments:`), e.g.:

```json
{"error":"link not found","code":"not_found"}
```

## Account

Check which account an API key belongs to, and view plan / quota and the dashboard summary (read-only)

### Profile object

The account that owns the API key, with its current plan and usage

| Name | Type | Required | Description |
|---|---|---|---|
| `email` | `string` | Yes | Account email |
| `display_name` | `string` | Yes | Display name |
| `plan` | `string` | Yes | Current plan: free \| starter \| pro \| business \| enterprise |
| `created_at` | `string (RFC 3339)` | Yes | Sign-up date |
| `usage.links` | `integer` | Yes | Number of existing links (excluding deleted ones) |
| `usage.links_this_month` | `integer` | Yes | Links created this month (Asia/Bangkok timezone, including deleted ones) — this is the value capped by max_links |
| `usage.max_links` | `integer` | Yes | The plan's monthly link-creation quota (-1 = unlimited) — resets on the 1st of every month |
| `usage.domains` | `integer` | Yes | Number of domains added |
| `usage.max_domains` | `integer` | Yes | The plan's maximum number of domains (-1 = unlimited) |
| `usage.bio_pages` | `integer` | Yes | Number of existing bio pages |
| `usage.max_bio_pages` | `integer` | Yes | The plan's maximum number of bio pages (-1 = unlimited) |

### GET /api/v1/me — Get profile (whoami)

Returns the account that owns the API key, its current plan and usage against quota — handy for checking that an API key works

**Example request**

```bash
curl "https://api.go2w.co/api/v1/me" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — Profile object

```json
{
  "email": "dev@example.com",
  "display_name": "Dev Team",
  "plan": "pro",
  "created_at": "2026-08-21T11:00:00+07:00",
  "usage": {
    "links": 128,
    "links_this_month": 42,
    "max_links": 2000,
    "domains": 1,
    "max_domains": 5,
    "bio_pages": 2,
    "max_bio_pages": 10
  }
}
```

### GET /api/v1/dashboard — Get dashboard summary

Account overview for a date range (default: last 7 days): clicks, unique visitors and their trend versus the previous period, daily clicks, a weekday × hour heatmap, breakdowns by device / browser / referrer / country / project / domain, top links in the range and links that need attention — plus all-time totals and recent links

- Days are cut in the Asia/Bangkok timezone (UTC+7) · without from/to = the last 7 days up to today · from > to is swapped · maximum range 366 days · unparseable values fall back to the default
- Range-based fields (range, clicks, unique_visitors, bot_clicks, daily_clicks, hourly_clicks, heatmap, devices / os / browsers / referrers / countries, projects, domains, top_links_in_range) count the same set of clicks: real clicks (bots / link previews excluded, except in bot_clicks) on links that are not deleted — each breakdown (devices, os, browsers, referrers, countries), projects and domains lists every value with a click (no top-N cut, most clicks first), so each always sums to clicks
- \*_prev and previous_range cover the preceding period of the same length, ending the day before from (e.g. 7 days → the 7 days before that), for computing trends — when a \*_prev value is 0, treat the change as "new" instead of dividing by 0
- Range-independent fields: total_links, active_links, total_clicks (all time), clicks_7d (always the last 7 × 24 hours), clicks_today (today, Bangkok time), top_links (all-time clicks), recent_links and attention (current status)
- daily_clicks follows the selected range — day is the start of the day as Bangkok wall-clock time with a Z suffix (not a real UTC instant — do not convert), e.g. "2026-11-10T00:00:00Z" = Nov 10 · only days with clicks are included, and it is [] when there are no clicks at all · without from/to it still covers the last 7 days
- unique_visitors counts distinct IPs among real clicks — people sharing an IP (NAT, office Wi-Fi) count as one, and frequently changing IPs (mobile, IPv6) may be over-counted
- daily_clicks / devices / os / browsers / referrers / countries / projects / domains / heatmap / top_links_in_range are [] (not null) when there are no clicks · hourly_clicks is null when the range is longer than 1 day ([] for a single day without clicks) · attention is null when that part fails to load
- short_url in top_links / recent_links / top_links_in_range / attention.links is the link's real short URL — on its custom domain when it has one, else on its platform domain (e.g. go2w.co), the same as short_url in the link API

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `from` | `string (YYYY-MM-DD)` | No | Start date (inclusive) — omitted = 6 days before to (e.g. `2026-11-06`) |
| `to` | `string (YYYY-MM-DD)` | No | End date (inclusive) — omitted = today (Bangkok time) (e.g. `2026-11-12`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/dashboard" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — The account's dashboard summary

```json
{
  "total_links": 128,
  "active_links": 120,
  "total_clicks": 15872,
  "clicks_7d": 1934,
  "daily_clicks": [
    {
      "day": "2026-11-11T00:00:00Z",
      "clicks": 301
    },
    {
      "day": "2026-11-12T00:00:00Z",
      "clicks": 276
    }
  ],
  "top_links": [
    {
      "short_code": "promo2026",
      "short_url": "https://go.brand.com/promo2026",
      "long_url": "https://shop.example.com/sale/1212",
      "click_count": 1342,
      "created_at": "2026-11-01T10:12:45+07:00"
    }
  ],
  "recent_links": [
    {
      "short_code": "Xk9pQ2a",
      "short_url": "https://go2w.co/Xk9pQ2a",
      "long_url": "https://example.com/campaign/new-year?utm_source=line",
      "click_count": 0,
      "created_at": "2026-11-12T16:30:00+07:00"
    }
  ],
  "range": {
    "from": "2026-11-06",
    "to": "2026-11-12"
  },
  "previous_range": {
    "from": "2026-10-30",
    "to": "2026-11-05"
  },
  "clicks": 1934,
  "clicks_prev": 1650,
  "unique_visitors": 1288,
  "unique_visitors_prev": 1102,
  "bot_clicks": 87,
  "clicks_today": 276,
  "hourly_clicks": null,
  "heatmap": [
    {
      "weekday": 5,
      "hour": 20,
      "clicks": 64
    },
    {
      "weekday": 5,
      "hour": 21,
      "clicks": 58
    },
    {
      "weekday": 1,
      "hour": 12,
      "clicks": 41
    }
  ],
  "devices": [
    {
      "label": "mobile",
      "count": 1402
    },
    {
      "label": "desktop",
      "count": 488
    },
    {
      "label": "tablet",
      "count": 44
    }
  ],
  "os": [
    {
      "label": "android",
      "count": 812
    },
    {
      "label": "ios",
      "count": 634
    },
    {
      "label": "windows",
      "count": 401
    },
    {
      "label": "macos",
      "count": 87
    }
  ],
  "browsers": [
    {
      "label": "chrome",
      "count": 1015
    },
    {
      "label": "safari",
      "count": 702
    },
    {
      "label": "other",
      "count": 217
    }
  ],
  "referrers": [
    {
      "label": "(direct)",
      "count": 1120
    },
    {
      "label": "l.facebook.com",
      "count": 613
    },
    {
      "label": "line.me",
      "count": 201
    }
  ],
  "countries": [
    {
      "label": "TH",
      "count": 1788
    },
    {
      "label": "JP",
      "count": 96
    },
    {
      "label": "(unknown)",
      "count": 50
    }
  ],
  "projects": [
    {
      "project_id": 3,
      "name": "แคมเปญ 12.12",
      "clicks": 1210
    },
    {
      "project_id": null,
      "name": "",
      "clicks": 724
    }
  ],
  "domains": [
    {
      "domain_id": null,
      "platform_domain_id": 1,
      "host": "go2w.co",
      "clicks": 1380
    },
    {
      "domain_id": 2,
      "platform_domain_id": null,
      "host": "go.brand.com",
      "clicks": 434
    },
    {
      "domain_id": null,
      "platform_domain_id": 2,
      "host": "g2w.to",
      "clicks": 120
    }
  ],
  "top_links_in_range": [
    {
      "short_code": "promo2026",
      "short_url": "https://go.brand.com/promo2026",
      "long_url": "https://shop.example.com/sale/1212",
      "name": "โปร 12.12",
      "click_count": 1342,
      "clicks": 812,
      "created_at": "2026-11-01T10:12:45+07:00"
    }
  ],
  "attention": {
    "total": 2,
    "counts": {
      "blocked": 0,
      "unsafe": 0,
      "expiring": 1,
      "near_cap": 0,
      "no_clicks": 1
    },
    "links": [
      {
        "short_code": "flash11",
        "short_url": "https://go.brand.com/flash11",
        "name": "Flash sale 11.11",
        "reason": "expiring",
        "expires_at": "2026-11-15T00:00:00+07:00",
        "click_count": 92,
        "max_clicks": null
      },
      {
        "short_code": "Xk9pQ2a",
        "short_url": "https://go2w.co/Xk9pQ2a",
        "name": "",
        "reason": "no_clicks",
        "expires_at": null,
        "click_count": 0,
        "max_clicks": null
      }
    ]
  }
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `total_links` | `integer` | Yes | Total number of links (excluding deleted ones) |
| `active_links` | `integer` | Yes | Number of enabled links |
| `total_clicks` | `integer` | Yes | All-time clicks across all links (excluding bots) |
| `clicks_7d` | `integer` | Yes | Clicks in the last 7 × 24 hours (excluding bots) — independent of from/to |
| `daily_clicks` | `{day, clicks}[]` | Yes | Daily clicks in the selected range (Bangkok-time days) — lowercase keys (day, clicks) |
| `top_links` | `object[]` | Yes | Top 5 enabled links by all-time clicks: short_code, short_url, long_url, click_count, created_at |
| `recent_links` | `object[]` | Yes | The 5 most recent links (same fields as top_links) |
| `range / previous_range` | `{from, to}` | Yes | The effective range and the preceding range of equal length (YYYY-MM-DD, both days inclusive) |
| `clicks / clicks_prev` | `integer` | Yes | Real clicks in the range / in the previous range |
| `unique_visitors / unique_visitors_prev` | `integer` | Yes | Distinct IPs among real clicks in the range / in the previous range |
| `bot_clicks` | `integer` | Yes | Bot / link-preview clicks in the range — bot share = bot_clicks ÷ (clicks + bot_clicks) |
| `clicks_today` | `integer` | Yes | Real clicks since 00:00 today (Bangkok time) — independent of from/to |
| `hourly_clicks` | `{hour, clicks}[] \| null` | Yes | Hourly clicks (only hours with clicks) when a single day is requested (from = to) · null for multi-day ranges |
| `heatmap` | `{weekday, hour, clicks}[]` | Yes | Clicks by weekday × hour (Bangkok time), only cells with clicks — weekday 0 = Sunday … 6 = Saturday, hour 0–23 |
| `devices` | `{label, count}[]` | Yes | Clicks in the range across all links, by device: mobile, tablet, desktop, tv (smart TV), console (game console), app (a native app's own request, not a browser), other (unidentified client), unknown |
| `os` | `{label, count}[]` | Yes | By operating system (from the user agent): ios, android, windows, macos, linux, chromeos, other, unknown · an iPad sending a Mac user agent (iPadOS 13+) counts as macos |
| `browsers` | `{label, count}[]` | Yes | By browser: chrome, safari, firefox, edge, other |
| `referrers` | `{label, count}[]` | Yes | Referrer host, or (direct) |
| `countries` | `{label, count}[]` | Yes | ISO 3166-1 country code, e.g. TH, or (unknown) |
| `projects` | `{project_id, name, clicks}[]` | Yes | Clicks in the range by project, every project with a click — project_id null = links without a project |
| `domains` | `{domain_id, platform_domain_id, host, clicks}[]` | Yes | Clicks in the range by domain, every domain with a click (show host as the name) — a custom domain has its domain_id (platform_domain_id null) · platform domains have domain_id null, one entry each, with their platform_domain_id (possibly null for the default platform domain's entry), so domain_id alone is not a unique key |
| `top_links_in_range` | `object[]` | Yes | Top 5 links by real clicks in the range (including disabled links): the top_links fields + name, clicks (clicks in the range) |
| `attention` | `object \| null` | Yes | Links that need attention: total (distinct links), counts per reason, links (up to 3 sample links per reason: short_code, short_url, name, reason, expires_at, click_count, max_clicks) |
| `attention.total` | `integer` | Yes | Number of distinct links that need attention (a link with several reasons counts once) |
| `attention.links` | `object[]` | Yes | Up to 3 sample links per reason ([] when none): short_code, short_url, name, reason, expires_at (null = never expires), click_count, max_clicks (null = no limit) |
| `attention.counts` | `object` | Yes | blocked (suspended by an admin), unsafe (unsafe destination), expiring (live and expiring within 7 days), near_cap (live and at 80% or more of max_clicks), no_clicks (live for 3+ days with no real clicks yet) — one link can count under several reasons |

## Links

Create, search, update and delete short links, one at a time or in bulk

### Link object

A single short link — fields marked "may be absent" are omitted from the JSON entirely when not set

| Name | Type | Required | Description |
|---|---|---|---|
| `short_code` | `string` | Yes | The link's short code (used as :code in paths) |
| `short_url` | `string` | Yes | Full short URL — on your domain if domain_id is set, otherwise on the link's platform domain (platform_domain_id, or the default one, e.g. https://go2w.co/\<code>) · its host is always the link's real domain |
| `name` | `string` | Yes | Link name ("" if not set) |
| `long_url` | `string` | Yes | Destination URL |
| `project_id` | `integer` | No | The link's project (may be absent) |
| `domain_id` | `integer` | No | Attached custom domain (may be absent) |
| `platform_domain_id` | `integer` | No | The platform domain the link is on (see GET /api/v1/platform-domains) — may be absent: it is absent when the link is on a custom domain (domain_id), and a link with neither field is on the current default platform domain · it can be a domain that is no longer public |
| `starts_at` | `string (RFC 3339)` | No | Activation time (may be absent) |
| `expires_at` | `string (RFC 3339)` | No | Expiry time (may be absent) |
| `max_clicks` | `integer` | No | Click limit (may be absent) |
| `sensitive` | `boolean` | Yes | Show a preview / warning page before redirecting |
| `has_password` | `boolean` | Yes | Whether a password is set (the API never returns the password) |
| `is_active` | `boolean` | Yes | Whether the link is enabled |
| `blocked` | `boolean` | Yes | Suspended by an admin — the owner cannot lift it |
| `click_count` | `integer` | Yes | Total clicks (excluding bots / link previews) |
| `created_at` | `string (RFC 3339)` | Yes | Creation time |
| `updated_at` | `string (RFC 3339)` | Yes | Last update time |
| `threat_type` | `string` | No | Threat type found by Safe Browsing (may be absent) |
| `safe_checked_at` | `string (RFC 3339)` | No | Time of the last Safe Browsing check (absent = not checked yet) |

### POST /api/v1/links — Create link

Creates one short link. Without an alias, a random 7-character short code (0-9 a-z A-Z) is generated

- The destination is checked with Google Safe Browsing before the link is created — a detected threat returns 422
- Set your own short code with "alias": "promo2026" · use a custom domain with "domain_id", or another platform domain with "platform_domain_id" (one or the other) — neither = the default platform domain
- Counts toward the plan's monthly link quota (see usage in GET /api/v1/me) — deleting a link does not give the quota back

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `url` | `string` | Yes | Destination URL — Must start with http:// or https:// and include a host — must not point back to Go2Web's own domain (prevents redirect loops) |
| `name` | `string` | No | Link name, for searching and recognizing the link — Up to 100 characters (leading/trailing whitespace is trimmed) |
| `alias` | `string` | No | Your own short code (custom URL) — 3–32 characters: a-z A-Z 0-9 - _ · reserved words are not allowed (e.g. api, admin, login, dashboard, links, pricing) |
| `project_id` | `integer` | No | Put the link in a project (must be one of your projects) |
| `domain_id` | `integer` | No | Use one of your custom domains for the short link (the domain must be verified) — cannot be combined with platform_domain_id · neither = the default platform domain |
| `platform_domain_id` | `integer` | No | Use this platform domain for the short link — cannot be combined with domain_id · neither = the default platform domain — An id from GET /api/v1/platform-domains (a public platform domain) |
| `starts_at` | `string (RFC 3339)` | No | Activation time — the link does not redirect before this time — e.g. 2026-12-01T00:00:00+07:00 |
| `expires_at` | `string (RFC 3339)` | No | Expiry time — the link stops working after this time — Must be in the future and after starts_at |
| `max_clicks` | `integer` | No | The link stops redirecting once click_count reaches this number (is_active is not changed) — At least 1 |
| `sensitive` | `boolean` | No | Show a preview / warning page before sending visitors to the destination — default false |
| `password` | `string` | No | Require a password to open the link — At most 72 bytes in UTF-8 (72 Latin letters, about 24 Thai characters) |
| `rules` | `Targeting rule[]` | No | Targeting rules created together with the link (Pro and above), e.g. country / Google or Facebook bot → another URL — array order is the priority (see the fields in Targeting rule object) — Up to 100 rules · validated like PUT /links/:code/rules — an invalid rule or a plan without targeting creates no link |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/links" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://example.com/campaign/new-year?utm_source=line",
  "name": "โปรโมชันปีใหม่"
}'
```

**Response** `201 Created` — The created Link object

```json
{
  "short_code": "Xk9pQ2a",
  "short_url": "https://go2w.co/Xk9pQ2a",
  "name": "โปรโมชันปีใหม่",
  "long_url": "https://example.com/campaign/new-year?utm_source=line",
  "sensitive": false,
  "has_password": false,
  "is_active": true,
  "blocked": false,
  "click_count": 0,
  "created_at": "2026-11-12T16:30:00+07:00",
  "updated_at": "2026-11-12T16:30:00+07:00",
  "safe_checked_at": "2026-11-12T16:30:00+07:00"
}
```

**Errors**

- `400` url is missing or empty — `url is required`
- `400` url cannot be parsed — `url is not parseable`
- `400` url is not http(s) (including a url with no scheme) — `url must be http or https`
- `400` url has no host — `url must have a host`
- `400` url points to Go2Web's own domain — `cannot shorten this service's own domain`
- `400` name is longer than 100 characters — `name must be at most 100 chars`
- `400` alias has an invalid format — `alias must be 3-32 chars: letters, digits, - or _`
- `400` expires_at is not in the future — `expires_at must be in the future`
- `400` expires_at is not after starts_at — `expires_at must be after starts_at`
- `400` max_clicks is less than 1 — `max_clicks must be at least 1`
- `400` password is longer than 72 bytes — `password must be at most 72 bytes in UTF-8`
- `400` project_id does not exist or belongs to another account — `project not found`
- `400` domain_id does not exist or belongs to another account — `domain not found`
- `400` The domain is not verified yet — `domain is not verified yet` · code `domain_not_verified`
- `400` platform_domain_id does not exist or is not public (not in List platform domains) — `platform domain not found or not available`
- `400` A custom domain_id was sent together with a non-null platform_domain_id — `domain_id and platform_domain_id are mutually exclusive`
- `409` alias is already taken — `alias already taken` · code `alias_taken`
- `409` alias is a reserved word — `alias is reserved` · code `alias_reserved`
- `403` The plan's monthly link quota is used up (resets on the 1st of each month, Bangkok time) — `monthly link quota reached: the Pro plan includes 2000 new links per month (resets on the 1st, Bangkok time); upgrade or wait for next month` · code `link_quota_exceeded`
- `422` Google Safe Browsing reports the destination as unsafe — `destination is flagged as unsafe by Google Safe Browsing (MALWARE)` · code `unsafe_url`

### GET /api/v1/links — List links

Lists the account's links with pagination, newest first by created_at. Supports search and filtering by project and status, and returns the number of links per status (status_counts)

- A link's status is computed at request time, in the order the redirect checks it — each link has exactly one: blocked = suspended by an admin (overrides everything) → inactive = turned off by you (is_active false) → expired = past expires_at → capped = click_count has reached max_clicks → scheduled = before starts_at → active = everything else, i.e. it redirects right now
- status_counts honours q and project_id but not status — enough to draw status filter buttons with counts from one request (all = the sum of every status = total when no status is sent)

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | `string` | No | Searches short_code, long_url and name (case-insensitive) (e.g. `promo`) |
| `project_id` | `integer` | No | Only links in this project (non-numeric values are ignored) (e.g. `12`) |
| `status` | `string` | No | Only links with this status: active, inactive, expired, capped, scheduled, blocked (case-insensitive · omitted / empty = every status · any other value returns 400) (e.g. `active`) |
| `page` | `integer` | No | Page number, starting at 1 (default 1 · values below 1 become 1 · a non-numeric value uses the default) (e.g. `1`) |
| `per_page` | `integer` | No | Items per page, 1–100 (default 20 · values below 1 become 1, above 100 become 100 · a non-numeric value uses the default) (e.g. `20`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/links?page=1&per_page=20" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — links (array of Link objects) + pagination info

```json
{
  "links": [
    {
      "short_code": "Xk9pQ2a",
      "short_url": "https://go2w.co/Xk9pQ2a",
      "name": "โปรโมชันปีใหม่",
      "long_url": "https://example.com/campaign/new-year?utm_source=line",
      "sensitive": false,
      "has_password": false,
      "is_active": true,
      "blocked": false,
      "click_count": 0,
      "created_at": "2026-11-12T16:30:00+07:00",
      "updated_at": "2026-11-12T16:30:00+07:00",
      "safe_checked_at": "2026-11-12T16:30:00+07:00"
    },
    {
      "short_code": "promo2026",
      "short_url": "https://go.brand.com/promo2026",
      "name": "แคมเปญ 12.12",
      "long_url": "https://shop.example.com/sale/1212",
      "project_id": 12,
      "domain_id": 3,
      "starts_at": "2026-12-01T00:00:00+07:00",
      "expires_at": "2026-12-31T23:59:59+07:00",
      "max_clicks": 5000,
      "sensitive": false,
      "has_password": false,
      "is_active": true,
      "blocked": false,
      "click_count": 1342,
      "created_at": "2026-11-01T10:12:45+07:00",
      "updated_at": "2026-11-10T15:00:00+07:00",
      "safe_checked_at": "2026-11-01T10:12:45+07:00"
    }
  ],
  "total": 2,
  "page": 1,
  "per_page": 20,
  "status_counts": {
    "all": 2,
    "active": 2,
    "inactive": 0,
    "expired": 0,
    "capped": 0,
    "scheduled": 0,
    "blocked": 0
  }
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `links` | `Link[]` | Yes | Links on this page ([] when none) |
| `total` | `integer` | Yes | Total number of matching links (across all pages) |
| `page` | `integer` | Yes | The returned page |
| `per_page` | `integer` | Yes | Effective items per page |
| `status_counts` | `object` | Yes | Number of links per status, honouring q and project_id (not status) |
| `status_counts.all / status_counts.active / status_counts.inactive / status_counts.expired / status_counts.capped / status_counts.scheduled / status_counts.blocked` | `integer` | Yes | all = every status together · the rest = links with that status (each link counts under one status, so they sum to all) |

**Errors**

- `400` status is not a supported value (active, inactive, expired, capped, scheduled, blocked) — `invalid status "archived": must be one of active, inactive, expired, capped, scheduled, blocked`

### GET /api/v1/links/:code — Get link

Retrieves one link by short_code

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | Yes | The link's short_code (e.g. `promo2026`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/links/promo2026" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — Link object

```json
{
  "short_code": "promo2026",
  "short_url": "https://go.brand.com/promo2026",
  "name": "แคมเปญ 12.12",
  "long_url": "https://shop.example.com/sale/1212",
  "project_id": 12,
  "domain_id": 3,
  "starts_at": "2026-12-01T00:00:00+07:00",
  "expires_at": "2026-12-31T23:59:59+07:00",
  "max_clicks": 5000,
  "sensitive": false,
  "has_password": false,
  "is_active": true,
  "blocked": false,
  "click_count": 1342,
  "created_at": "2026-11-01T10:12:45+07:00",
  "updated_at": "2026-11-10T15:00:00+07:00",
  "safe_checked_at": "2026-11-01T10:12:45+07:00"
}
```

**Errors**

- `404` The link does not exist or belongs to another account (including deleted links) — `link not found`

### PATCH /api/v1/links/:code — Update link

Updates an existing link as a partial update — send only the fields you want to change; every field you leave out keeps its current value

- Omitted = unchanged, for every field — there is no need to read the link with GET and send values back
- project_id, domain_id, starts_at, expires_at, max_clicks: null = cleared (removed from its project / off its custom domain, back onto a platform domain / no start time / no expiry / no click limit) · a value = set
- Changing the domain: domain_id moves the link to a custom domain (clearing platform_domain_id) · platform_domain_id moves it to that platform domain (null = the default platform domain) and off any custom domain, with no need to send domain_id: null too (sending it as well is fine) · re-sending the link's current platform_domain_id is allowed even if that domain is no longer public, but choosing any other non-public platform domain returns 400
- Clear a time with null only, never an empty string "" — "" or a time that is not RFC 3339 returns 400 invalid json body (code invalid_json)
- Only fields whose value changes are validated (re-sending the stored value counts as unchanged): a new expires_at must be in the future · the starts_at / expires_at order is checked when either one changes (expires_at must be after starts_at) · a new max_clicks must be ≥ 1 — so an expiry that has already passed can stay while you rename the link or change its starts_at or max_clicks · except name, which is checked whenever it is sent (it cannot be "", even for a link that has no name — leave name out unless you are renaming)
- Concurrent PATCH requests are safe — a field one request did not send is never overwritten with a stale value (when two requests change the same field, the later one wins)
- After short_code changes, the old code stops working immediately; existing stats move to the new code

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | Yes | The link's short_code (e.g. `Xk9pQ2a`) |

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `short_code` | `string` | No | Changes the link's short code — 3–32 characters: a-z A-Z 0-9 - _ · reserved words are not allowed (e.g. api, admin, login, dashboard, links, pricing) |
| `name` | `string` | No | Link name — 1–100 characters (cannot be empty) |
| `url` | `string` | No | New destination (re-checked with Safe Browsing) — Must start with http:// or https:// and include a host — must not point back to Go2Web's own domain (prevents redirect loops) |
| `project_id` | `integer \| null` | No | Move into a project (must be one of yours) — omitted = unchanged · null = removed from its project |
| `domain_id` | `integer \| null` | No | Attached custom domain (must be verified) — omitted = unchanged · null = off the custom domain, onto a platform domain (platform_domain_id, or the default one) |
| `platform_domain_id` | `integer \| null` | No | Move to this platform domain, off any custom domain — omitted = unchanged · null = the default platform domain — An id from GET /api/v1/platform-domains — except the link's current platform domain, which may be re-sent even if it is no longer public |
| `starts_at` | `string (RFC 3339) \| null` | No | Activation time — omitted = unchanged · null = cleared — e.g. 2026-12-01T00:00:00+07:00 · clear with null (never "") |
| `expires_at` | `string (RFC 3339) \| null` | No | Expiry time — omitted = unchanged · null = cleared (never expires) — Must be in the future and after starts_at · clear with null (never "") |
| `max_clicks` | `integer \| null` | No | Click limit — omitted = unchanged · null = cleared (no limit) — At least 1 |
| `sensitive` | `boolean` | No | Show a preview page before redirecting — omitted = unchanged |
| `password` | `string` | No | Set a new password · "" = remove the password · omitted = unchanged — At most 72 bytes in UTF-8 (72 Latin letters, about 24 Thai characters) |
| `is_active` | `boolean` | No | Temporarily enable/disable the link — omitted = unchanged |

**Example request**

```bash
curl -X PATCH "https://api.go2w.co/api/v1/links/Xk9pQ2a" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "โปรโมชันปีใหม่ (แก้ไข)",
  "expires_at": "2030-12-31T23:59:59+07:00",
  "max_clicks": null
}'
```

**Response** `200 OK` — The updated Link object — fields cleared with null (e.g. max_clicks) are absent from the JSON

```json
{
  "short_code": "Xk9pQ2a",
  "short_url": "https://go2w.co/Xk9pQ2a",
  "name": "โปรโมชันปีใหม่ (แก้ไข)",
  "long_url": "https://example.com/campaign/new-year?utm_source=line",
  "expires_at": "2030-12-31T23:59:59+07:00",
  "sensitive": false,
  "has_password": false,
  "is_active": true,
  "blocked": false,
  "click_count": 0,
  "created_at": "2026-11-12T16:30:00+07:00",
  "updated_at": "2026-11-12T17:05:00+07:00",
  "safe_checked_at": "2026-11-12T16:30:00+07:00"
}
```

**Errors**

- `400` short_code has an invalid format — `short code must be 3-32 chars: letters, digits, - or _`
- `400` name is empty or longer than 100 characters — `name is required (1-100 chars)`
- `400` url is sent as an empty string — `url is required`
- `400` url cannot be parsed — `url is not parseable`
- `400` url is not http(s) (including a url with no scheme) — `url must be http or https`
- `400` url has no host — `url must have a host`
- `400` url points to Go2Web's own domain — `cannot shorten this service's own domain`
- `400` The new expires_at is not in the future — `expires_at must be in the future`
- `400` starts_at or expires_at changed and expires_at is not after starts_at — `expires_at must be after starts_at`
- `400` The new max_clicks is less than 1 — `max_clicks must be at least 1`
- `400` password is longer than 72 bytes — `password must be at most 72 bytes in UTF-8`
- `400` project_id does not exist or belongs to another account — `project not found`
- `400` domain_id does not exist or belongs to another account — `domain not found`
- `400` The domain is not verified yet — `domain is not verified yet` · code `domain_not_verified`
- `400` platform_domain_id does not exist or is not public (not in List platform domains) — `platform domain not found or not available`
- `404` The link does not exist or belongs to another account (including deleted links) — `link not found`
- `409` The new short_code is already taken — `short code already taken` · code `alias_taken`
- `409` The new short_code is a reserved word — `short code is reserved` · code `alias_reserved`
- `422` Google Safe Browsing reports the destination as unsafe — `destination is flagged as unsafe by Google Safe Browsing (MALWARE)` · code `unsafe_url`

### DELETE /api/v1/links/:code — Delete link

Deletes a link — the short link stops working immediately and disappears from your list

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | Yes | The link's short_code (e.g. `Xk9pQ2a`) |

**Example request**

```bash
curl -X DELETE "https://api.go2w.co/api/v1/links/Xk9pQ2a" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `204 No Content` — Deleted (no body)

**Errors**

- `404` The link does not exist or belongs to another account (including deleted links) — `link not found`

### POST /api/v1/links/bulk — Bulk create links

Creates up to 100 short links per request (each gets a random short code). Each URL is validated separately; a failing item gets its own error without failing the whole batch

- Empty strings (after trimming) are skipped and do not appear in results
- Only urls, project_id and domain_id / platform_domain_id can be set in bulk — set a custom short code (alias), name, password, sensitive, starts_at / expires_at or max_clicks later with PATCH /api/v1/links/:code (the short code goes in the short_code field)
- If the remaining monthly link quota is smaller than the number of URLs, the excess items get their own error (code link_quota_exceeded) and the rest are still created

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `urls` | `string[]` | Yes | Destination URLs — 1–100 items · each is validated like url |
| `project_id` | `integer` | No | Put every link in this project |
| `domain_id` | `integer` | No | Use this custom domain for every link (must be verified) — cannot be combined with platform_domain_id · neither = the default platform domain |
| `platform_domain_id` | `integer` | No | Use this platform domain for every link — cannot be combined with domain_id — An id from GET /api/v1/platform-domains (a public platform domain) |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/links/bulk" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "urls": [
    "https://example.com/products/1",
    "https://example.com/products/2",
    "ftp://example.com/file"
  ]
}'
```

**Response** `201 Created` — Per-item results, in request order

```json
{
  "results": [
    {
      "url": "https://example.com/products/1",
      "short_code": "Lm4tR8z",
      "short_url": "https://go2w.co/Lm4tR8z"
    },
    {
      "url": "https://example.com/products/2",
      "short_code": "b7QwE2n",
      "short_url": "https://go2w.co/b7QwE2n"
    },
    {
      "url": "ftp://example.com/file",
      "error": "url must be http or https",
      "code": "bad_request"
    }
  ]
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `results[].url` | `string` | Yes | The submitted URL (whitespace-trimmed) |
| `results[].short_code` | `string` | No | The created short code (present on success) |
| `results[].short_url` | `string` | No | Short URL (present on success) |
| `results[].error` | `string` | No | English error message (present on failure) |
| `results[].code` | `string` | No | Error code of a failed item (the same codes as an error body), e.g. link_quota_exceeded = monthly quota used up, unsafe_url = unsafe destination, bad_request = invalid URL — branch on it, not on the message |

**Errors**

- `400` urls is missing or empty — `urls is required`
- `400` urls has more than 100 items — `too many urls (max 100)`
- `400` project_id does not exist or belongs to another account — `project not found`
- `400` domain_id does not exist or belongs to another account — `domain not found`
- `400` The domain is not verified yet — `domain is not verified yet` · code `domain_not_verified`
- `400` platform_domain_id does not exist or is not public (not in List platform domains) — `platform domain not found or not available`
- `400` A custom domain_id was sent together with a non-null platform_domain_id — `domain_id and platform_domain_id are mutually exclusive`
- `403` The plan's monthly link quota is used up (resets on the 1st of each month, Bangkok time) — `monthly link quota reached: the Pro plan includes 2000 new links per month (resets on the 1st, Bangkok time); upgrade or wait for next month` · code `link_quota_exceeded`

### POST /api/v1/links/bulk-update — Bulk move links

Moves multiple links to another project and/or domain at once. Only the fields you send change; each link's other values stay as they are

- Send at least one of project_id, domain_id or platform_domain_id — project_id null = remove links from their project · domain_id null or platform_domain_id = move them onto a platform domain (platform_domain_id null or omitted = the default one), off any custom domain
- Codes that are not found or not yours are skipped (not counted in updated)
- Moving links to a custom domain keeps their old short URLs working: a link still resolves on its previous platform domain and on every verified domain of your account (another account's domains never serve your links) · Moving them to another platform domain stops their URLs on the previous platform domain — except links created before platform domains existed, which resolve on every platform domain

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `codes` | `string[]` | Yes | short_codes of the links to move — 1–500 items |
| `project_id` | `integer \| null` | No | Target project · null = remove from their project |
| `domain_id` | `integer \| null` | No | Target custom domain (must be verified) · null = a platform domain (platform_domain_id, or the default one) |
| `platform_domain_id` | `integer \| null` | No | Target platform domain (links leave their custom domain) · null = the default platform domain — An id from GET /api/v1/platform-domains (a public platform domain) |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/links/bulk-update" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "codes": [
    "Xk9pQ2a",
    "promo2026"
  ],
  "project_id": 12
}'
```

**Response** `200 OK` — Number of links updated

```json
{
  "updated": 2
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `updated` | `integer` | Yes | Number of matched links (yours, not deleted) — a link already at the target value still counts |

**Errors**

- `400` codes is missing or empty — `codes is required`
- `400` codes has more than 500 items — `too many codes (max 500)`
- `400` None of project_id, domain_id or platform_domain_id was sent — `project_id, domain_id or platform_domain_id is required`
- `400` project_id does not exist or belongs to another account — `project not found`
- `400` domain_id does not exist or belongs to another account — `domain not found`
- `400` The domain is not verified yet — `domain is not verified yet` · code `domain_not_verified`
- `400` platform_domain_id does not exist or is not public (not in List platform domains) — `platform domain not found or not available`

### POST /api/v1/links/bulk-delete — Bulk delete links

Deletes up to 500 links per request · codes that are not found or not yours are skipped

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `codes` | `string[]` | Yes | short_codes of the links to delete — 1–500 items |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/links/bulk-delete" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "codes": [
    "Xk9pQ2a",
    "old-promo"
  ]
}'
```

**Response** `200 OK` — Number of links deleted

```json
{
  "deleted": 2
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `deleted` | `integer` | Yes | Number of links actually deleted |

**Errors**

- `400` codes is missing or empty — `codes is required`
- `400` codes has more than 500 items — `too many codes (max 500)`

## Stats & Targeting Rules

Per-link click stats, and redirect rules by country / device / language / referer / user agent / IP

### Targeting rule object

A redirect condition — the first matching rule (in array order) is used; if no rule matches, visitors go to long_url · a rule never matches when the visitor sends no value for its dimension (e.g. no Referer), except not_in, which counts a missing value as not in the list (country not_in TH also takes visitors whose country is unknown, bot not_in google takes every person)

| Name | Type | Required | Description |
|---|---|---|---|
| `dimension` | `string` | Yes | What to match on — user_agent \| device (mobile, tablet, desktop, other, unknown) \| referer (the full Referer header URL, e.g. https://l.facebook.com/… — use contains / starts_with) \| language (e.g. th, en) \| country (ISO 3166-1, e.g. TH — several countries: in with TH,LA) \| ip \| bot (a crawler identified from the User-Agent: google, facebook — equals / in / not_in only · LINE / iMessage previews send the Facebook UA, so they count as facebook) |
| `operator` | `string` | Yes | How to compare (case-insensitive, except regex) — contains \| equals \| starts_with \| ends_with \| regex \| in (comma-separated values) \| not_in (not in the comma-separated values — e.g. country not_in TH = every country except Thailand) |
| `value` | `string` | Yes | Value to compare against — Must not be empty · regex must be valid RE2 syntax |
| `target_url` | `string` | Yes | Destination when this rule matches — Must start with http:// or https:// and include a host — must not point back to Go2Web's own domain (prevents redirect loops) |

### GET /api/v1/links/:code/stats — Get link stats

Daily clicks and breakdowns by device / browser / referrer / country for a date range (days cut in the Asia/Bangkok timezone, UTC+7)

- Without from/to = the last 30 days up to today · from > to is swapped · maximum range 366 days · unparseable values fall back to the default
- from and to in the response are the effective range after adjustment
- day / hour are the bucket start as Bangkok wall-clock time with a Z suffix (not a real UTC instant — do not convert), e.g. "2026-11-10T00:00:00Z" = Nov 10 and "2026-11-12T21:00:00Z" = 21:00–21:59 Bangkok time · all keys are lowercase (day, hour, clicks)
- daily, devices, os, browsers, referrers, countries, user_agents are [] (not null) when there are no clicks · hourly is null for a multi-day range ([] for a single day without clicks) · each breakdown lists every value with a click (no top-N cut), most clicks first
- Bot / link-preview clicks (including curl, Postman and scripts) are counted separately in bot_clicks and excluded from every other number

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | Yes | The link's short_code (e.g. `Xk9pQ2a`) |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `from` | `string (YYYY-MM-DD)` | No | Start date (inclusive) — omitted = 29 days before to (e.g. `2026-11-06`) |
| `to` | `string (YYYY-MM-DD)` | No | End date (inclusive) — omitted = today (Bangkok time) (e.g. `2026-11-12`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/links/Xk9pQ2a/stats" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — Stats for the selected range

```json
{
  "short_code": "Xk9pQ2a",
  "from": "2026-10-14",
  "to": "2026-11-12",
  "daily": [
    {
      "day": "2026-11-10T00:00:00Z",
      "clicks": 42
    },
    {
      "day": "2026-11-11T00:00:00Z",
      "clicks": 57
    },
    {
      "day": "2026-11-12T00:00:00Z",
      "clicks": 18
    }
  ],
  "hourly": null,
  "devices": [
    {
      "label": "mobile",
      "count": 88
    },
    {
      "label": "desktop",
      "count": 29
    }
  ],
  "os": [
    {
      "label": "ios",
      "count": 47
    },
    {
      "label": "android",
      "count": 41
    },
    {
      "label": "windows",
      "count": 23
    },
    {
      "label": "macos",
      "count": 6
    }
  ],
  "browsers": [
    {
      "label": "chrome",
      "count": 61
    },
    {
      "label": "safari",
      "count": 40
    },
    {
      "label": "other",
      "count": 16
    }
  ],
  "referrers": [
    {
      "label": "(direct)",
      "count": 70
    },
    {
      "label": "l.facebook.com",
      "count": 47
    }
  ],
  "countries": [
    {
      "label": "TH",
      "count": 110
    },
    {
      "label": "JP",
      "count": 7
    }
  ],
  "user_agents": [
    {
      "label": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/14.19.1",
      "count": 47
    },
    {
      "label": "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
      "count": 41
    },
    {
      "label": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
      "count": 27
    },
    {
      "label": "(unknown)",
      "count": 2
    }
  ],
  "bot_clicks": 5
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `short_code` | `string` | Yes | The link's short code |
| `from / to` | `string (YYYY-MM-DD)` | Yes | Effective date range (both days inclusive) |
| `daily` | `{day, clicks}[]` | Yes | Daily clicks — only days with clicks |
| `hourly` | `{hour, clicks}[] \| null` | Yes | Hourly clicks (only hours with clicks) when a single day is requested (from = to) · null for multi-day ranges |
| `devices` | `{label, count}[]` | Yes | mobile, tablet, desktop, tv (smart TV), console (game console), app (a native app's own request, not a browser), other (unidentified client), unknown |
| `os` | `{label, count}[]` | Yes | Operating system (from the user agent): ios, android, windows, macos, linux, chromeos, other, unknown · an iPad sending a Mac user agent (iPadOS 13+) counts as macos |
| `browsers` | `{label, count}[]` | Yes | chrome, safari, firefox, edge, other |
| `referrers` | `{label, count}[]` | Yes | Referrer host, or (direct) |
| `countries` | `{label, count}[]` | Yes | ISO 3166-1 country code, e.g. TH, or (unknown) |
| `user_agents` | `{label, count}[]` | Yes | The raw User-Agent header of the clicks (at most 512 characters), one row per distinct value · (unknown) = a click without a user agent, or from before user agents were stored |
| `bot_clicks` | `integer` | Yes | Bot / link-preview clicks in this range |

**Errors**

- `404` The link does not exist or belongs to another account (including deleted links) — `link not found`

### GET /api/v1/links/:code/rules — Get targeting rules

The link's targeting rules in priority order (the first matching rule is used)

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | Yes | The link's short_code (e.g. `Xk9pQ2a`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/links/Xk9pQ2a/rules" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — rules (array of Targeting rules — [] when none) · see fields in Targeting rule object

```json
{
  "rules": [
    {
      "dimension": "bot",
      "operator": "in",
      "value": "google,facebook",
      "target_url": "https://example.com/preview"
    },
    {
      "dimension": "country",
      "operator": "in",
      "value": "JP,KR",
      "target_url": "https://example.com/asia"
    },
    {
      "dimension": "device",
      "operator": "equals",
      "value": "mobile",
      "target_url": "https://m.example.com"
    }
  ]
}
```

**Errors**

- `404` The link does not exist or belongs to another account (including deleted links) — `link not found`

### PUT /api/v1/links/:code/rules — Replace targeting rules

Replaces the link's entire rule set with the one you send; array order is the priority

- Send "rules": [] to remove all rules — omitting rules or sending null returns 400 (so rules are never deleted by accident)
- Example: Google / Facebook bots go to /preview, visitors from Japan/Korea go to /asia, mobile users go to m.example.com, everyone else goes to long_url — put bot rules before country rules, since bots have a country too (Googlebot mostly crawls from the US)
- You can also set rules when creating the link, with the rules field of POST /links

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | Yes | The link's short_code (e.g. `Xk9pQ2a`) |

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `rules` | `Targeting rule[]` | Yes | The link's complete rule set (see the fields in Targeting rule object) |

**Example request**

```bash
curl -X PUT "https://api.go2w.co/api/v1/links/Xk9pQ2a/rules" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "rules": [
    {
      "dimension": "bot",
      "operator": "in",
      "value": "google,facebook",
      "target_url": "https://example.com/preview"
    },
    {
      "dimension": "country",
      "operator": "in",
      "value": "JP,KR",
      "target_url": "https://example.com/asia"
    },
    {
      "dimension": "device",
      "operator": "equals",
      "value": "mobile",
      "target_url": "https://m.example.com"
    }
  ]
}'
```

**Response** `204 No Content` — Saved (no body)

**Errors**

- `400` rules is missing or null — `rules is required`
- `400` Unsupported dimension — `invalid dimension: city`
- `400` Unsupported operator — `invalid operator: like`
- `400` value is empty — `rule value is required`
- `400` Invalid regex — `invalid regex: ([a-z`
- `400` Unknown bot (google and facebook are supported) — `unknown bot "bing" (want one of: facebook, google)`
- `400` A bot rule uses an operator other than equals / in / not_in — `bot rules take the equals, in or not_in operator`
- `400` More than 100 rules — `too many rules (max 100)`
- `400` Invalid target_url — `target_url: url must be http or https`
- `404` The link does not exist or belongs to another account (including deleted links) — `link not found`

## Projects

Group links into projects or campaigns

### Project object

Groups links by campaign, client or channel

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Project ID |
| `name` | `string` | Yes | Project name |
| `link_count` | `integer` | Yes | Number of links in the project |
| `created_at` | `string (RFC 3339)` | Yes | Creation time |

### GET /api/v1/projects — List projects

Lists all projects with their link counts, newest first (no pagination)

**Example request**

```bash
curl "https://api.go2w.co/api/v1/projects" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — projects (array of Projects — [] when none) · see fields in Project object

```json
{
  "projects": [
    {
      "id": 12,
      "name": "แคมเปญ SMS",
      "link_count": 24,
      "created_at": "2026-11-12T16:30:00+07:00"
    }
  ]
}
```

### POST /api/v1/projects — Create project

Creates a project; use its id as project_id when creating/moving links

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | Yes | Project name — 1–80 bytes (UTF-8) after trimming — every Thai code point, including vowel and tone marks, is 3 bytes |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/projects" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "แคมเปญ SMS"
}'
```

**Response** `201 Created` — The created Project object

```json
{
  "id": 12,
  "name": "แคมเปญ SMS",
  "link_count": 0,
  "created_at": "2026-11-12T16:30:00+07:00"
}
```

**Errors**

- `400` name is empty or too long — `name is required (1-80 chars)`

### PATCH /api/v1/projects/:id — Rename project

Renames a project

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Project ID — a non-numeric id returns 400 `invalid project id` (e.g. `12`) |

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | `string` | Yes | New name — 1–80 bytes (UTF-8) after trimming — every Thai code point, including vowel and tone marks, is 3 bytes |

**Example request**

```bash
curl -X PATCH "https://api.go2w.co/api/v1/projects/12" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "แคมเปญ SMS ธันวาคม"
}'
```

**Response** `204 No Content` — Saved (no body)

**Errors**

- `400` id is not a number — `invalid project id`
- `400` name is empty or too long — `name is required (1-80 chars)`
- `404` The project does not exist or belongs to another account — `project not found`

### DELETE /api/v1/projects/:id — Delete project

Deletes a project — its links are not deleted; they just no longer belong to a project

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Project ID — a non-numeric id returns 400 `invalid project id` (e.g. `12`) |

**Example request**

```bash
curl -X DELETE "https://api.go2w.co/api/v1/projects/12" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `204 No Content` — Deleted (no body)

**Errors**

- `400` id is not a number — `invalid project id`
- `404` The project does not exist or belongs to another account — `project not found`

## Bio Links

Linktree-style bio link pages ("link in bio"): one page per handle at https://go2w.co/@handle with link buttons, headings, social icons, product cards, images, image galleries, PromptPay QR codes and LINE OA add-friend buttons, on the domain and in the font you choose, each block optionally scheduled, plus page-view, traffic-source and per-button click stats

### Bio page object

A single bio link page ("link in bio"), public at https://go2w.co/@handle — every field is always present except items, which only single-page responses include (not List bio pages)

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Page ID (used as :id in paths) |
| `handle` | `string` | Yes | The name in the page URL (lowercase) — unique across the whole system |
| `url` | `string` | Yes | Public URL on the page's domain + /@handle — its custom domain (domain_id), e.g. https://go.brand.com/@myshop, otherwise its platform domain (platform_domain_id, or the default one, e.g. https://go2w.co/@myshop) · its host is always the page's real domain |
| `domain_id` | `integer \| null` | Yes | The custom domain the page is on (null = not on a custom domain) |
| `platform_domain_id` | `integer \| null` | Yes | The platform domain the page is on (see GET /api/v1/platform-domains) — null when the page is on a custom domain, and for an old page that never chose a domain (it is on the current default platform domain) · it can be a domain that is no longer public |
| `title` | `string` | Yes | Display name on the page (also the \<title>, and the og:title when og_title is not in effect) |
| `bio` | `string` | Yes | Text under the name ("" if not set) |
| `avatar_url` | `string` | Yes | Profile image URL ("" = an initials circle in accent_color) |
| `cover_url` | `string` | Yes | Cover image URL, shown as a full-width banner at the top of the page ("" = no cover) — every plan |
| `theme` | `string` | Yes | Background / text color theme: light \| dark \| cream \| mint \| midnight |
| `accent_color` | `string` | Yes | Button color, #rrggbb (the button text is white or dark automatically, whichever reads better) |
| `meta_pixel_id` | `string` | Yes | Meta Pixel ID (Facebook / Instagram), 10–20 digits ("" = not set) |
| `tiktok_pixel_id` | `string` | Yes | TikTok Pixel ID, uppercase ("" = not set) |
| `ga4_measurement_id` | `string` | Yes | Google Analytics 4 measurement ID, e.g. G-ABC123XYZ9 ("" = not set) |
| `line_tag_id` | `string` | Yes | LINE Tag ID (LINE Ads), a lowercase UUID ("" = not set) — all four pixels run on the page only while the owner's plan includes the feature (Pro or higher) and after the visitor accepts cookies (PDPA) |
| `hide_branding` | `boolean` | Yes | Hides the "Made with Go2Web" footer — takes effect only while the owner's plan includes the feature (Pro or higher) · the "Report this page" link is always shown |
| `og_title` | `string` | Yes | Link-preview title, og:title ("" = title is used) |
| `og_description` | `string` | Yes | Link-preview description, og:description ("" = bio is used) |
| `og_image_url` | `string` | Yes | Link-preview image, og:image ("" = cover_url, else avatar_url is used) — og_* take effect only while the owner's plan includes the feature (Pro or higher) |
| `bg_image_url` | `string` | Yes | Full-screen background image URL behind the content ("" = the theme color) — every plan |
| `bg_overlay` | `integer` | Yes | Opacity of the theme's background color over the background image, 0–80 (%) — applies only while bg_image_url is set (default 40) |
| `layout` | `string` | Yes | Page layout: classic \| hero \| grid \| card (default classic) |
| `button_style` | `string` | Yes | Button style: filled \| outline \| soft \| shadow (default filled) |
| `button_shape` | `string` | Yes | Button and card shape: rounded \| pill \| square (default rounded) |
| `font` | `string` | Yes | Page font: default \| noto-sans-thai \| ibm-plex-sans-thai \| prompt \| kanit \| sarabun \| bai-jamjuree \| chakra-petch \| mitr \| mali \| itim \| noto-serif-thai \| pridi (default default) |
| `is_active` | `boolean` | Yes | Whether the page is on — false = /@handle returns 404 |
| `blocked` | `boolean` | Yes | Suspended by an admin (/@handle returns 404) — the owner cannot lift it |
| `blocked_reason` | `string` | Yes | Why an admin blocked the page ("" when not blocked) |
| `item_count` | `integer` | Yes | Number of items on the page (including disabled ones) |
| `created_at` | `string (RFC 3339)` | Yes | Creation time |
| `updated_at` | `string (RFC 3339)` | Yes | Last update time |
| `items` | `Bio item[]` | No | All items in page order ([] when none) — only in Create / Get / Update bio page |

### Bio item object

One block on a bio page: link = a full-width button (optionally with a thumbnail) · heading = a section title between buttons · social = an icon in the social row under the bio (every social item goes into that row in order, wherever it sits in the array) · product = a product card (square image, name, price and a "View" button) · image = a full-width image · gallery = a swipeable slider of 1–10 images (each can link somewhere of its own) · promptpay = a PromptPay QR code to scan and pay · line = a LINE add-friend button (plus a QR code on desktop) — every field is always present; fields that do not apply to the type are "" / false / null / []

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Item ID — kept across saves when you send it back in Replace bio page items |
| `type` | `string` | Yes | link \| heading \| social \| product \| image \| gallery \| promptpay \| line |
| `label` | `string` | Yes | Button / heading text / product name — social: the icon's accessible name for screen readers · image: alt text and caption · promptpay / line: the QR caption / button text ("" = the default text in the visitor's language) |
| `url` | `string` | Yes | Destination: link / social = http(s), mailto: or tel: · product / image = http(s) · line = a LINE add-friend link ("" for heading, promptpay and a non-clickable image) |
| `platform` | `string` | Yes | Icon of a social item ("" for other types) |
| `image_url` | `string` | Yes | Image of a product / image item, and the thumbnail of a link (40×40, rounded, at the left inside the button) ("" = none) |
| `price` | `string` | Yes | Price on a product card (free text) — product only ("" = no price shown) |
| `featured` | `boolean` | Yes | Highlights the button / card with an animated ring (no animation when the visitor prefers reduced motion) — link / product only |
| `starts_at` | `string (RFC 3339) \| null` | Yes | Shown on the page from this time on (null = no start limit) |
| `ends_at` | `string (RFC 3339) \| null` | Yes | Hidden from the page from this time on (null = no end limit) |
| `promptpay_id` | `string` | Yes | The PromptPay ID that receives the money (digits only: a 10-digit phone number, a 13-digit ID or a 15-digit e-wallet ID) — promptpay only |
| `amount` | `string` | Yes | The amount in baht embedded in the QR, with 2 decimals, e.g. "199.00" ("" = the payer enters the amount) — promptpay only |
| `images` | `object[]` | Yes | The gallery images in slide order — image_url = the image · url = where tapping it goes ("" = not clickable · a tap counts as a click of the item) · alt = the image description for screen readers ("" = none) — [] for other types |
| `aspect` | `string` | Yes | The gallery frame: square (1:1) \| portrait (4:5) \| landscape (16:9) — every image is cropped to fill the same frame ("" for other types) |
| `is_active` | `boolean` | Yes | false = hidden from the public page (still in the editor and the API) — an item is shown when is_active is true and the time is within starts_at / ends_at |

### GET /api/v1/bio-pages — List bio pages

Lists the account's bio pages, newest first (no pagination) — without items; get them with Get bio page

**Example request**

```bash
curl "https://api.go2w.co/api/v1/bio-pages" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — bio_pages (array of Bio pages without items — [] when none) · see fields in Bio page object

```json
{
  "bio_pages": [
    {
      "id": 7,
      "handle": "myshop",
      "url": "https://go2w.co/@myshop",
      "title": "My Shop เสื้อผ้าวินเทจ",
      "bio": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน\nสั่งซื้อทาง LINE OA หรือ Shopee ได้เลย",
      "avatar_url": "https://cdn.example.com/myshop/avatar.jpg",
      "cover_url": "https://cdn.example.com/myshop/cover.jpg",
      "theme": "cream",
      "accent_color": "#ee4d2d",
      "meta_pixel_id": "1234567890123456",
      "tiktok_pixel_id": "",
      "ga4_measurement_id": "G-ABC123XYZ9",
      "line_tag_id": "",
      "hide_branding": true,
      "og_title": "My Shop — เสื้อผ้าวินเทจคัดเกรด",
      "og_description": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน ช้อปผ่าน LINE OA หรือ Shopee",
      "og_image_url": "https://cdn.example.com/myshop/og.jpg",
      "bg_image_url": "https://cdn.example.com/myshop/background.jpg",
      "bg_overlay": 40,
      "layout": "card",
      "button_style": "outline",
      "button_shape": "pill",
      "font": "kanit",
      "domain_id": null,
      "platform_domain_id": 1,
      "is_active": true,
      "blocked": false,
      "blocked_reason": "",
      "item_count": 12,
      "created_at": "2026-11-12T16:30:00+07:00",
      "updated_at": "2026-11-12T17:05:00+07:00"
    }
  ]
}
```

### POST /api/v1/bio-pages — Create bio page

Creates a bio page (with no items yet), live at once at https://go2w.co/@handle — then add buttons with Replace bio page items

- Pages per account by plan: Free 1 · Starter 5 · Pro 10 · Business 25 · Enterprise unlimited (see usage.bio_pages / usage.max_bio_pages in GET /api/v1/me) — deleting a page frees its slot · after a downgrade, pages over the quota stay live and editable, but no new page can be created
- Without domain_id / platform_domain_id the page is on the default platform domain (https://go2w.co/@handle)
- The page's domain is chosen like a link's: domain_id (a verified custom domain, needs a plan with Custom Domains) or platform_domain_id (a domain from List platform domains), one or the other · url in the response is always on that domain · handles stay unique across the whole system (not per domain)
- The page opens at /@handle on: the custom domain it is on · its platform domain (an old page with neither domain_id nor platform_domain_id opens on every platform domain) · any other verified custom domain of the same account — any other host returns 404 · after moving a page to another platform domain, its URL on the old platform domain stops working · deleting a custom domain that has pages moves them to the default platform domain (they are not deleted)
- After a downgrade to a plan without Custom Domains, a page already on a custom domain can stay there, but it cannot move to another custom domain (403 plan_feature_required)
- handle is unique across the whole system — sending "MyShop" gives back "myshop" (it is lowercased for you)
- Tracking pixels (meta_pixel_id, tiktok_pixel_id, ga4_measurement_id, line_tag_id) need the Pro plan or higher (Pro, Business, Enterprise) — send only the ID and the platform adds each provider's standard code (no custom script / HTML, no Google Tag Manager) · on a plan without the feature, setting a new value returns 403 plan_feature_required, while "" (clear) or the stored value is always accepted
- Pixels run on the page only while the page owner's current plan includes the feature — after a downgrade the IDs are kept and still returned, but they are not loaded on the page; upgrading again turns them back on
- PDPA: a page with active pixels shows a cookie consent banner naming the active providers (Meta, TikTok, Google, LINE) — no provider script loads before the visitor clicks "Accept" · the answer is remembered per page in that browser and covers only the providers named in the banner when it was given (a provider added later asks again); it can be changed from the "Cookie settings" link in the footer · "Decline" or JavaScript turned off = no pixel loads at all · ad blockers can block pixels, so pixel counts may be lower than Go2Web stats
- Events sent (after the visitor accepts): page open = a page view for each active provider (Meta PageView · TikTok page · GA4 page_view · LINE pv) · button or icon click = Meta BioLinkClick (custom event, content_name = button text) · TikTok ClickButton (content_name = button text) · GA4 select_content (content_type = bio_link, content_id = item id, link_text = button text) · LINE conversion type BioLinkClick
- Removing branding and custom link previews (hide_branding, og_title, og_description, og_image_url) need the Pro plan or higher (Pro, Business, Enterprise) — on a plan without the feature, setting hide_branding: true or an og_* field to a new non-empty value returns 403 plan_feature_required, while false / "" (clear) or the stored value is always accepted · after a downgrade the values are kept and still returned, but the page shows "Made with Go2Web" and the default preview again; upgrading again brings the stored values back
- Link preview when the page is shared (the og:title / og:description / og:image that LINE, Facebook and X read): title = og_title if set, else title · description = og_description, else bio · image = og_image_url, else cover_url, else avatar_url (og_* apply only while the current plan includes the feature) — chat apps cache previews on their side, so a change may take a while to show
- Every public page has a "Report this page" link in the footer to /@handle/report (a form where visitors report scams / phishing, impersonation, illegal content, spam or other abuse) — it is always shown, even with hide_branding (like the "Cookie settings" link), and it is not an API endpoint · Go2Web reviews reports and may block the page (blocked: true with the reason in blocked_reason)

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `handle` | `string` | Yes | The name in the page URL (/@handle) — 3–30 characters: a-z 0-9 - _, starting with a letter or digit · lowercased for you · unique across the whole system · reserved words are not allowed (system paths such as api, admin, login, bio, plus brand / impersonation names such as go2web, official, line, shopee, facebook, bank) |
| `title` | `string` | Yes | Display name on the page, e.g. your shop name — 1–80 characters after trimming (Unicode code points — each Thai vowel / tone mark counts as one) |
| `bio` | `string` | No | Text under the name — omitted = "" — Up to 300 characters · line breaks (\n) are kept |
| `avatar_url` | `string` | No | Profile image URL — omitted = "" (an initials circle) — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) |
| `cover_url` | `string` | No | Cover image URL (top banner) — omitted = "" (no cover) — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) · shown as a full-width banner at the top of the page (about 3:1, cropped to fill) — every plan |
| `theme` | `string` | No | Page color theme — omitted = light — light \| dark \| cream \| mint \| midnight |
| `accent_color` | `string` | No | Button color — omitted = #4f46e5 — A #rrggbb hex color, e.g. #4f46e5 (stored lowercase) |
| `domain_id` | `integer` | No | Publish the page on one of your custom domains (must be verified · needs a plan with Custom Domains — Starter or higher) — cannot be combined with platform_domain_id · neither = the default platform domain |
| `platform_domain_id` | `integer` | No | Publish the page on this platform domain — cannot be combined with domain_id · neither = the default platform domain — An id from GET /api/v1/platform-domains (a public platform domain) |
| `bg_image_url` | `string` | No | Background image URL — omitted = "" (the theme color) — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) · fills the screen behind the content (cropped to cover, stays put while the page scrolls) — every plan |
| `bg_overlay` | `integer` | No | Overlay strength over the background image — omitted (or null) = 40 · 0 = no overlay — 0–80 (%) — opacity of the theme's background color laid over the background image so text stays readable · 0 = no overlay · applies only while bg_image_url is set |
| `layout` | `string` | No | Page layout — omitted or "" = classic — classic \| hero \| grid \| card — classic = the original look · hero = a 200px cover band (no cover_url = a gradient of accent_color) with a bigger profile image and name · grid = consecutive non-featured link items form a 2-column grid (image_url shown on top), other item types and featured links span the full width · card = the content sits on a centered panel · trimmed and lowercased for you |
| `button_style` | `string` | No | Button style — omitted or "" = filled — filled \| outline \| soft \| shadow — filled = accent_color background (the original) · outline = a 2px accent border · soft = a light accent tint · shadow = a surface background, a border in the text color and a hard accent shadow · applies to link buttons and the product card button (the LINE button stays LINE green in every style) · trimmed and lowercased for you |
| `button_shape` | `string` | No | Button shape — omitted or "" = rounded — rounded \| pill \| square — rounded = rounded corners (the original) · pill = fully rounded · square = square corners · applies to buttons and cards (product / image / promptpay) alike · trimmed and lowercased for you |
| `font` | `string` | No | Page font — omitted or "" = default — default \| noto-sans-thai \| ibm-plex-sans-thai \| prompt \| kanit \| sarabun \| bai-jamjuree \| chakra-petch \| mitr \| mali \| itim \| noto-serif-thai \| pridi — default = the visitor's system font (the original) · the others are Thai + Latin fonts (SIL Open Font License): Noto Sans Thai, IBM Plex Sans Thai, Prompt, Kanit, Sarabun, Bai Jamjuree (sans) · Chakra Petch (techy) · Mitr, Mali (rounded) · Itim (handwriting — one weight) · Noto Serif Thai, Pridi (serif) — Go2Web serves the font files itself from the page's own domain (never from Google Fonts, so visitor IPs are not shared with a third party) · trimmed and lowercased for you |
| `meta_pixel_id` | `string` | No | Meta Pixel ID (Facebook / Instagram) — Pro plan or higher — omitted = "" — "" or 10–20 digits · surrounding spaces are trimmed |
| `tiktok_pixel_id` | `string` | No | TikTok Pixel ID — Pro plan or higher — omitted = "" — "" or 15–30 letters A-Z / digits · trimmed and uppercased for you |
| `ga4_measurement_id` | `string` | No | Google Analytics 4 measurement ID — Pro plan or higher — omitted = "" — "" or G- followed by 4–20 letters A-Z / digits, e.g. G-ABC123XYZ9 · trimmed and uppercased for you |
| `line_tag_id` | `string` | No | LINE Tag ID (LINE Ads) — Pro plan or higher — omitted = "" — "" or a UUID, e.g. 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d · trimmed and lowercased for you |
| `hide_branding` | `boolean` | No | Hide the "Made with Go2Web" footer — Pro plan or higher — omitted = false — true needs the Pro plan or higher |
| `og_title` | `string` | No | Link-preview title (og:title) instead of title — Pro plan or higher — omitted = "" — Up to 80 characters (Unicode code points) · a non-empty value needs the Pro plan or higher |
| `og_description` | `string` | No | Link-preview description (og:description) instead of bio — Pro plan or higher — omitted = "" — Up to 200 characters (Unicode code points) · a non-empty value needs the Pro plan or higher |
| `og_image_url` | `string` | No | Link-preview image (og:image), separate from cover_url / avatar_url — Pro plan or higher — omitted = "" — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) · a non-empty value needs the Pro plan or higher |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/bio-pages" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "handle": "myshop",
  "title": "My Shop เสื้อผ้าวินเทจ",
  "bio": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน\nสั่งซื้อทาง LINE OA หรือ Shopee ได้เลย",
  "theme": "cream",
  "accent_color": "#ee4d2d"
}'
```

**Response** `201 Created` — The created Bio page (items: []) · see fields in Bio page object

```json
{
  "id": 7,
  "handle": "myshop",
  "url": "https://go2w.co/@myshop",
  "title": "My Shop เสื้อผ้าวินเทจ",
  "bio": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน\nสั่งซื้อทาง LINE OA หรือ Shopee ได้เลย",
  "avatar_url": "",
  "cover_url": "",
  "theme": "cream",
  "accent_color": "#ee4d2d",
  "meta_pixel_id": "",
  "tiktok_pixel_id": "",
  "ga4_measurement_id": "",
  "line_tag_id": "",
  "hide_branding": false,
  "og_title": "",
  "og_description": "",
  "og_image_url": "",
  "bg_image_url": "",
  "bg_overlay": 40,
  "layout": "classic",
  "button_style": "filled",
  "button_shape": "rounded",
  "font": "default",
  "domain_id": null,
  "platform_domain_id": 1,
  "is_active": true,
  "blocked": false,
  "blocked_reason": "",
  "item_count": 0,
  "created_at": "2026-11-12T16:30:00+07:00",
  "updated_at": "2026-11-12T16:30:00+07:00",
  "items": []
}
```

**Errors**

- `400` handle has an invalid format — `handle must be 3-30 chars: lowercase letters, digits, - or _, starting with a letter or digit`
- `400` title is empty or longer than 80 characters — `title is required (1-80 chars)`
- `400` bio is longer than 300 characters — `bio must be at most 300 chars`
- `400` avatar_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `avatar_url must be an https URL`
- `400` cover_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `cover_url must be an https URL`
- `400` Unsupported theme — `theme must be one of: light, dark, cream, mint, midnight`
- `400` accent_color is not #rrggbb — `accent_color must be a hex color like #4f46e5`
- `400` meta_pixel_id is neither "" nor 10–20 digits — `meta_pixel_id must be 10-20 digits`
- `400` tiktok_pixel_id is neither "" nor 15–30 letters / digits — `tiktok_pixel_id must be 15-30 letters or digits`
- `400` ga4_measurement_id is neither "" nor G- followed by 4–20 letters / digits — `ga4_measurement_id must look like G-XXXXXXXXXX`
- `400` line_tag_id is neither "" nor a UUID — `line_tag_id must be a UUID like 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d`
- `400` og_title is longer than 80 characters — `og_title must be at most 80 chars`
- `400` og_description is longer than 200 characters — `og_description must be at most 200 chars`
- `400` og_image_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `og_image_url must be an https URL`
- `400` bg_image_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `bg_image_url must be an https URL`
- `400` bg_overlay is below 0 or above 80 — `bg_overlay must be between 0 and 80`
- `400` Unsupported layout (including "" in Update bio page) — `layout must be one of: classic, hero, grid, card`
- `400` Unsupported button_style (including "" in Update bio page) — `button_style must be one of: filled, outline, soft, shadow`
- `400` Unsupported button_shape (including "" in Update bio page) — `button_shape must be one of: rounded, pill, square`
- `400` Unsupported font (including "" in Update bio page) — `font must be one of: default, noto-sans-thai, ibm-plex-sans-thai, prompt, kanit, sarabun, bai-jamjuree, chakra-petch, mitr, mali, itim, noto-serif-thai, pridi`
- `409` handle is a reserved word — `handle is reserved` · code `handle_reserved`
- `409` handle is already taken (by any account) — `handle already taken` · code `handle_taken`
- `400` domain_id does not exist or belongs to another account — `domain not found`
- `400` The domain is not verified yet — `domain is not verified yet` · code `domain_not_verified`
- `400` platform_domain_id does not exist or is not public (not in List platform domains) — `platform domain not found or not available`
- `400` A custom domain_id was sent together with a non-null platform_domain_id — `domain_id and platform_domain_id are mutually exclusive`
- `403` A custom domain (domain_id) is chosen on a plan without Custom Domains (Free) — a page already on that custom domain is fine — `Custom Domains require the Starter plan or higher` · code `plan_feature_required`
- `403` The plan's bio page limit is reached — `bio page limit reached: the Pro plan includes 10 bio pages; upgrade for more` · code `bio_page_limit_reached`
- `403` A tracking pixel is set to a new value on a plan without the feature (needs Pro, Business, Enterprise) — sending "" or the stored value is fine — `Tracking pixels require the Pro plan or higher` · code `plan_feature_required`
- `403` hide_branding is set to true, or og_title / og_description / og_image_url to a new non-empty value, on a plan without the feature (needs Pro, Business, Enterprise) — sending false / "" or the stored value is fine — `Removing Go2Web branding and custom link previews require the Pro plan or higher` · code `plan_feature_required`

### GET /api/v1/bio-pages/:id — Get bio page

Retrieves one bio page with all of its items in page order (including disabled items)

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Bio page ID — a non-numeric id returns 400 `invalid bio page id` (e.g. `7`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/bio-pages/7" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — Bio page with items · see fields in Bio page object

```json
{
  "id": 7,
  "handle": "myshop",
  "url": "https://go2w.co/@myshop",
  "title": "My Shop เสื้อผ้าวินเทจ",
  "bio": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน\nสั่งซื้อทาง LINE OA หรือ Shopee ได้เลย",
  "avatar_url": "https://cdn.example.com/myshop/avatar.jpg",
  "cover_url": "https://cdn.example.com/myshop/cover.jpg",
  "theme": "cream",
  "accent_color": "#ee4d2d",
  "meta_pixel_id": "1234567890123456",
  "tiktok_pixel_id": "",
  "ga4_measurement_id": "G-ABC123XYZ9",
  "line_tag_id": "",
  "hide_branding": true,
  "og_title": "My Shop — เสื้อผ้าวินเทจคัดเกรด",
  "og_description": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน ช้อปผ่าน LINE OA หรือ Shopee",
  "og_image_url": "https://cdn.example.com/myshop/og.jpg",
  "bg_image_url": "https://cdn.example.com/myshop/background.jpg",
  "bg_overlay": 40,
  "layout": "card",
  "button_style": "outline",
  "button_shape": "pill",
  "font": "kanit",
  "domain_id": null,
  "platform_domain_id": 1,
  "is_active": true,
  "blocked": false,
  "blocked_reason": "",
  "item_count": 12,
  "created_at": "2026-11-12T16:30:00+07:00",
  "updated_at": "2026-11-12T17:05:00+07:00",
  "items": [
    {
      "label": "",
      "url": "https://www.instagram.com/myshop",
      "platform": "instagram",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 11,
      "type": "social"
    },
    {
      "label": "",
      "url": "https://www.tiktok.com/@myshop",
      "platform": "tiktok",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 12,
      "type": "social"
    },
    {
      "label": "แอดไลน์สั่งซื้อ",
      "url": "https://lin.ee/aBcD123",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 13,
      "type": "line"
    },
    {
      "label": "ร้านเราบน Shopee",
      "url": "https://shopee.co.th/myshop",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/shopee-icon.png",
      "price": "",
      "featured": true,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 14,
      "type": "link"
    },
    {
      "label": "สินค้าแนะนำ",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 15,
      "type": "heading"
    },
    {
      "label": "เสื้อเชิ้ตวินเทจลายดอก",
      "url": "https://shopee.co.th/product/123456/7890123",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/shirt-01.jpg",
      "price": "฿390",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 16,
      "type": "product"
    },
    {
      "label": "แจ็กเก็ตยีนส์ ราคา 12.12",
      "url": "https://shopee.co.th/product/123456/7890456",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/jacket-01.jpg",
      "price": "฿590 (ปกติ ฿890)",
      "featured": true,
      "starts_at": "2026-12-12T00:00:00+07:00",
      "ends_at": "2026-12-13T00:00:00+07:00",
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 17,
      "type": "product"
    },
    {
      "label": "ลุคบุ๊กคอลเลกชันหน้าหนาว",
      "url": "https://www.instagram.com/p/AbCdEf123/",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/lookbook.jpg",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 18,
      "type": "image"
    },
    {
      "label": "ของเข้าใหม่สัปดาห์นี้",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [
        {
          "image_url": "https://cdn.example.com/myshop/new-01.jpg",
          "url": "https://shopee.co.th/product/123456/7890789",
          "alt": "เสื้อยีนส์วินเทจสีซีด"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-02.jpg",
          "url": "https://shopee.co.th/product/123456/7890790",
          "alt": "กระโปรงลายสก๊อต"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-03.jpg",
          "url": "",
          "alt": ""
        }
      ],
      "aspect": "portrait",
      "is_active": true,
      "id": 22,
      "type": "gallery"
    },
    {
      "label": "ติดต่อเรา",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 19,
      "type": "heading"
    },
    {
      "label": "โทรสั่งของ",
      "url": "tel:081-234-5678",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 20,
      "type": "link"
    },
    {
      "label": "โอนค่ามัดจำ",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "0812345678",
      "amount": "100.00",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 21,
      "type": "promptpay"
    }
  ]
}
```

**Errors**

- `400` id is not a number — `invalid bio page id`
- `404` The bio page does not exist or belongs to another account — `bio page not found`

### PATCH /api/v1/bio-pages/:id — Update bio page

Updates the page settings as a partial update — send only the fields to change; every field left out keeps its value (edit items with Replace bio page items)

- Omitted = unchanged, for every field · bio "" removes the text · avatar_url "" removes the image (an initials circle is shown) · cover_url "" removes the cover · bg_image_url "" removes the background image · a pixel field "" clears that pixel · an og_* field "" falls back to the page title / bio / images · bg_overlay: 0 means no overlay (a real value, not "omitted") · layout / button_style / button_shape cannot be "" (400)
- After handle changes, the old URL (the old /@handle) returns 404 at once and does not redirect to the new one — the old handle becomes free for any account · stats and items stay
- is_active: false turns the page off temporarily (/@handle returns 404) while its data and stats are kept · blocked / blocked_reason cannot be changed through the API (a blocked page can still be edited, but /@handle returns 404 until Go2Web lifts the block)
- The handle of a suspended page (blocked: true) cannot change — a new handle returns 403 bio_page_blocked and nothing in the request is saved · sending the current handle, or no handle, works as usual · so the handle stays with this page until Go2Web lifts the block
- Changing the domain (like a link's PATCH): domain_id moves the page to a custom domain · platform_domain_id moves it to that platform domain (null = the default one) and off any custom domain · domain_id: null moves it off its custom domain, onto the default platform domain
- The page's domain is chosen like a link's: domain_id (a verified custom domain, needs a plan with Custom Domains) or platform_domain_id (a domain from List platform domains), one or the other · url in the response is always on that domain · handles stay unique across the whole system (not per domain)
- The page opens at /@handle on: the custom domain it is on · its platform domain (an old page with neither domain_id nor platform_domain_id opens on every platform domain) · any other verified custom domain of the same account — any other host returns 404 · after moving a page to another platform domain, its URL on the old platform domain stops working · deleting a custom domain that has pages moves them to the default platform domain (they are not deleted)
- After a downgrade to a plan without Custom Domains, a page already on a custom domain can stay there, but it cannot move to another custom domain (403 plan_feature_required)
- Tracking pixels (meta_pixel_id, tiktok_pixel_id, ga4_measurement_id, line_tag_id) need the Pro plan or higher (Pro, Business, Enterprise) — send only the ID and the platform adds each provider's standard code (no custom script / HTML, no Google Tag Manager) · on a plan without the feature, setting a new value returns 403 plan_feature_required, while "" (clear) or the stored value is always accepted
- Pixels run on the page only while the page owner's current plan includes the feature — after a downgrade the IDs are kept and still returned, but they are not loaded on the page; upgrading again turns them back on
- PDPA: a page with active pixels shows a cookie consent banner naming the active providers (Meta, TikTok, Google, LINE) — no provider script loads before the visitor clicks "Accept" · the answer is remembered per page in that browser and covers only the providers named in the banner when it was given (a provider added later asks again); it can be changed from the "Cookie settings" link in the footer · "Decline" or JavaScript turned off = no pixel loads at all · ad blockers can block pixels, so pixel counts may be lower than Go2Web stats
- Events sent (after the visitor accepts): page open = a page view for each active provider (Meta PageView · TikTok page · GA4 page_view · LINE pv) · button or icon click = Meta BioLinkClick (custom event, content_name = button text) · TikTok ClickButton (content_name = button text) · GA4 select_content (content_type = bio_link, content_id = item id, link_text = button text) · LINE conversion type BioLinkClick
- Removing branding and custom link previews (hide_branding, og_title, og_description, og_image_url) need the Pro plan or higher (Pro, Business, Enterprise) — on a plan without the feature, setting hide_branding: true or an og_* field to a new non-empty value returns 403 plan_feature_required, while false / "" (clear) or the stored value is always accepted · after a downgrade the values are kept and still returned, but the page shows "Made with Go2Web" and the default preview again; upgrading again brings the stored values back
- Link preview when the page is shared (the og:title / og:description / og:image that LINE, Facebook and X read): title = og_title if set, else title · description = og_description, else bio · image = og_image_url, else cover_url, else avatar_url (og_* apply only while the current plan includes the feature) — chat apps cache previews on their side, so a change may take a while to show
- Every public page has a "Report this page" link in the footer to /@handle/report (a form where visitors report scams / phishing, impersonation, illegal content, spam or other abuse) — it is always shown, even with hide_branding (like the "Cookie settings" link), and it is not an API endpoint · Go2Web reviews reports and may block the page (blocked: true with the reason in blocked_reason)

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Bio page ID — a non-numeric id returns 400 `invalid bio page id` (e.g. `7`) |

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `handle` | `string` | No | New handle — the old URL stops working at once — 3–30 characters: a-z 0-9 - _, starting with a letter or digit · lowercased for you · unique across the whole system · reserved words are not allowed (system paths such as api, admin, login, bio, plus brand / impersonation names such as go2web, official, line, shopee, facebook, bank) |
| `title` | `string` | No | Display name (cannot be empty) — 1–80 characters after trimming (Unicode code points — each Thai vowel / tone mark counts as one) |
| `bio` | `string` | No | Text under the name · "" = removed — Up to 300 characters · line breaks (\n) are kept |
| `avatar_url` | `string` | No | Profile image URL · "" = removed — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) |
| `cover_url` | `string` | No | Cover image URL (top banner) · "" = removed — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) · shown as a full-width banner at the top of the page (about 3:1, cropped to fill) — every plan |
| `theme` | `string` | No | Page color theme — light \| dark \| cream \| mint \| midnight |
| `accent_color` | `string` | No | Button color — A #rrggbb hex color, e.g. #4f46e5 (stored lowercase) |
| `domain_id` | `integer \| null` | No | Move the page to this custom domain (must be verified · needs a plan with Custom Domains) — omitted = unchanged · null = off the custom domain, onto the default platform domain (or platform_domain_id) |
| `platform_domain_id` | `integer \| null` | No | Move the page to this platform domain, off any custom domain — omitted = unchanged · null = the default platform domain — An id from GET /api/v1/platform-domains — except the page's current platform domain, which may be re-sent even if it is no longer public |
| `bg_image_url` | `string` | No | Background image URL · "" = removed — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) · fills the screen behind the content (cropped to cover, stays put while the page scrolls) — every plan |
| `bg_overlay` | `integer` | No | Overlay strength over the background image (0 is a real value, not "omitted") — 0–80 (%) — opacity of the theme's background color laid over the background image so text stays readable · 0 = no overlay · applies only while bg_image_url is set |
| `layout` | `string` | No | Page layout ("" is rejected) — classic \| hero \| grid \| card — classic = the original look · hero = a 200px cover band (no cover_url = a gradient of accent_color) with a bigger profile image and name · grid = consecutive non-featured link items form a 2-column grid (image_url shown on top), other item types and featured links span the full width · card = the content sits on a centered panel · trimmed and lowercased for you |
| `button_style` | `string` | No | Button style ("" is rejected) — filled \| outline \| soft \| shadow — filled = accent_color background (the original) · outline = a 2px accent border · soft = a light accent tint · shadow = a surface background, a border in the text color and a hard accent shadow · applies to link buttons and the product card button (the LINE button stays LINE green in every style) · trimmed and lowercased for you |
| `button_shape` | `string` | No | Button shape ("" is rejected) — rounded \| pill \| square — rounded = rounded corners (the original) · pill = fully rounded · square = square corners · applies to buttons and cards (product / image / promptpay) alike · trimmed and lowercased for you |
| `font` | `string` | No | Page font ("" is rejected) — default \| noto-sans-thai \| ibm-plex-sans-thai \| prompt \| kanit \| sarabun \| bai-jamjuree \| chakra-petch \| mitr \| mali \| itim \| noto-serif-thai \| pridi — default = the visitor's system font (the original) · the others are Thai + Latin fonts (SIL Open Font License): Noto Sans Thai, IBM Plex Sans Thai, Prompt, Kanit, Sarabun, Bai Jamjuree (sans) · Chakra Petch (techy) · Mitr, Mali (rounded) · Itim (handwriting — one weight) · Noto Serif Thai, Pridi (serif) — Go2Web serves the font files itself from the page's own domain (never from Google Fonts, so visitor IPs are not shared with a third party) · trimmed and lowercased for you |
| `meta_pixel_id` | `string` | No | Meta Pixel ID (Facebook / Instagram) — Pro plan or higher · "" = cleared — "" or 10–20 digits · surrounding spaces are trimmed |
| `tiktok_pixel_id` | `string` | No | TikTok Pixel ID — Pro plan or higher · "" = cleared — "" or 15–30 letters A-Z / digits · trimmed and uppercased for you |
| `ga4_measurement_id` | `string` | No | Google Analytics 4 measurement ID — Pro plan or higher · "" = cleared — "" or G- followed by 4–20 letters A-Z / digits, e.g. G-ABC123XYZ9 · trimmed and uppercased for you |
| `line_tag_id` | `string` | No | LINE Tag ID (LINE Ads) — Pro plan or higher · "" = cleared — "" or a UUID, e.g. 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d · trimmed and lowercased for you |
| `hide_branding` | `boolean` | No | Hide the "Made with Go2Web" footer — Pro plan or higher — true needs the Pro plan or higher |
| `og_title` | `string` | No | Link-preview title (og:title) instead of title — Pro plan or higher · "" = cleared — Up to 80 characters (Unicode code points) · a non-empty value needs the Pro plan or higher |
| `og_description` | `string` | No | Link-preview description (og:description) instead of bio — Pro plan or higher · "" = cleared — Up to 200 characters (Unicode code points) · a non-empty value needs the Pro plan or higher |
| `og_image_url` | `string` | No | Link-preview image (og:image), separate from cover_url / avatar_url — Pro plan or higher · "" = cleared — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) · a non-empty value needs the Pro plan or higher |
| `is_active` | `boolean` | No | Turn the page on / off |

**Example request**

```bash
curl -X PATCH "https://api.go2w.co/api/v1/bio-pages/7" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "avatar_url": "https://cdn.example.com/myshop/avatar.jpg",
  "theme": "mint",
  "accent_color": "#06c755"
}'
```

**Response** `200 OK` — The updated Bio page with items · see fields in Bio page object

```json
{
  "id": 7,
  "handle": "myshop",
  "url": "https://go2w.co/@myshop",
  "title": "My Shop เสื้อผ้าวินเทจ",
  "bio": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน\nสั่งซื้อทาง LINE OA หรือ Shopee ได้เลย",
  "avatar_url": "https://cdn.example.com/myshop/avatar.jpg",
  "cover_url": "https://cdn.example.com/myshop/cover.jpg",
  "theme": "mint",
  "accent_color": "#06c755",
  "meta_pixel_id": "1234567890123456",
  "tiktok_pixel_id": "",
  "ga4_measurement_id": "G-ABC123XYZ9",
  "line_tag_id": "",
  "hide_branding": true,
  "og_title": "My Shop — เสื้อผ้าวินเทจคัดเกรด",
  "og_description": "เสื้อผ้าวินเทจคัดเกรด ส่งของทุกวัน ช้อปผ่าน LINE OA หรือ Shopee",
  "og_image_url": "https://cdn.example.com/myshop/og.jpg",
  "bg_image_url": "https://cdn.example.com/myshop/background.jpg",
  "bg_overlay": 40,
  "layout": "card",
  "button_style": "outline",
  "button_shape": "pill",
  "font": "kanit",
  "domain_id": null,
  "platform_domain_id": 1,
  "is_active": true,
  "blocked": false,
  "blocked_reason": "",
  "item_count": 12,
  "created_at": "2026-11-12T16:30:00+07:00",
  "updated_at": "2026-11-12T17:05:00+07:00",
  "items": [
    {
      "label": "",
      "url": "https://www.instagram.com/myshop",
      "platform": "instagram",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 11,
      "type": "social"
    },
    {
      "label": "",
      "url": "https://www.tiktok.com/@myshop",
      "platform": "tiktok",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 12,
      "type": "social"
    },
    {
      "label": "แอดไลน์สั่งซื้อ",
      "url": "https://lin.ee/aBcD123",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 13,
      "type": "line"
    },
    {
      "label": "ร้านเราบน Shopee",
      "url": "https://shopee.co.th/myshop",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/shopee-icon.png",
      "price": "",
      "featured": true,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 14,
      "type": "link"
    },
    {
      "label": "สินค้าแนะนำ",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 15,
      "type": "heading"
    },
    {
      "label": "เสื้อเชิ้ตวินเทจลายดอก",
      "url": "https://shopee.co.th/product/123456/7890123",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/shirt-01.jpg",
      "price": "฿390",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 16,
      "type": "product"
    },
    {
      "label": "แจ็กเก็ตยีนส์ ราคา 12.12",
      "url": "https://shopee.co.th/product/123456/7890456",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/jacket-01.jpg",
      "price": "฿590 (ปกติ ฿890)",
      "featured": true,
      "starts_at": "2026-12-12T00:00:00+07:00",
      "ends_at": "2026-12-13T00:00:00+07:00",
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 17,
      "type": "product"
    },
    {
      "label": "ลุคบุ๊กคอลเลกชันหน้าหนาว",
      "url": "https://www.instagram.com/p/AbCdEf123/",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/lookbook.jpg",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 18,
      "type": "image"
    },
    {
      "label": "ของเข้าใหม่สัปดาห์นี้",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [
        {
          "image_url": "https://cdn.example.com/myshop/new-01.jpg",
          "url": "https://shopee.co.th/product/123456/7890789",
          "alt": "เสื้อยีนส์วินเทจสีซีด"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-02.jpg",
          "url": "https://shopee.co.th/product/123456/7890790",
          "alt": "กระโปรงลายสก๊อต"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-03.jpg",
          "url": "",
          "alt": ""
        }
      ],
      "aspect": "portrait",
      "is_active": true,
      "id": 22,
      "type": "gallery"
    },
    {
      "label": "ติดต่อเรา",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 19,
      "type": "heading"
    },
    {
      "label": "โทรสั่งของ",
      "url": "tel:081-234-5678",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 20,
      "type": "link"
    },
    {
      "label": "โอนค่ามัดจำ",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "0812345678",
      "amount": "100.00",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 21,
      "type": "promptpay"
    }
  ]
}
```

**Errors**

- `400` id is not a number — `invalid bio page id`
- `400` handle has an invalid format — `handle must be 3-30 chars: lowercase letters, digits, - or _, starting with a letter or digit`
- `400` title is empty or longer than 80 characters — `title is required (1-80 chars)`
- `400` bio is longer than 300 characters — `bio must be at most 300 chars`
- `400` avatar_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `avatar_url must be an https URL`
- `400` cover_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `cover_url must be an https URL`
- `400` Unsupported theme — `theme must be one of: light, dark, cream, mint, midnight`
- `400` accent_color is not #rrggbb — `accent_color must be a hex color like #4f46e5`
- `400` meta_pixel_id is neither "" nor 10–20 digits — `meta_pixel_id must be 10-20 digits`
- `400` tiktok_pixel_id is neither "" nor 15–30 letters / digits — `tiktok_pixel_id must be 15-30 letters or digits`
- `400` ga4_measurement_id is neither "" nor G- followed by 4–20 letters / digits — `ga4_measurement_id must look like G-XXXXXXXXXX`
- `400` line_tag_id is neither "" nor a UUID — `line_tag_id must be a UUID like 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d`
- `400` og_title is longer than 80 characters — `og_title must be at most 80 chars`
- `400` og_description is longer than 200 characters — `og_description must be at most 200 chars`
- `400` og_image_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `og_image_url must be an https URL`
- `400` bg_image_url is neither "" nor an https:// URL with a host (including one longer than 2048 bytes) — `bg_image_url must be an https URL`
- `400` bg_overlay is below 0 or above 80 — `bg_overlay must be between 0 and 80`
- `400` Unsupported layout (including "" in Update bio page) — `layout must be one of: classic, hero, grid, card`
- `400` Unsupported button_style (including "" in Update bio page) — `button_style must be one of: filled, outline, soft, shadow`
- `400` Unsupported button_shape (including "" in Update bio page) — `button_shape must be one of: rounded, pill, square`
- `400` Unsupported font (including "" in Update bio page) — `font must be one of: default, noto-sans-thai, ibm-plex-sans-thai, prompt, kanit, sarabun, bai-jamjuree, chakra-petch, mitr, mali, itim, noto-serif-thai, pridi`
- `400` domain_id does not exist or belongs to another account — `domain not found`
- `400` The domain is not verified yet — `domain is not verified yet` · code `domain_not_verified`
- `400` platform_domain_id does not exist or is not public (not in List platform domains) — `platform domain not found or not available`
- `400` A custom domain_id was sent together with a non-null platform_domain_id — `domain_id and platform_domain_id are mutually exclusive`
- `403` A custom domain (domain_id) is chosen on a plan without Custom Domains (Free) — a page already on that custom domain is fine — `Custom Domains require the Starter plan or higher` · code `plan_feature_required`
- `404` The bio page does not exist or belongs to another account — `bio page not found`
- `403` A new handle is sent for a page that Go2Web has suspended (blocked: true) — nothing in the request is saved · sending the current handle (after lowercasing) or leaving handle out is fine — `the handle of a suspended bio page cannot be changed` · code `bio_page_blocked`
- `403` A tracking pixel is set to a new value on a plan without the feature (needs Pro, Business, Enterprise) — sending "" or the stored value is fine — `Tracking pixels require the Pro plan or higher` · code `plan_feature_required`
- `403` hide_branding is set to true, or og_title / og_description / og_image_url to a new non-empty value, on a plan without the feature (needs Pro, Business, Enterprise) — sending false / "" or the stored value is fine — `Removing Go2Web branding and custom link previews require the Pro plan or higher` · code `plan_feature_required`
- `409` handle is a reserved word — `handle is reserved` · code `handle_reserved`
- `409` handle is already taken (by any account) — `handle already taken` · code `handle_taken`

### DELETE /api/v1/bio-pages/:id — Delete bio page

Permanently deletes a bio page with all of its items — /@handle returns 404 at once, and it cannot be undone

- The handle becomes free for any account and the page slot is freed at once · to hide a page only for a while, use Update bio page with is_active: false
- A suspended page (blocked: true) cannot be deleted — it returns 403 bio_page_blocked and nothing is deleted · so its handle stays reserved (nobody can create a new page with it) until Go2Web lifts the block, after which deleting works as usual

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Bio page ID — a non-numeric id returns 400 `invalid bio page id` (e.g. `7`) |

**Example request**

```bash
curl -X DELETE "https://api.go2w.co/api/v1/bio-pages/7" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `204 No Content` — Deleted (no body)

**Errors**

- `400` id is not a number — `invalid bio page id`
- `403` The page is suspended by Go2Web (blocked: true) — nothing is deleted; deleting works again once the block is lifted — `this bio page is suspended and cannot be deleted` · code `bio_page_blocked`
- `404` The bio page does not exist or belongs to another account — `bio page not found`

### PUT /api/v1/bio-pages/:id/items — Replace bio page items

Replaces the page's entire item list with the one you send (up to 50 items); array order is the order on the page — all or nothing

- An item with an id updates that item (the id stays, so its per-button click stats stay attached) · an item without an id is new · existing items missing from the request are deleted — the safe pattern is GET the page, edit items, and send the whole list back with ids
- This example is the first save of a new page (no ids yet) — next time send back the ids you got, e.g. {"id": 13, "type": "line", "url": "https://lin.ee/aBcD123"}
- Send "items": [] to remove every item — omitting items or sending null returns 400 (so items are never deleted by accident)
- items[i] in an error message is the position in the array you sent (starting at 0)
- Required fields by type: link = label + url (image_url as a thumbnail, featured allowed) · heading = label · social = platform + url · product = label (product name) + image_url + url (price, featured allowed) · image = image_url (url makes the image clickable, label is the alt text / caption) · gallery = images, 1–10 of them (aspect, and label as a caption above them, allowed · no url / image_url of its own) · promptpay = promptpay_id (amount, label allowed) · line = url (label allowed) — fields that do not apply to the type must be omitted or empty ("" / false / []), otherwise 400 (e.g. price on a link) · starts_at / ends_at work on every type
- url of a link / social item: http(s) clicks are counted through /@handle/{item id} (bots excluded), then redirected to url · mailto: / tel: open directly and are not counted · product / image / line: url is http(s) and counted the same way · links to go2w.co (e.g. your short links) are allowed
- New or changed http(s) URLs of every type are checked with Google Safe Browsing — a detected threat returns 422 unsafe_url
- image_url takes an https URL of an image hosted elsewhere, or the URL of an image uploaded in the web app (the API has no upload endpoint) · product images are shown square · image items are full width at the image's own aspect ratio · link thumbnails are 40×40
- Scheduling: an item is shown on the page when is_active is true and starts_at ≤ now < ends_at (null = no limit on that side) — outside that window the item is hidden and /@handle/{item id} returns 404 · setting starts_at / ends_at to a new value (not null and not the item's stored value) needs the Starter plan or higher (so every plan with API Access can), while null or the stored value is always accepted · a stored window always applies, even after a downgrade
- promptpay: the page builds the Thai QR Payment code (EMVCo PromptPay) from promptpay_id and amount, and shows it with the caption, the amount (when set) and a masked ID such as xxx-xxx-5678 · default caption "Scan to pay with PromptPay" (Thai visitors see it in Thai) · no url and no click counting — double-check promptpay_id before saving, because payments go straight to that ID, not through Go2Web
- line: url must be an https add-friend link on line.me or lin.ee (e.g. https://line.me/R/ti/p/@myshop or https://lin.ee/aBcD123 from LINE Official Account Manager) · default button text "Add on LINE" (Thai visitors see it in Thai) · on desktop computers with a mouse the page also shows a QR code of the link to scan with a phone
- gallery: the images swipe one at a time by touch or trackpad (no JavaScript needed) with the next one peeking at the right edge, position dots, and previous / next buttons on computers with a mouse · every image is cropped to fill the same aspect frame (aspect omitted = square) · an image with a url links to /@handle/{item id}/{image number, from 1}, which counts as a click of the item (the per-button click stats add up every image) and redirects to the image's url · every new or changed image url is checked with Google Safe Browsing too (the error names items[i].images[j])
- On the public page every social item is shown in the icon row under the bio, and the other items follow in order · items with is_active: false or outside their time window are hidden · featured highlights a link / product with an animated ring (no animation when the visitor prefers reduced motion)

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Bio page ID — a non-numeric id returns 400 `invalid bio page id` (e.g. `7`) |

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `items` | `object[]` | Yes | Every item of the page, in display order — 0–50 items |
| `items[].id` | `integer` | No | ID of an existing item of this page — omitted = a new item |
| `items[].type` | `string` | Yes | Item type — link \| heading \| social \| product \| image \| gallery \| promptpay \| line |
| `items[].label` | `string` | No | Button / heading text, product name or caption — link, heading and product: required, 1–80 characters · social, image, promptpay and line: optional, up to 80 characters (promptpay "" = "Scan to pay with PromptPay" · line "" = "Add on LINE") |
| `items[].url` | `string` | No | Destination — required for link, social, product and line · optional for image · none for heading and promptpay — link / social: http:// or https:// with a host · mailto:address · tel: followed by digits, + - ( ) and spaces (at least one digit) · product / image: http:// or https:// with a host only · line: https:// on line.me or lin.ee · up to 2048 bytes |
| `items[].platform` | `string` | No | Icon of a social item (social only — required there) — instagram \| tiktok \| facebook \| line \| youtube \| x \| shopee \| lazada \| website \| email \| phone |
| `items[].image_url` | `string` | No | Image — required for product and image · optional for link (a thumbnail) · none for other types — "" or an https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) |
| `items[].price` | `string` | No | Price on the product card, as free text (product only) — Up to 30 characters |
| `items[].featured` | `boolean` | No | Highlights the button / card with an animated ring (link and product only) — default false |
| `items[].starts_at` | `string (RFC 3339) \| null` | No | Shown from this time on — omitted / null = no start limit — A new value needs the Starter plan or higher |
| `items[].ends_at` | `string (RFC 3339) \| null` | No | Hidden from this time on — omitted / null = no end limit — Must be after starts_at (when both are set) · a new value needs the Starter plan or higher |
| `items[].promptpay_id` | `string` | No | The PromptPay ID that receives the money (promptpay only — required there) — A 10-digit phone number, a 13-digit national ID / tax ID or a 15-digit e-wallet ID · spaces / dashes are allowed and only the digits are stored |
| `items[].amount` | `string` | No | The amount in baht embedded in the QR — omitted / "" = the payer enters the amount (promptpay only) — 0.01–999999.99 with at most 2 decimals · stored with 2 decimals ("199" → "199.00") |
| `items[].images` | `object[]` | No | The images of a gallery, in slide order (gallery only — required there · other types omit it or send []) — 1–10 images |
| `items[].images[].image_url` | `string` | Yes | The image — An https:// URL with a host, up to 2048 bytes (the API has no upload endpoint — link to an image hosted elsewhere, or reuse the URL of an image uploaded in the web app) |
| `items[].images[].url` | `string` | No | Where tapping this image goes — omitted / "" = not clickable — http:// or https:// with a host only · up to 2048 bytes |
| `items[].images[].alt` | `string` | No | Image description for screen readers — omitted / "" = none — Up to 100 characters |
| `items[].aspect` | `string` | No | The gallery frame — omitted / "" = square (gallery only) — square (1:1) \| portrait (4:5) \| landscape (16:9) · trimmed and lowercased for you |
| `items[].is_active` | `boolean` | No | false = hidden from the public page — default true |

**Example request**

```bash
curl -X PUT "https://api.go2w.co/api/v1/bio-pages/7/items" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "type": "social",
      "url": "https://www.instagram.com/myshop",
      "platform": "instagram"
    },
    {
      "type": "social",
      "url": "https://www.tiktok.com/@myshop",
      "platform": "tiktok"
    },
    {
      "type": "line",
      "label": "แอดไลน์สั่งซื้อ",
      "url": "https://lin.ee/aBcD123"
    },
    {
      "type": "link",
      "label": "ร้านเราบน Shopee",
      "url": "https://shopee.co.th/myshop",
      "image_url": "https://cdn.example.com/myshop/shopee-icon.png",
      "featured": true
    },
    {
      "type": "heading",
      "label": "สินค้าแนะนำ"
    },
    {
      "type": "product",
      "label": "เสื้อเชิ้ตวินเทจลายดอก",
      "url": "https://shopee.co.th/product/123456/7890123",
      "image_url": "https://cdn.example.com/myshop/shirt-01.jpg",
      "price": "฿390"
    },
    {
      "type": "product",
      "label": "แจ็กเก็ตยีนส์ ราคา 12.12",
      "url": "https://shopee.co.th/product/123456/7890456",
      "image_url": "https://cdn.example.com/myshop/jacket-01.jpg",
      "price": "฿590 (ปกติ ฿890)",
      "featured": true,
      "starts_at": "2026-12-12T00:00:00+07:00",
      "ends_at": "2026-12-13T00:00:00+07:00"
    },
    {
      "type": "image",
      "label": "ลุคบุ๊กคอลเลกชันหน้าหนาว",
      "url": "https://www.instagram.com/p/AbCdEf123/",
      "image_url": "https://cdn.example.com/myshop/lookbook.jpg"
    },
    {
      "type": "gallery",
      "label": "ของเข้าใหม่สัปดาห์นี้",
      "aspect": "portrait",
      "images": [
        {
          "image_url": "https://cdn.example.com/myshop/new-01.jpg",
          "url": "https://shopee.co.th/product/123456/7890789",
          "alt": "เสื้อยีนส์วินเทจสีซีด"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-02.jpg",
          "url": "https://shopee.co.th/product/123456/7890790",
          "alt": "กระโปรงลายสก๊อต"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-03.jpg"
        }
      ]
    },
    {
      "type": "heading",
      "label": "ติดต่อเรา"
    },
    {
      "type": "link",
      "label": "โทรสั่งของ",
      "url": "tel:081-234-5678"
    },
    {
      "type": "promptpay",
      "label": "โอนค่ามัดจำ",
      "promptpay_id": "081-234-5678",
      "amount": "100"
    }
  ]
}'
```

**Response** `200 OK` — items after saving (array of Bio items in page order, with ids) — promptpay_id is digits only and amount has 2 decimals · see fields in Bio item object

```json
{
  "items": [
    {
      "label": "",
      "url": "https://www.instagram.com/myshop",
      "platform": "instagram",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 11,
      "type": "social"
    },
    {
      "label": "",
      "url": "https://www.tiktok.com/@myshop",
      "platform": "tiktok",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 12,
      "type": "social"
    },
    {
      "label": "แอดไลน์สั่งซื้อ",
      "url": "https://lin.ee/aBcD123",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 13,
      "type": "line"
    },
    {
      "label": "ร้านเราบน Shopee",
      "url": "https://shopee.co.th/myshop",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/shopee-icon.png",
      "price": "",
      "featured": true,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 14,
      "type": "link"
    },
    {
      "label": "สินค้าแนะนำ",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 15,
      "type": "heading"
    },
    {
      "label": "เสื้อเชิ้ตวินเทจลายดอก",
      "url": "https://shopee.co.th/product/123456/7890123",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/shirt-01.jpg",
      "price": "฿390",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 16,
      "type": "product"
    },
    {
      "label": "แจ็กเก็ตยีนส์ ราคา 12.12",
      "url": "https://shopee.co.th/product/123456/7890456",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/jacket-01.jpg",
      "price": "฿590 (ปกติ ฿890)",
      "featured": true,
      "starts_at": "2026-12-12T00:00:00+07:00",
      "ends_at": "2026-12-13T00:00:00+07:00",
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 17,
      "type": "product"
    },
    {
      "label": "ลุคบุ๊กคอลเลกชันหน้าหนาว",
      "url": "https://www.instagram.com/p/AbCdEf123/",
      "platform": "",
      "image_url": "https://cdn.example.com/myshop/lookbook.jpg",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 18,
      "type": "image"
    },
    {
      "label": "ของเข้าใหม่สัปดาห์นี้",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [
        {
          "image_url": "https://cdn.example.com/myshop/new-01.jpg",
          "url": "https://shopee.co.th/product/123456/7890789",
          "alt": "เสื้อยีนส์วินเทจสีซีด"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-02.jpg",
          "url": "https://shopee.co.th/product/123456/7890790",
          "alt": "กระโปรงลายสก๊อต"
        },
        {
          "image_url": "https://cdn.example.com/myshop/new-03.jpg",
          "url": "",
          "alt": ""
        }
      ],
      "aspect": "portrait",
      "is_active": true,
      "id": 22,
      "type": "gallery"
    },
    {
      "label": "ติดต่อเรา",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 19,
      "type": "heading"
    },
    {
      "label": "โทรสั่งของ",
      "url": "tel:081-234-5678",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "",
      "amount": "",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 20,
      "type": "link"
    },
    {
      "label": "โอนค่ามัดจำ",
      "url": "",
      "platform": "",
      "image_url": "",
      "price": "",
      "featured": false,
      "starts_at": null,
      "ends_at": null,
      "promptpay_id": "0812345678",
      "amount": "100.00",
      "images": [],
      "aspect": "",
      "is_active": true,
      "id": 21,
      "type": "promptpay"
    }
  ]
}
```

**Errors**

- `400` id is not a number — `invalid bio page id`
- `400` items is missing or null — `items is required`
- `400` items has more than 50 entries — `too many items (max 50)`
- `400` Unsupported type — `items[0]: type must be link, heading, social, product, image, promptpay, line or gallery`
- `400` A link / heading / product has no label, or it is longer than 80 characters — `items[3]: label is required (1-80 chars)`
- `400` A social / image / promptpay / line label is longer than 80 characters — `items[0]: label must be at most 80 chars`
- `400` A link / social / product / line item has no url — `items[3]: url is required`
- `400` The url of a link / social item is not a valid http(s) / mailto: / tel: URL (http(s) without a host, mailto: without an address or with whitespace, tel: without a digit or with other characters, or longer than 2048 bytes) — `items[3]: url must be http(s), mailto: or tel:`
- `400` The url of a product / image item is not an http(s) URL with a host (including mailto: / tel:, or longer than 2048 bytes) — `items[5]: url must be an http(s) URL`
- `400` The url of a line item is not https:// on line.me or lin.ee — `items[2]: url must be a LINE add-friend link (https://line.me/… or https://lin.ee/…)`
- `400` A heading has a url — `items[4]: a heading has no url`
- `400` A social item has no platform, or an unsupported one — `items[1]: platform must be one of: instagram, tiktok, facebook, line, youtube, x, shopee, lazada, website, email, phone`
- `400` A non-social item has a platform — `items[3]: platform is only for social items`
- `400` A product / image item has no image_url — `items[5]: image_url is required`
- `400` image_url is not an https:// URL with a host (including one longer than 2048 bytes) — `items[3]: image_url must be an https URL`
- `400` A heading / social / gallery / promptpay / line item has an image_url of its own — `items[4]: image_url is only for link, product and image items`
- `400` price is longer than 30 characters — `items[5]: price must be at most 30 chars`
- `400` A non-product item has a price — `items[3]: price is only for product items`
- `400` featured: true on an item that is not a link / product — `items[7]: featured is only for link and product items`
- `400` A promptpay item has no promptpay_id, or it is not 10 / 13 / 15 digits (after removing spaces and dashes) — `items[10]: promptpay_id must be a 10-digit phone number, a 13-digit ID or a 15-digit e-wallet ID`
- `400` amount is not a number, is outside 0.01–999999.99, or has more than 2 decimals — `items[10]: amount must be between 0.01 and 999999.99 with at most 2 decimals`
- `400` A non-promptpay item has a promptpay_id or amount — `items[3]: promptpay_id and amount are only for promptpay items`
- `400` A gallery has a url of its own (each image has its url) — `items[8]: a gallery has no url (each image has its own)`
- `400` A gallery has no images, or more than 10 — `items[8]: a gallery needs 1 to 10 images`
- `400` A gallery image_url is empty or not an https:// URL with a host (including one longer than 2048 bytes) — `items[8].images[2]: image_url must be an https URL`
- `400` A gallery image url is not an http(s) URL with a host (including mailto: / tel:, or longer than 2048 bytes) — `items[8].images[0]: url must be an http(s) URL`
- `400` A gallery image alt is longer than 100 characters — `items[8].images[1]: alt is too long (max 100 characters)`
- `400` Unsupported gallery aspect — `items[8]: aspect must be one of: square, portrait, landscape`
- `400` A non-gallery item has images — `items[3]: images are only allowed on gallery items`
- `400` A non-gallery item has an aspect — `items[3]: aspect is only for gallery items`
- `400` Both starts_at and ends_at are set and ends_at is not after starts_at — `items[6]: ends_at must be after starts_at`
- `400` id is not an existing item of this page — `items[3]: unknown id`
- `403` starts_at / ends_at is set to a new value on a plan without scheduling (Free) — never seen through the API, since every plan with API Access includes scheduling · sending null or the stored value is fine — `Scheduling bio blocks requires the Starter plan or higher` · code `plan_feature_required`
- `404` The bio page does not exist or belongs to another account — `bio page not found`
- `422` Google Safe Browsing reports an item's url as unsafe (only new or changed http(s) URLs are checked) — `items[3]: url is flagged as unsafe by Google Safe Browsing (MALWARE)` · code `unsafe_url`
- `422` Google Safe Browsing reports a gallery image's url as unsafe (only urls the item did not already have are checked) — `items[8].images[0]: url is flagged as unsafe by Google Safe Browsing (MALWARE)` · code `unsafe_url`

### GET /api/v1/bio-pages/:id/stats — Get bio page stats

Page views, where visitors come from (app / referrer / device / OS / country) and per-button clicks for a date range (days cut in the Asia/Bangkok timezone, UTC+7)

- Without from/to = the last 30 days up to today · from > to is swapped · maximum range 366 days · unparseable values fall back to the default (the same as Get link stats)
- views / clicks / daily / items and every breakdown exclude bots and link previews (including curl, Postman and scripts) — bot page views are counted separately in bot_views
- Only items with an http(s) url count clicks (link, social, product, a clickable image, line) — mailto: / tel: items, headings and promptpay items are always 0 · clicks = the sum of clicks in items
- sources, referrers, devices, os and countries break down page views (not clicks), top 10 by count, highest first · each is [] (not null) when there are no views
- source is taken from the in-app browser first (the user agent of the Instagram, TikTok, Facebook or LINE app), then from the referrer host: instagram, tiktok, facebook, line, x, youtube, google, direct (no referrer), other — most apps send no referrer, so those views usually show as (direct) in referrers but still get the right app in sources
- items lists every current item of the page (0 when it has no clicks) — clicks of deleted items are not in the response
- day is the start of the day as Bangkok wall-clock time with a Z suffix (not a real UTC instant — do not convert) · daily only includes days with views, and is [] when there are none

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Bio page ID — a non-numeric id returns 400 `invalid bio page id` (e.g. `7`) |

**Query parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `from` | `string (YYYY-MM-DD)` | No | Start date (inclusive) — omitted = 29 days before to (e.g. `2026-10-15`) |
| `to` | `string (YYYY-MM-DD)` | No | End date (inclusive) — omitted = today (Bangkok time) (e.g. `2026-11-13`) |

**Example request**

```bash
curl "https://api.go2w.co/api/v1/bio-pages/7/stats" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — Page stats for the selected range

```json
{
  "from": "2026-10-15",
  "to": "2026-11-13",
  "views": 120,
  "clicks": 57,
  "bot_views": 7,
  "daily": [
    {
      "day": "2026-11-11T00:00:00Z",
      "views": 38
    },
    {
      "day": "2026-11-12T00:00:00Z",
      "views": 52
    },
    {
      "day": "2026-11-13T00:00:00Z",
      "views": 30
    }
  ],
  "items": [
    {
      "id": 11,
      "clicks": 4
    },
    {
      "id": 12,
      "clicks": 2
    },
    {
      "id": 13,
      "clicks": 24
    },
    {
      "id": 14,
      "clicks": 15
    },
    {
      "id": 15,
      "clicks": 0
    },
    {
      "id": 16,
      "clicks": 9
    },
    {
      "id": 17,
      "clicks": 0
    },
    {
      "id": 18,
      "clicks": 3
    },
    {
      "id": 19,
      "clicks": 0
    },
    {
      "id": 20,
      "clicks": 0
    },
    {
      "id": 21,
      "clicks": 0
    }
  ],
  "sources": [
    {
      "label": "instagram",
      "count": 46
    },
    {
      "label": "tiktok",
      "count": 31
    },
    {
      "label": "line",
      "count": 18
    },
    {
      "label": "facebook",
      "count": 12
    },
    {
      "label": "direct",
      "count": 9
    },
    {
      "label": "google",
      "count": 4
    }
  ],
  "referrers": [
    {
      "label": "(direct)",
      "count": 66
    },
    {
      "label": "l.instagram.com",
      "count": 28
    },
    {
      "label": "www.tiktok.com",
      "count": 12
    },
    {
      "label": "l.facebook.com",
      "count": 10
    },
    {
      "label": "www.google.com",
      "count": 4
    }
  ],
  "devices": [
    {
      "label": "mobile",
      "count": 104
    },
    {
      "label": "desktop",
      "count": 14
    },
    {
      "label": "tablet",
      "count": 2
    }
  ],
  "os": [
    {
      "label": "ios",
      "count": 58
    },
    {
      "label": "android",
      "count": 46
    },
    {
      "label": "windows",
      "count": 10
    },
    {
      "label": "macos",
      "count": 6
    }
  ],
  "countries": [
    {
      "label": "TH",
      "count": 114
    },
    {
      "label": "JP",
      "count": 3
    },
    {
      "label": "US",
      "count": 2
    },
    {
      "label": "(unknown)",
      "count": 1
    }
  ]
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `from / to` | `string (YYYY-MM-DD)` | Yes | Effective date range (both days inclusive) |
| `views` | `integer` | Yes | Page views in the range (bots excluded) |
| `clicks` | `integer` | Yes | Clicks on all items in the range (bots excluded) |
| `bot_views` | `integer` | Yes | Bot / link-preview page views in the range |
| `daily` | `object[]` | Yes | Daily page views — only days with views |
| `daily[].day` | `string (RFC 3339)` | Yes | Start of the day as Bangkok wall-clock time with a Z suffix, e.g. "2026-11-12T00:00:00Z" = Nov 12 |
| `daily[].views` | `integer` | Yes | Page views that day |
| `items` | `object[]` | Yes | Clicks per item — every current item of the page |
| `items[].id` | `integer` | Yes | Item ID (matches id in Bio item) |
| `items[].clicks` | `integer` | Yes | Clicks on the item in the range (0 when none) |
| `sources` | `{label, count}[]` | Yes | Page views by app / source: instagram, tiktok, facebook, line, x, youtube, google, direct, other |
| `referrers` | `{label, count}[]` | Yes | Page views by referrer host, or (direct) |
| `devices` | `{label, count}[]` | Yes | Page views by device: mobile, tablet, desktop, other, unknown |
| `os` | `{label, count}[]` | Yes | Page views by operating system (from the user agent): ios, android, windows, macos, linux, chromeos, other, unknown |
| `countries` | `{label, count}[]` | Yes | Page views by ISO 3166-1 country code, e.g. TH, or (unknown) |

**Errors**

- `400` id is not a number — `invalid bio page id`
- `404` The bio page does not exist or belongs to another account — `bio page not found`

## Domains

Add your custom domain, verify ownership, then use it for links via domain_id · list the platform domains every account can use for links via platform_domain_id

### Domain object

Your custom domain for short links

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Domain ID |
| `host` | `string` | Yes | Hostname, e.g. go.brand.com |
| `verified` | `boolean` | Yes | Whether ownership is verified (usable for links only when true) |
| `ssl_status` | `string` | Yes | SSL certificate status: pending \| active |
| `link_count` | `integer` | Yes | Number of links on this domain |
| `created_at` | `string (RFC 3339)` | Yes | Time added |
| `managed` | `boolean` | Yes | true = Cloudflare issues the SSL certificate automatically |
| `name_servers` | `string[] \| null` | Yes | Nameservers to set at the domain's registrar — null when using the DNS-record method |
| `dns_records` | `object[]` | Yes | DNS records to add ({type, name, value}) — [] when using the nameserver method |

### GET /api/v1/domains — List domains

Lists the account's domains with their verification status, newest first (no pagination)

**Example request**

```bash
curl "https://api.go2w.co/api/v1/domains" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — domains (array of Domains — [] when none) · see fields in Domain object

```json
{
  "domains": [
    {
      "id": 3,
      "host": "go.brand.com",
      "verified": true,
      "ssl_status": "active",
      "link_count": 18,
      "created_at": "2026-11-12T16:30:00+07:00",
      "managed": true,
      "name_servers": [
        "ada.ns.cloudflare.com",
        "bob.ns.cloudflare.com"
      ],
      "dns_records": []
    }
  ]
}
```

### GET /api/v1/platform-domains — List platform domains

Lists the platform domains every account can use for links (managed by Go2Web), default first (no pagination) — use an id as platform_domain_id when creating or moving links

- Only public platform domains are listed, and they are available on every plan — nothing to add or verify
- A link created without domain_id and platform_domain_id goes on the domain with is_default: true · a link on a domain that later stops being public stays on it (read the link's real domain from the host of its short_url), but that domain cannot be chosen for other links

**Example request**

```bash
curl "https://api.go2w.co/api/v1/platform-domains" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — domains (array — default first)

```json
{
  "domains": [
    {
      "id": 1,
      "host": "go2w.co",
      "is_default": true
    },
    {
      "id": 2,
      "host": "g2w.to",
      "is_default": false
    }
  ]
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `domains[].id` | `integer` | Yes | Platform domain ID — use it as platform_domain_id |
| `domains[].host` | `string` | Yes | Hostname, e.g. go2w.co |
| `domains[].is_default` | `boolean` | Yes | true = the default platform domain (where links without a domain go) |

### POST /api/v1/domains — Create domain

Adds a custom domain (not verified yet). Configure DNS using the returned name_servers (or dns_records), then call Verify domain

- Domains per plan: Starter 1 · Pro 5 · Business 20 · Enterprise unlimited (Free cannot add domains)
- Nameserver changes can take from 5 minutes to several hours to take effect

**Request body (JSON)**

| Name | Type | Required | Description |
|---|---|---|---|
| `host` | `string` | Yes | Hostname, e.g. go.brand.com — No scheme needed (a leading http:// or https:// and a trailing / are stripped; the host is lowercased) · 4–253 characters of a-z 0-9 . - and must contain a dot |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/domains" \
  -H "Authorization: Bearer $GO2WEB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "host": "go.brand.com"
}'
```

**Response** `201 Created` — The added Domain object (verified: false)

```json
{
  "id": 3,
  "host": "go.brand.com",
  "verified": false,
  "ssl_status": "pending",
  "link_count": 0,
  "created_at": "2026-11-12T16:30:00+07:00",
  "managed": true,
  "name_servers": [
    "ada.ns.cloudflare.com",
    "bob.ns.cloudflare.com"
  ],
  "dns_records": []
}
```

**Errors**

- `400` host has an invalid format — `invalid domain host`
- `400` The host is Go2Web's own domain — `cannot add the service's own domain`
- `403` The plan's domain quota is used up — `custom domain limit reached: the Pro plan allows 5 domains; upgrade to add more` · code `domain_limit_reached`
- `409` This domain has already been added (by any account) — `domain already registered` · code `domain_taken`
- `502` Creating the Cloudflare zone failed (safe to retry)

### POST /api/v1/domains/:id/verify — Verify domain

Checks whether DNS setup is complete. Once it is, you get verified: true and ssl_status: "active" — safe to call again until it passes

- Nameserver method: until the change takes effect you get 200 with verified: false and ssl_status: "pending"
- DNS-record method: every call re-checks the TXT record — a verified domain stays verified even if a later Verify call returns 422
- No request body needed

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Domain ID — a non-numeric id returns 400 `invalid domain id` (e.g. `3`) |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/domains/3/verify" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — The Domain object with its latest status

```json
{
  "id": 3,
  "host": "go.brand.com",
  "verified": true,
  "ssl_status": "active",
  "link_count": 0,
  "created_at": "2026-11-12T16:30:00+07:00",
  "managed": true,
  "name_servers": [
    "ada.ns.cloudflare.com",
    "bob.ns.cloudflare.com"
  ],
  "dns_records": []
}
```

**Errors**

- `400` id is not a number — `invalid domain id`
- `404` The domain does not exist or belongs to another account — `domain not found`
- `422` DNS-record method: the TXT record is not found yet (DNS has not propagated, or the record was not added) — `TXT record not found at _golnk-verify.go.brand.com; check your DNS settings` · code `dns_record_not_found`
- `422` DNS-record method: a TXT record exists but its value does not match the token in dns_records — `TXT record value does not match the verification token; check it again` · code `dns_record_mismatch`
- `502` A Cloudflare call failed (checking the zone status, or setting up routing once the zone is active) — safe to retry

### DELETE /api/v1/domains/:id — Delete domain

Removes the domain from the account (a soft delete — Restore domain brings it back)

- Caution: all links on this domain are deleted too and stop redirecting at once
- The domain's DNS zone is released, so the same host can be added again (by any account)

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Domain ID — a non-numeric id returns 400 `invalid domain id` (e.g. `3`) |

**Example request**

```bash
curl -X DELETE "https://api.go2w.co/api/v1/domains/3" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `204 No Content` — Deleted (no body)

**Errors**

- `400` id is not a number — `invalid domain id`
- `404` The domain does not exist, belongs to another account, or is already deleted — `domain not found`

### GET /api/v1/domains/deleted — List deleted domains

Lists the domains this account deleted (restorable with Restore domain), most recently deleted first (no pagination)

**Example request**

```bash
curl "https://api.go2w.co/api/v1/domains/deleted" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — domains ([] when none)

```json
{
  "domains": [
    {
      "id": 3,
      "host": "go.brand.com",
      "link_count": 18,
      "created_at": "2026-11-12T16:30:00+07:00",
      "deleted_at": "2026-11-12T09:30:00+07:00"
    }
  ]
}
```

**Response fields**

| Name | Type | Required | Description |
|---|---|---|---|
| `domains[].id` | `integer` | Yes | Domain ID |
| `domains[].host` | `string` | Yes | Hostname |
| `domains[].link_count` | `integer` | Yes | Number of links deleted with the domain = the links a restore brings back |
| `domains[].created_at` | `string (RFC 3339)` | Yes | Time the domain was added |
| `domains[].deleted_at` | `string (RFC 3339)` | Yes | Time deleted |

### POST /api/v1/domains/:id/restore — Restore domain

Restores a deleted domain together with the links deleted with it. The domain comes back unverified: configure DNS using the returned name_servers (or dns_records), then call Verify domain

- Links you deleted yourself before the domain stay deleted
- name_servers may differ from before, because the old DNS zone was released on delete
- Counts against the domain quota like a new domain
- No request body needed

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | `integer` | Yes | Domain ID — a non-numeric id returns 400 `invalid domain id` (e.g. `3`) |

**Example request**

```bash
curl -X POST "https://api.go2w.co/api/v1/domains/3/restore" \
  -H "Authorization: Bearer $GO2WEB_API_KEY"
```

**Response** `200 OK` — The restored Domain object (verified: false · link_count = the links brought back)

```json
{
  "id": 3,
  "host": "go.brand.com",
  "verified": false,
  "ssl_status": "pending",
  "link_count": 18,
  "created_at": "2026-11-12T16:30:00+07:00",
  "managed": true,
  "name_servers": [
    "ada.ns.cloudflare.com",
    "bob.ns.cloudflare.com"
  ],
  "dns_records": []
}
```

**Errors**

- `400` id is not a number — `invalid domain id`
- `403` The plan's domain quota is used up — `custom domain limit reached: the Pro plan allows 5 domains; upgrade to add more` · code `domain_limit_reached`
- `404` No such deleted domain (it does not exist, belongs to another account, is not deleted, or was revoked by an administrator) — `deleted domain not found`
- `409` Another domain with this host was added after the delete — `domain already registered` · code `domain_taken`
- `502` Creating the Cloudflare zone failed — the domain stays deleted (safe to retry)
