{"openapi":"3.1.0","info":{"title":"Trnqify API","version":"1.0.0","license":{"name":"Proprietary","url":"https://trnqify.com/terms-and-conditions"},"summary":"Public REST API for short links, link metrics, account analytics, and outbound webhooks","description":"Create, list, get, and update short links. Read per-link click metrics (including plan-shaped geo/device detail). Read account-wide analytics across every link on the account. Manage Pro outbound webhooks that receive signed HTTP callbacks for product events.\n\n## Authentication\n\nREST calls on `api.trnqify.com` use a Clerk-issued API key (`ak_...`) as `Authorization: Bearer ak_...`.\n\nMint, rotate, and revoke keys in the Trnqify app (Settings → API keys). Keys require a **Standard** (limited) or **Pro** (full) plan — they are included, not a separate add-on. Free-plan credentials are rejected.\n\nNative MCP at `https://mcp.trnqify.com/mcp` uses OAuth on all plans (Free accounts get limited tools and rate caps). Standard and Pro can also connect with an API key (`x-api-key` header) in addition to OAuth. Send **one** auth header, not both. To set up Claude, Cursor, VS Code, or another MCP client, see **Connect an MCP client**.\n\n## Try it\n\nPaste an `ak_...` key into Authenticate, then run requests against the Public REST API server. CORS is enabled on `api.trnqify.com` so the console talks to the live API from this origin.\n\nWebhook management (`/user/webhooks`), link metrics (`/links/{path}/metrics*`), and account analytics (`/analytics/*`) routes are served on the Public REST API only (not on the MCP host).\n\n## Plans\n\n| Plan | Daily requests | Rate limit | Link metrics window | Geo detail | Account analytics | Outbound webhooks |\n|------|----------------|------------|---------------------|------------|-------------------|-------------------|\n| Free | No API access | — | App session only (7 days; not callable with API keys) | None | App session only | — |\n| Standard | 10,000 / day | 60 req/min (burst 5) | 30 days | Country | Summary | Not included |\n| Pro | Unlimited count | 300 req/min (burst 10) | Up to 365 days | Country + city | Summary + detail | Up to 3 endpoints |\n\nQuota and throttle exhaustion return HTTP 429. Missing plan access returns HTTP 403 with `{ error: \"upgrade_required\", entitlement, tier, message }`.\n\nCustom domains on created links require Pro. Omit `link.domain` (or use the default Trnqify host) for standard short links.\n\nCustom slugs (`link.slug` on `POST /links` and the MCP `createLink` tool) require Pro. A slug that is already taken returns HTTP 409 `slug_taken`; omit `link.slug` for a generated path.\n\n## Link metrics\n\nPublic API callers (Standard/Pro keys) use `GET /links/{path}/metrics` for a daily click series over the plan's `analytics.window`, and `GET /links/{path}/metrics/detail` for lifetime `totalClicks` plus plan-shaped geo/device fields. City breakdown (`topCity`, `cities`) is **Pro-only** and omitted on Standard. Free-plan API keys cannot call these routes; Free shaping exists only for the signed-in app session.\n\n## Account analytics\n\n`GET /analytics/summary` returns the click trend, top 10 links, and country / desktop-mobile split summed across every link on the account. `GET /analytics/detail` adds the Pro breakdowns: campaign / source / medium rollup, browser, OS, custom domain, network (ASN), a day × hour heatmap, and city. Standard keys get `403 upgrade_required` from the detail route. See the **Analytics** tag.\n\n## Outbound webhooks\n\nPro accounts can subscribe to product events and receive HMAC-signed `POST` deliveries. See the **Webhooks** tag for CRUD, **Verifying webhook signatures** for receiver verification, and the **Webhooks** section below for event payload schemas."},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API"},{"url":"https://mcp.trnqify.com","description":"MCP host (link REST routes + native MCP at /mcp; metrics and webhooks are on the Public REST API only)"}],"tags":[{"name":"Links","description":"Short-link CRUD for the authenticated account"},{"name":"Metrics","description":"Per-link click analytics on the Public REST API (Standard/Pro API keys). Free-plan credentials are rejected by the authorizer before these handlers run.\n\n### Lookback window (`analytics.window`)\n\n| Plan | Window (API) |\n|------|----------------|\n| Free | Not available via API keys (app session uses 7 days) |\n| Standard | Last 30 days |\n| Pro | Day-to-year (up to 365 days) |\n\nDetail aggregates use an inclusive window from the start of the first lookback day through **now** (today's clicks are included).\n\n### Geo / device shaping (`analytics.dimension.*`)\n\nDetail responses omit fields the plan is not entitled to (they are **absent**, not always `null`). Callable API shapes:\n\n| Plan | `topCountryCode` | `topCity` / `cities` | `mobileClicks` / `desktopClicks` |\n|------|------------------|----------------------|----------------------------------|\n| Standard | Present (`null` when unknown) | Absent | Present |\n| Pro | Present | Present (`topCity` may be `null`; `cities` may be `[]`) | Present |\n\nCity enrichment uses MaxMind GeoLite2. Clicks recorded before city capture (or lookup misses) are omitted from the city breakdown."},{"name":"Analytics","description":"Account-wide click analytics summed across every link the authenticated account owns (Standard/Pro API keys). Free-plan credentials are rejected by the authorizer before these handlers run.\n\n| Route | Standard | Pro |\n|-------|----------|-----|\n| `GET /analytics/summary` | `200` — trend, top links, country, desktop/mobile | `200` — same fields (no city) |\n| `GET /analytics/detail` | `403 upgrade_required` (`entitlement: analytics.detailed_breakdowns`) | `200` — every breakdown, including city |\n\n### Window (`analytics.window`)\n\nBoth routes use an inclusive `YYYY-MM-DD` window ending **today** (UTC): the last 30 days on Standard and the last 365 days on Pro. `startDate`, `endDate`, and `lookbackDays` echo the window applied.\n\n### Denominators\n\nBreakdowns come from a per-account daily aggregate that only counts clicks recorded after it shipped, and leaves out values that could not be identified (`unknown`). Don't divide breakdown counts by `totalClicks`:\n\n- Summary country and device percentages: divide by `mobileClicks + desktopClicks`.\n- Detail percentages: divide by `breakdownClicks`. When `firstBreakdownDate` is after `startDate`, breakdowns only cover data from that date on.\n- Campaign / source / medium rollups come from the daily click series, so they line up with `totalClicks` except for clicks on deleted links."},{"name":"Webhooks","description":"Manage outbound webhook subscriptions (Pro). HTTPS endpoints only; private/link-local destinations are rejected.\n\n### Entitlements\n\n- Creating, updating, rotating the secret, and sending a test require `webhooks.enabled` (Pro). Free/Standard receive HTTP 403 `upgrade_required`.\n- Listing, reading, delivery history, and deleting work on any plan. Endpoints saved on Pro are kept after a downgrade with `pausedByPlan: true` and receive no deliveries; they resume with the same URL and signing secret on return to Pro.\n- Cap: `webhooks.max_count` = **3** per account. Creating beyond the cap returns HTTP 403 `limit_exceeded`.\n\n### Delivery\n\nTrnqify `POST`s JSON event envelopes to your URL with HMAC signatures. Success is HTTP **2xx** only. See **Verifying webhook signatures** and the outbound **Webhooks** payload reference."},{"name":"Connect an MCP client","description":"Connect an AI client that speaks MCP (Model Context Protocol) to Trnqify, then ask it in plain language to create, find, and update your short links.\n\n### Before you start\n\n- **Free** accounts connect via OAuth with limited tools and rate caps. **Standard** ($9/mo) and **Pro** ($29/mo) connect via OAuth (all plans) or API key (Standard & Pro only).\n- An API key (`ak_...`) is optional on Standard and Pro — create one in the Trnqify app under **Settings → API keys**. Anyone with the key can manage your links, so keep it out of shared repos and screenshots.\n\n### Connection details\n\n| Setting | Value |\n|---------|-------|\n| Endpoint | `https://mcp.trnqify.com/mcp` |\n| Transport | Streamable HTTP |\n| Auth header | `x-api-key: YOUR_API_KEY` |\n\nSend the key in `x-api-key` only. Don't also send `Authorization`: that header is for the REST API on `api.trnqify.com`.\n\n### Cursor\n\n1. Open **Cursor Settings → Tools & MCP** and add a new MCP server. Cursor opens your global `mcp.json` (`~/.cursor/mcp.json`).\n2. Add the `trnqify` entry under `mcpServers`, with your key in place of `YOUR_API_KEY`, and save:\n\n```json\n{\n  \"mcpServers\": {\n    \"trnqify\": {\n      \"url\": \"https://mcp.trnqify.com/mcp\",\n      \"headers\": {\n        \"x-api-key\": \"YOUR_API_KEY\"\n      }\n    }\n  }\n}\n```\n\n3. Back in **Tools & MCP**, check that `trnqify` is turned on and lists four tools. Free accounts see a subset (createLink, listLinks read-only).\n\nTo use Trnqify in one project only, put the same entry in `.cursor/mcp.json` in that project. To keep the key out of the file, set `TRNQIFY_API_KEY` in your shell profile and use `\"x-api-key\": \"${env:TRNQIFY_API_KEY}\"`. If it doesn't connect, open the Output panel and choose **MCP Logs**.\n\n### Claude Code\n\n```bash\nclaude mcp add --transport http trnqify https://mcp.trnqify.com/mcp \\\n  --header \"x-api-key: YOUR_API_KEY\"\n```\n\nAdd `--scope user` to use it in every project. Run `claude mcp list`, or `/mcp` inside a session, to check the connection.\n\n### Claude (web, desktop, and mobile)\n\nAdd Trnqify once as a custom connector and it works on claude.ai, Claude Desktop, and the Claude mobile apps. Free Claude plans allow one custom connector.\n\n1. Open **Customize → Connectors**, click **+ Add**, then **Add custom connector**.\n2. Name it `Trnqify`, enter `https://mcp.trnqify.com/mcp`, and click **Continue**.\n3. Under **Authentication**, choose **No sign in**.\n4. Under **Request headers**, add a header named `x-api-key` with your API key as the value.\n5. Click **Add**. In a chat, turn Trnqify on from **+ → Connectors**.\n\nClaude calls Trnqify from Anthropic's servers, not from your computer, so this works in any browser.\n\nOn Team and Enterprise plans, an Owner adds the connector under **Organization settings → Connectors → Add → Custom → Web**. Everyone who uses it sends the same key, so every member's links land in the key owner's Trnqify account.\n\n### Claude Desktop (local config)\n\nTo keep the key on your computer instead, run the `mcp-remote` bridge from Claude Desktop's config file. It needs Node.js 18 or later. Open **Settings → Developer → Edit Config** and add Trnqify to `claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"trnqify\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"-y\",\n        \"mcp-remote\",\n        \"https://mcp.trnqify.com/mcp\",\n        \"--header\",\n        \"x-api-key:${TRNQIFY_API_KEY}\"\n      ],\n      \"env\": {\n        \"TRNQIFY_API_KEY\": \"YOUR_API_KEY\"\n      }\n    }\n  }\n}\n```\n\nQuit and reopen Claude Desktop. Trnqify's tools appear under the tools menu in the chat box.\n\n### VS Code\n\nAdd Trnqify to `.vscode/mcp.json`, or run **MCP: Open User Configuration** to use it everywhere. VS Code asks for the key once and stores it securely:\n\n```json\n{\n  \"inputs\": [\n    {\n      \"type\": \"promptString\",\n      \"id\": \"trnqify-api-key\",\n      \"description\": \"Trnqify API key\",\n      \"password\": true\n    }\n  ],\n  \"servers\": {\n    \"trnqify\": {\n      \"type\": \"http\",\n      \"url\": \"https://mcp.trnqify.com/mcp\",\n      \"headers\": {\n        \"x-api-key\": \"${input:trnqify-api-key}\"\n      }\n    }\n  }\n}\n```\n\n### Other MCP clients\n\nAny client that supports Streamable HTTP and custom request headers can connect. Most accept an `mcpServers` block like this one:\n\n```json\n{\n  \"mcpServers\": {\n    \"trnqify\": {\n      \"url\": \"https://mcp.trnqify.com/mcp\",\n      \"headers\": {\n        \"x-api-key\": \"YOUR_API_KEY\"\n      }\n    }\n  }\n}\n```\n\n### ChatGPT\n\nChatGPT uses Trnqify through a custom GPT with an Action, which calls the REST API instead of MCP. You need a ChatGPT plan that can create GPTs.\n\n1. In ChatGPT, open **GPTs → Create**, then **Configure → Create new action**.\n2. Choose **Import from URL** and enter `https://mcp.trnqify.com/openapi.json`.\n3. Under **Authentication**, choose **API Key**, paste your key, and set **Auth Type** to **Bearer**.\n4. Save the GPT and ask it for a link, like the prompt below.\n\nKeep the GPT private (**Only me**). Your API key is saved in the Action, so anyone who can use the GPT creates and changes links on your account. The Action can call every REST operation, including link metrics, account analytics, and webhooks, not only the four MCP tools.\n\nChatGPT's MCP connectors sign in with OAuth and can't send an API-key header. Trnqify doesn't offer OAuth sign-in yet, so use the custom GPT in ChatGPT for now.\n\n### Tools\n\n- **`createLink`** creates a short link. Send `link` with `longUrl` and `name`, plus any of `source`, `medium`, `campaign`, `previewTitle`, `previewDescription`, `previewImageUrl`, `domain` (Pro), and `slug` (Pro).\n- **`listLinks`** lists every link on the account. No input.\n- **`getLink`** gets one link by `path`.\n- **`updateLink`** changes a link's destination, name, UTM tags, or preview. Send `path` and the `link` fields to change (the same fields as `createLink`, except `slug`).\n\n`source`, `medium`, and `campaign` become the `utm_source`, `utm_medium`, and `utm_campaign` tags on the destination URL. Each tool returns the same JSON as the matching REST call (`{ \"link\": { ... } }` or `{ \"links\": [ ... ] }`). Each link includes `shortUrl`, the full short link. Show it as-is.\n\nTry a prompt like:\n\n> Make a short link for https://example.com/launch called \"Launch post\", tagged source=newsletter, medium=email, campaign=launch.\n\nThen follow up with \"List my Trnqify links\" or \"Point that link at https://example.com/launch-v2\".\n\n### Limits\n\n- MCP requests share your plan's API quota. They aren't counted separately. Free accounts get **100 requests a day** (20 a minute) and can call `createLink` at most **10/day** (5/hour). Standard allows **10,000 requests a day** (60 a minute). Pro has **no daily cap** (300 a minute). Every request your client sends counts, including the connection handshake and the tool list.\n- Custom slugs (`slug`) and custom domains (`domain`) need **Pro**. On Standard, `createLink` returns an `upgrade_required` error. Leave them out to get a generated path on `trnq.io`.\n\n### Troubleshooting\n\nWhen the connection is refused, the status is HTTP 403 and the response body's `error` says why:\n\n- **`unauthorized`**: the key is missing, mistyped, or revoked. Check that it's sent as `x-api-key`, or create a new key in **Settings → API keys**.\n- **`upgrade_required`**: the key belongs to a Free account using API-key auth. Upgrade to Standard or Pro, or connect via OAuth (Free accounts can use MCP with limited tools and rate caps).\n- **`trial_ended`**: your Standard trial ended. Choose a paid plan to keep using MCP.\n- **`billing_required`**: a payment failed. Update your payment method in the app.\n\nHTTP **429** means you hit the per-minute rate limit or the daily quota. Wait and retry.\n\nWhen a tool call fails, the client shows a tool error with the same JSON, for example `upgrade_required` with `entitlement: links.custom_slug` when Standard sets `slug`, or `slug_taken` when the slug is in use.\n\n**Requests to `/.well-known/oauth-*` in client logs are expected.** Cursor and other clients check for OAuth before connecting. Trnqify answers that it has no OAuth server, and the client goes on with your `x-api-key` header. If a client opens a browser sign-in or insists on OAuth, it doesn't support API-key headers, so use a different client for now."},{"name":"Verifying webhook signatures","description":"Every delivery includes `X-Trnqify-Timestamp` (Unix seconds) and `X-Trnqify-Signature` (`sha256=` + lowercase hex HMAC-SHA256).\n\n### Signed material\n\n1. Take the **exact raw request body bytes** (UTF-8 JSON as received — do not re-serialize).\n2. Concatenate: ASCII decimal `timestamp` + `.` (0x2e) + raw body bytes.\n3. Compute HMAC-SHA256 with your webhook secret (shown once on create / regenerate).\n4. Compare to `X-Trnqify-Signature` with a timing-safe equality check.\n5. Reject timestamps outside a **±5 minute** replay window.\n\nAlso expect:\n\n| Header | Meaning |\n|--------|---------|\n| `Content-Type` | `application/json; charset=utf-8` |\n| `User-Agent` | `Trnqify-Webhooks/1.0` |\n| `X-Trnqify-Event` | Event type (same as body `type`) |\n| `X-Trnqify-Delivery-Id` | Unique per attempt (`dlv_` + ULID); retries get a new id |\n| `X-Trnqify-Timestamp` | Unix seconds when the attempt was signed |\n| `X-Trnqify-Signature` | `sha256=` + hex HMAC |\n\nBody `id` is a stable `evt_`-prefixed identifier (catalog events: deterministic `evt_` + 26 hex chars; `webhook.test`: ULID). Retries keep the same body `id`.\n\n### Node.js\n\n```js\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\nfunction verify({ secret, rawBody, timestampHeader, signatureHeader, nowSec = Math.floor(Date.now() / 1000) }) {\n  const timestamp = Number(timestampHeader);\n  if (!Number.isFinite(timestamp)) return false;\n  // Replay window: 5 minutes\n  if (Math.abs(nowSec - timestamp) > 300) return false;\n\n  const bodyBytes = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');\n  const signed = Buffer.concat([\n    Buffer.from(`${timestamp}.`, 'utf8'),\n    bodyBytes,\n  ]);\n\n  const expected = 'sha256=' + createHmac('sha256', secret)\n    .update(signed)\n    .digest('hex');\n\n  const a = Buffer.from(expected);\n  const b = Buffer.from(String(signatureHeader || ''));\n  return a.length === b.length && timingSafeEqual(a, b);\n}\n```\n\n### Generic / curl-style pseudocode\n\n```text\n# Inputs from the HTTP request:\n#   TIMESTAMP = value of X-Trnqify-Timestamp\n#   SIGNATURE = value of X-Trnqify-Signature\n#   BODY      = exact raw request body bytes\n#   SECRET    = webhook signing secret from create/regenerate\n\nNOW = current_unix_seconds()\nif abs(NOW - int(TIMESTAMP)) > 300:\n  reject  # outside ±5 minute replay window\n\nSIGNED = utf8(TIMESTAMP) + byte(0x2E) + BODY\nDIGEST = hmac_sha256_hex(key=SECRET, message=SIGNED)\nEXPECTED = \"sha256=\" + DIGEST\n\nif not timing_safe_equal(EXPECTED, SIGNATURE):\n  reject\n\naccept\n```\n\nExample openssl one-liner for a known body file (for manual debugging only — prefer the timing-safe check above in production):\n\n```bash\n# TIMESTAMP and BODY_FILE must match the attempt you are verifying\nprintf '%s.' \"$TIMESTAMP\" | cat - \"$BODY_FILE\" | \\\n  openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print \"sha256=\"$2}'\n```"},{"name":"MCP","description":"MCP usage and rate limits for the authenticated account"}],"security":[{"BearerAuth":[]}],"paths":{"/links":{"get":{"tags":["Links"],"summary":"List links","description":"Return every short link owned by the authenticated account.","operationId":"listLinks","responses":{"200":{"description":"List of links","content":{"application/json":{"schema":{"type":"object","required":["links"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/Link"}}}},"example":{"links":[{"path":"Hx7dKq9","shortUrl":"https://trnq.io/Hx7dKq9","ownerId":"user_abc","longUrl":"https://example.com/launch","name":"Launch post","source":"newsletter","medium":"email","campaign":"spring-launch","totalClicks":0,"createdAt":"2026-10-14T18:00:00.000Z","updatedAt":"2026-10-14T18:00:00.000Z"},{"path":"spring-sale","shortUrl":"https://go.example.com/spring-sale","ownerId":"user_abc","longUrl":"https://example.com/sale","name":"Spring sale","domain":"go.example.com","totalClicks":42,"createdAt":"2026-09-12T18:00:00.000Z","updatedAt":"2026-09-13T09:30:00.000Z"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Links"],"summary":"Create a new link","description":"Create a short link for the authenticated account. Optionally set `link.domain` to a verified custom domain hostname owned by the account (Pro).\n\n### Custom slugs (Pro)\n\nSet `link.slug` to choose the short-link path instead of the generated 7-character one. Omitting it (or sending `null` / `\"\"`) keeps the generated path and needs no entitlement.\n\n- **Pro-only** (`links.custom_slug`). Standard keys get `403` with `{ error: \"upgrade_required\", entitlement: \"links.custom_slug\" }`.\n- The slug is trimmed and lower-cased, then must be 3–30 characters of `a-z`, `0-9`, `-`, `_`, and cannot start or end with `-`. Reserved system paths and profane words are rejected. Failures return `400` `invalid_slug` with the failed `rule`.\n- Slugs are unique across Trnqify. A taken slug returns `409` `slug_taken`; retry with a different slug.\n- The same rules apply to the MCP `createLink` tool, which returns these bodies as tool errors (`isError: true`) with a `status` field.","operationId":"createLink","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["link"],"properties":{"link":{"$ref":"#/components/schemas/CreateLinkInput"}}},"examples":{"generated":{"summary":"Generated path","value":{"link":{"longUrl":"https://example.com/campaign","name":"spring-sale","source":"newsletter","medium":"email","campaign":"spring-2026"}}},"customSlug":{"summary":"Custom slug (Pro)","value":{"link":{"longUrl":"https://example.com/campaign","name":"spring-sale","slug":"spring-sale"}}}}}}},"responses":{"200":{"description":"Link created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"link":{"$ref":"#/components/schemas/Link"}}},"example":{"link":{"path":"Hx7dKq9","shortUrl":"https://trnq.io/Hx7dKq9","ownerId":"user_abc","longUrl":"https://example.com/launch","name":"Launch post","source":"newsletter","medium":"email","campaign":"spring-launch","totalClicks":0,"createdAt":"2026-10-14T18:00:00.000Z","updatedAt":"2026-10-14T18:00:00.000Z"}}}}},"400":{"description":"Domain is not verified, the request is invalid, or `link.slug` failed validation (`invalid_slug`)","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/SlugErrorBody"},{"$ref":"#/components/schemas/MessageBody"}]},"example":{"error":"invalid_slug","rule":"reserved","message":"This custom slug is reserved. Please choose another.","fields":["slug"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Plan upgrade required (custom domain or `link.slug` on a non-Pro plan), or the domain is not owned by the authenticated account","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"},"example":{"error":"upgrade_required","entitlement":"links.custom_slug","tier":"standard","message":"Custom slugs require a Pro plan. Upgrade to choose your own short link."}}}},"404":{"description":"Custom domain not found or deleted"},"409":{"description":"`link.slug` is already taken (`slug_taken`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SlugErrorBody"},"example":{"error":"slug_taken","message":"This custom slug is already taken. Please choose another.","fields":["slug"]}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/links/{path}":{"get":{"tags":["Links"],"summary":"Get a link","description":"Get a specific link by its path. Returns 404 when the path is missing or owned by another account.","operationId":"getLink","parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"The path identifier of the link"}],"responses":{"200":{"description":"Link found","content":{"application/json":{"schema":{"type":"object","properties":{"link":{"$ref":"#/components/schemas/Link"}}},"example":{"link":{"path":"Hx7dKq9","shortUrl":"https://trnq.io/Hx7dKq9","ownerId":"user_abc","longUrl":"https://example.com/launch","name":"Launch post","source":"newsletter","medium":"email","campaign":"spring-launch","totalClicks":0,"createdAt":"2026-10-14T18:00:00.000Z","updatedAt":"2026-10-14T18:00:00.000Z"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"tags":["Links"],"summary":"Update a link","description":"Update an existing link owned by the authenticated account.","operationId":"updateLink","parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"The path identifier of the link"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["link"],"properties":{"link":{"$ref":"#/components/schemas/LinkInput"}}}}}},"responses":{"200":{"description":"Link updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"link":{"$ref":"#/components/schemas/Link"}}},"example":{"link":{"path":"Hx7dKq9","shortUrl":"https://trnq.io/Hx7dKq9","ownerId":"user_abc","longUrl":"https://example.com/launch","name":"Launch post","source":"newsletter","medium":"email","campaign":"spring-launch-v2","totalClicks":12,"createdAt":"2026-10-14T18:00:00.000Z","updatedAt":"2026-10-15T09:00:00.000Z"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}},"/links/{path}/metrics":{"get":{"tags":["Metrics"],"summary":"Get link click metrics (daily series)","description":"Return click totals for a link owned by the authenticated account over the plan's `analytics.window` lookback, including a zero-filled daily series.\n\nRequires a Standard or Pro API key (Free-plan credentials are rejected).\n\n| Plan | Lookback |\n|------|----------|\n| Standard | 30 days |\n| Pro | Up to 365 days |\n\n`totalClicks` here is the sum of `dailyClicks` in the window (not lifetime). Each `dailyClicks[].updatedAt` is an ISO 8601 date-time string.","operationId":"getLinkMetrics","parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"The path identifier of the link"}],"responses":{"200":{"description":"Daily click metrics for the plan lookback window","content":{"application/json":{"schema":{"type":"object","required":["metrics"],"properties":{"metrics":{"$ref":"#/components/schemas/LinkMetricsSummary"}}},"example":{"metrics":{"totalClicks":15,"dailyClicks":[{"path":"spring-sale","metricDate":"2026-09-12","ownerId":"user_abc","updatedAt":"2026-09-12T00:00:00.000Z","numClicks":10},{"path":"spring-sale","metricDate":"2026-09-13","ownerId":"user_abc","updatedAt":"2026-09-13T00:00:00.000Z","numClicks":5}]}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (metrics are not served on the MCP host)"}]},"/links/{path}/metrics/detail":{"get":{"tags":["Metrics"],"summary":"Get link metrics detail (geo + device)","description":"Return lifetime `totalClicks` plus plan-shaped geo and device breakdowns for clicks in the plan's `analytics.window` (from the start of the first lookback day through **now**, so today's clicks are included).\n\nRequires a Standard or Pro API key (Free-plan credentials are rejected).\n\n### Plan shaping\n\nFields the plan is not entitled to are **omitted** from `metrics` (not returned as `null`):\n\n| Field | Standard | Pro |\n|-------|----------|-----|\n| `totalClicks` | Present (lifetime) | Present |\n| `topCountryCode` | Present (`null` if unknown) | Present (`null` if unknown) |\n| `topCity` | Absent | Present — city name with the most clicks, or `null` when none enriched |\n| `cities` | Absent | Present — city breakdown for map views (`[]` when empty) |\n| `mobileClicks` / `desktopClicks` | Present | Present |\n\n`topCity` and `cities` are **Pro-only** (`analytics.dimension.geo` = `city`) and absent on Standard. Same-named cities in different regions appear as separate `cities` entries (grouped by city + subdivision + countryCode). Clicks without city enrichment are skipped in the breakdown.","operationId":"getLinkMetricsDetail","parameters":[{"name":"path","in":"path","required":true,"schema":{"type":"string"},"description":"The path identifier of the link"}],"responses":{"200":{"description":"Link metrics detail shaped by the account plan","content":{"application/json":{"schema":{"type":"object","required":["metrics"],"properties":{"metrics":{"$ref":"#/components/schemas/LinkMetricsDetail"}}},"examples":{"pro":{"summary":"Pro — country, city breakdown, and device","value":{"metrics":{"totalClicks":1284,"topCountryCode":"US","topCity":"Austin","cities":[{"city":"Austin","subdivision":"Texas","countryCode":"US","latitude":30.27,"longitude":-97.74,"clicks":42},{"city":"London","subdivision":"England","countryCode":"GB","latitude":51.5,"longitude":-0.12,"clicks":18}],"mobileClicks":80,"desktopClicks":40}}},"standard":{"summary":"Standard — country + device; city fields absent","value":{"metrics":{"totalClicks":1284,"topCountryCode":"US","mobileClicks":80,"desktopClicks":40}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (metrics are not served on the MCP host)"}]},"/analytics/summary":{"get":{"tags":["Analytics"],"summary":"Get account analytics summary","description":"Return click totals summed across every link the authenticated account owns, over the plan's `analytics.window`.\n\nRequires a Standard or Pro API key (Free-plan credentials are rejected).\n\n| Field | Standard | Pro |\n|-------|----------|-----|\n| `startDate` / `endDate` / `lookbackDays` | Present (30-day window) | Present (365-day window) |\n| `totalClicks` / `dailyClicks` | Present | Present |\n| `topLinks` | Present (top 10, lifetime clicks) | Present |\n| `topCountryCode` / `countries` | Present | Present |\n| `mobileClicks` / `desktopClicks` | Present | Present |\n\nCity data is not returned here; Pro gets it from `GET /analytics/detail`. `totalClicks` is the sum of `dailyClicks` in the window. Country and device counts start when breakdown collection began, so use `mobileClicks + desktopClicks` as their percentage denominator, not `totalClicks`. Examples are truncated to two days of `dailyClicks`; real responses return one entry per day in the window.","operationId":"getAccountAnalyticsSummary","responses":{"200":{"description":"Account analytics summary shaped by the account plan","content":{"application/json":{"schema":{"type":"object","required":["summary"],"properties":{"summary":{"$ref":"#/components/schemas/AccountAnalyticsSummary"}}},"examples":{"standard":{"summary":"Standard — 30-day window","value":{"summary":{"startDate":"2026-08-15","endDate":"2026-09-13","lookbackDays":30,"totalClicks":15,"dailyClicks":[{"metricDate":"2026-09-12","numClicks":10},{"metricDate":"2026-09-13","numClicks":5}],"topLinks":[{"path":"spring-sale","name":"Spring sale","longUrl":"https://example.com/spring","domain":"go.example.com","totalClicks":1284},{"path":"newsletter","name":null,"longUrl":"https://example.com/news","domain":null,"totalClicks":310}],"topCountryCode":"US","countries":[{"countryCode":"US","clicks":9},{"countryCode":"GB","clicks":4}],"mobileClicks":6,"desktopClicks":8}}},"pro":{"summary":"Pro — 365-day window, same fields","value":{"summary":{"startDate":"2025-09-14","endDate":"2026-09-13","lookbackDays":365,"totalClicks":15,"dailyClicks":[{"metricDate":"2026-09-12","numClicks":10},{"metricDate":"2026-09-13","numClicks":5}],"topLinks":[{"path":"spring-sale","name":"Spring sale","longUrl":"https://example.com/spring","domain":"go.example.com","totalClicks":1284},{"path":"newsletter","name":null,"longUrl":"https://example.com/news","domain":null,"totalClicks":310}],"topCountryCode":"US","countries":[{"countryCode":"US","clicks":9},{"countryCode":"GB","clicks":4}],"mobileClicks":6,"desktopClicks":8}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (analytics is not served on the MCP host)"}]},"/analytics/detail":{"get":{"tags":["Analytics"],"summary":"Get account analytics detail (Pro)","description":"Return the Pro account-wide breakdowns for the plan's `analytics.window`: campaign / source / medium rollup, browser, operating system, serving domain, top networks (ASN), a day-of-week × hour-of-day heatmap, and city.\n\n**Pro-only** (`analytics.detailed_breakdowns`). Standard API keys get `403` with `{ error: \"upgrade_required\", entitlement: \"analytics.detailed_breakdowns\" }`; Free-plan credentials are rejected.\n\n- Use `breakdownClicks` as the percentage denominator for every breakdown.\n- `heatmap[dayOfWeek][hour]` is in **UTC** with `0` = Sunday.\n- `domain: null` is the default short domain; `value: null` in a rollup is links without that UTM value.\n- Unknown browsers, operating systems, networks, and cities are left out of their lists.","operationId":"getAccountAnalyticsDetail","responses":{"200":{"description":"Pro account analytics breakdowns","content":{"application/json":{"schema":{"type":"object","required":["detail"],"properties":{"detail":{"$ref":"#/components/schemas/AccountAnalyticsDetail"}}},"example":{"detail":{"startDate":"2025-09-14","endDate":"2026-09-13","lookbackDays":365,"firstBreakdownDate":"2026-09-01","breakdownClicks":12,"campaigns":[{"value":"spring","clicks":10,"links":2},{"value":null,"clicks":2,"links":1}],"sources":[{"value":"twitter","clicks":6,"links":1},{"value":"newsletter","clicks":4,"links":1},{"value":null,"clicks":2,"links":1}],"mediums":[{"value":"social","clicks":6,"links":1},{"value":"email","clicks":4,"links":1},{"value":null,"clicks":2,"links":1}],"browsers":[{"name":"Chrome","clicks":8},{"name":"Safari","clicks":4}],"operatingSystems":[{"name":"macOS","clicks":7},{"name":"iOS","clicks":5}],"domains":[{"domain":null,"clicks":8},{"domain":"go.example.com","clicks":4}],"networks":[{"asName":"Comcast Cable Communications, LLC","clicks":7},{"asName":"AT&T Services, Inc.","clicks":5}],"heatmap":[[0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]],"topCity":"Austin","cities":[{"city":"Austin","subdivision":"Texas","countryCode":"US","latitude":30.27,"longitude":-97.74,"clicks":7},{"city":"London","subdivision":"England","countryCode":"GB","latitude":51.5,"longitude":-0.12,"clicks":5}]}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Plan does not include detailed analytics (Standard) or API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"},"example":{"error":"upgrade_required","entitlement":"analytics.detailed_breakdowns","tier":"standard","message":"Detailed account analytics require a Pro plan."}}}},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (analytics is not served on the MCP host)"}]},"/user/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhooks","description":"Return every outbound webhook subscription on the account. Works on any plan: after a downgrade from Pro, saved endpoints are still listed with `pausedByPlan: true` so they can be reviewed or deleted.","operationId":"listWebhooks","responses":{"200":{"description":"List of webhooks","content":{"application/json":{"schema":{"type":"object","required":["webhooks"],"properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"post":{"tags":["Webhooks"],"summary":"Create a webhook","description":"Create an outbound webhook subscription. The signing `secret` is returned **once** in this response — store it securely. HTTPS URLs only; private destinations are rejected.","operationId":"createWebhook","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreateInput"},"example":{"url":"https://hooks.example.com/trnqify","description":"Production events","subscribedEvents":["link.created","link.click_milestone","domain.verified"],"enabled":true}}}},"responses":{"200":{"description":"Webhook created; secret shown once","content":{"application/json":{"schema":{"type":"object","required":["webhook","secret"],"properties":{"webhook":{"$ref":"#/components/schemas/Webhook"},"secret":{"type":"string","description":"Signing secret — returned only on create and regenerate"}}}}}},"400":{"description":"Invalid URL, empty subscribedEvents, or request validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Plan does not include outbound webhooks, or webhook quota exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"},"examples":{"upgradeRequired":{"summary":"Non-Pro plan","value":{"error":"upgrade_required","entitlement":"webhooks.enabled","tier":"standard","message":"Outbound webhooks require a Pro plan."}},"limitExceeded":{"summary":"Pro webhook cap","value":{"error":"limit_exceeded","entitlement":"webhooks.max_count","tier":"pro","message":"Your plan allows at most 3 for webhooks.max_count."}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (webhooks are not served on the MCP host)"}]},"/user/webhooks/{webhookId}":{"get":{"tags":["Webhooks"],"summary":"Get a webhook","description":"Get one webhook subscription by id. The signing secret is never returned. Works on any plan; `pausedByPlan` is `true` when the account is not on Pro.","operationId":"getWebhook","parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string","pattern":"^wh_"},"description":"Webhook subscription id (`wh_...`)"}],"responses":{"200":{"description":"Webhook found","content":{"application/json":{"schema":{"type":"object","required":["webhook"],"properties":{"webhook":{"$ref":"#/components/schemas/Webhook"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook","description":"Update URL, description, subscribed events, or enabled flag. Does not rotate the signing secret.","operationId":"updateWebhook","parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string","pattern":"^wh_"},"description":"Webhook subscription id (`wh_...`)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookUpdateInput"}}}},"responses":{"200":{"description":"Webhook updated","content":{"application/json":{"schema":{"type":"object","required":["webhook"],"properties":{"webhook":{"$ref":"#/components/schemas/Webhook"}}}}}},"400":{"description":"Invalid URL or subscribedEvents","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Plan does not include outbound webhooks, or webhook quota exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"},"examples":{"upgradeRequired":{"summary":"Non-Pro plan","value":{"error":"upgrade_required","entitlement":"webhooks.enabled","tier":"standard","message":"Outbound webhooks require a Pro plan."}},"limitExceeded":{"summary":"Pro webhook cap","value":{"error":"limit_exceeded","entitlement":"webhooks.max_count","tier":"pro","message":"Your plan allows at most 3 for webhooks.max_count."}}}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook","description":"Delete a webhook subscription and release its quota slot. Works on any plan, so endpoints paused by a downgrade can still be removed.","operationId":"deleteWebhook","parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string","pattern":"^wh_"},"description":"Webhook subscription id (`wh_...`)"}],"responses":{"200":{"description":"Webhook deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook deleted"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (webhooks are not served on the MCP host)"}]},"/user/webhooks/{webhookId}/secret":{"post":{"tags":["Webhooks"],"summary":"Regenerate webhook secret","description":"Rotate the signing secret. The new secret is returned **once**; previous signatures using the old secret will fail verification.","operationId":"regenerateWebhookSecret","parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string","pattern":"^wh_"},"description":"Webhook subscription id (`wh_...`)"}],"responses":{"200":{"description":"Secret regenerated","content":{"application/json":{"schema":{"type":"object","required":["webhook","secret"],"properties":{"webhook":{"$ref":"#/components/schemas/Webhook"},"secret":{"type":"string","description":"New signing secret — returned only on create and regenerate"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Plan does not include outbound webhooks, or webhook quota exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"},"examples":{"upgradeRequired":{"summary":"Non-Pro plan","value":{"error":"upgrade_required","entitlement":"webhooks.enabled","tier":"standard","message":"Outbound webhooks require a Pro plan."}},"limitExceeded":{"summary":"Pro webhook cap","value":{"error":"limit_exceeded","entitlement":"webhooks.max_count","tier":"pro","message":"Your plan allows at most 3 for webhooks.max_count."}}}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (webhooks are not served on the MCP host)"}]},"/user/webhooks/{webhookId}/test":{"post":{"tags":["Webhooks"],"summary":"Send a test webhook event","description":"Enqueue a synthetic `webhook.test` delivery to this endpoint through the normal signing pipeline. The webhook must exist, belong to the caller, and be enabled. `webhook.test` is not part of `subscribedEvents`.","operationId":"testWebhook","parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string","pattern":"^wh_"},"description":"Webhook subscription id (`wh_...`)"}],"responses":{"200":{"description":"Test event queued","content":{"application/json":{"schema":{"type":"object","required":["eventId","eventType","queued"],"properties":{"eventId":{"type":"string","description":"Stable event id (`evt_...`)"},"eventType":{"type":"string","enum":["webhook.test"]},"queued":{"type":"boolean","enum":[true]}}},"example":{"eventId":"evt_01JABCDEFG...","eventType":"webhook.test","queued":true}}}},"400":{"description":"Webhook is disabled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook is disabled"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Plan does not include outbound webhooks, or webhook quota exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"},"examples":{"upgradeRequired":{"summary":"Non-Pro plan","value":{"error":"upgrade_required","entitlement":"webhooks.enabled","tier":"standard","message":"Outbound webhooks require a Pro plan."}},"limitExceeded":{"summary":"Pro webhook cap","value":{"error":"limit_exceeded","entitlement":"webhooks.max_count","tier":"pro","message":"Your plan allows at most 3 for webhooks.max_count."}}}}}},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (webhooks are not served on the MCP host)"}]},"/user/webhooks/{webhookId}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List recent delivery attempts","description":"Return the newest-first delivery attempt log for this webhook (capped at 25 attempts stored on the subscription). Supports basic `limit`/`offset` paging over that in-memory window. Owner-scoped; does not include request/response bodies.","operationId":"listWebhookDeliveries","parameters":[{"name":"webhookId","in":"path","required":true,"schema":{"type":"string","pattern":"^wh_"},"description":"Webhook subscription id (`wh_...`)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":25,"default":25},"description":"Page size (max 25)"},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0},"description":"Number of newest attempts to skip"}],"responses":{"200":{"description":"Delivery attempts","content":{"application/json":{"schema":{"type":"object","required":["deliveries","total"],"properties":{"deliveries":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryAttempt"}},"total":{"type":"integer","minimum":0,"description":"Total attempts available in the recent log (before paging)"}}},"example":{"deliveries":[{"deliveryId":"dlv_01JABCDEFG...","eventId":"evt_01JABCDEFG...","eventType":"link.created","attempt":1,"status":"success","statusCode":200,"latencyMs":142,"at":"2026-09-12T19:00:00.000Z"}],"total":1}}}},"400":{"description":"Invalid `limit` or `offset` query parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"examples":{"invalidLimit":{"summary":"Non-integer or out-of-range limit","value":{"message":"Invalid limit query parameter"}},"invalidOffset":{"summary":"Negative or non-integer offset","value":{"message":"Invalid offset query parameter"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Webhook not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Webhook not found"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}},"servers":[{"url":"https://api.trnqify.com","description":"Public REST API (webhooks are not served on the MCP host)"}]},"/mcp/usage":{"get":{"tags":["MCP"],"summary":"Get MCP usage","description":"Get current MCP usage limits for the authenticated user. Returns remaining counts for tool calls per minute, tool calls per day, link creation per day, and link creation per hour. All plans return their current usage; Free plan shows remaining quota, while Standard and Pro may show high limits or unlimited access depending on the cap.","operationId":"getMcpUsage","responses":{"200":{"description":"MCP usage data","content":{"application/json":{"schema":{"type":"object","required":["remainingCallsMinute","remainingToolCallsToday","remainingCreateLinksDay","remainingCreateLinksHour"],"properties":{"remainingCallsMinute":{"type":"integer","description":"Remaining tool calls allowed in the current minute window"},"remainingToolCallsToday":{"type":"integer","description":"Remaining tool calls allowed today (UTC day)"},"remainingCreateLinksDay":{"type":"integer","description":"Remaining createLink calls allowed today (UTC day)"},"remainingCreateLinksHour":{"type":"integer","description":"Remaining createLink calls allowed in the current hour"}}},"example":{"remainingCallsMinute":115,"remainingToolCallsToday":1970,"remainingCreateLinksDay":298,"remainingCreateLinksHour":74}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"}}}}},"components":{"schemas":{"Link":{"type":"object","properties":{"path":{"type":"string","description":"The unique path identifier for the link","example":"Hx7dKq9"},"shortUrl":{"type":"string","format":"uri","readOnly":true,"description":"The full short link on the link's own host: your custom domain when `domain` is set, otherwise trnq.io. Show it as-is; don't build it from `path`.","example":"https://trnq.io/Hx7dKq9"},"ownerId":{"type":"string","description":"The ID of the user who owns this link"},"longUrl":{"type":"string","description":"The destination URL"},"source":{"type":"string","description":"UTM source parameter"},"medium":{"type":"string","description":"UTM medium parameter"},"name":{"type":"string","description":"The name of the link"},"totalClicks":{"type":"integer","description":"Total number of clicks on this link","default":0},"campaign":{"type":"string","description":"UTM campaign parameter"},"previewTitle":{"type":"string","description":"Optional Open Graph title shown when the short URL is shared on social platforms"},"previewDescription":{"type":"string","description":"Optional Open Graph description shown when the short URL is shared on social platforms"},"previewImageUrl":{"type":"string","description":"Optional public HTTP or HTTPS image URL used as the Open Graph preview image (HTTPS recommended)"},"domain":{"type":"string","description":"Normalized custom domain host when the link was created on a verified custom domain; omitted for the default Trnqify host"},"createdBy":{"type":"string","description":"The ID of the user who created this link"},"updatedBy":{"type":"string","description":"The ID of the user who last updated this link"},"createdAt":{"type":"string","format":"date-time","description":"When the link was created"},"updatedAt":{"type":"string","format":"date-time","description":"When the link was last updated"}}},"LinkInput":{"type":"object","required":["longUrl","name"],"properties":{"longUrl":{"type":"string","description":"The destination URL to be shortened"},"name":{"type":"string","description":"The name/path for the shortened URL"},"source":{"type":["string","null"],"description":"UTM source parameter (optional)"},"medium":{"type":["string","null"],"description":"UTM medium parameter (optional)"},"campaign":{"type":["string","null"],"description":"UTM campaign parameter (optional)"},"previewTitle":{"type":["string","null"],"description":"Optional Open Graph title shown when the short URL is shared on social platforms"},"previewDescription":{"type":["string","null"],"description":"Optional Open Graph description shown when the short URL is shared on social platforms"},"previewImageUrl":{"type":["string","null"],"description":"Optional public HTTP or HTTPS image URL used as the Open Graph preview image (HTTPS recommended)"},"domain":{"type":["string","null"],"description":"Optional verified custom domain hostname owned by the authenticated account. Omit or use the default Trnqify host for backward-compatible short links."}}},"CreateLinkInput":{"allOf":[{"$ref":"#/components/schemas/LinkInput"},{"type":"object","properties":{"slug":{"type":["string","null"],"description":"Optional custom short-link path (**Pro-only**, `links.custom_slug`). Trimmed and lower-cased, then 3–30 characters of `a-z`, `0-9`, `-`, `_`; cannot start or end with `-`; reserved system paths and profane words are rejected (`400 invalid_slug`). A taken slug returns `409 slug_taken`. Omit, `null`, or `\"\"` to get a generated 7-character path. Cannot be changed after creation.","example":"spring-sale"}}}]},"DailyClickMetric":{"type":"object","required":["path","metricDate","numClicks"],"properties":{"path":{"type":"string","description":"Link path"},"metricDate":{"type":"string","format":"date","description":"ISO date (`YYYY-MM-DD`) for the daily bucket"},"ownerId":{"type":"string","description":"Owner user id"},"updatedAt":{"type":"string","format":"date-time","description":"ISO 8601 timestamp when the daily row was last written. Zero-filled days use the UTC start of `metricDate`."},"numClicks":{"type":"integer","minimum":0,"description":"Clicks on this path for `metricDate`"}}},"LinkMetricsSummary":{"type":"object","required":["totalClicks","dailyClicks"],"properties":{"totalClicks":{"type":"integer","minimum":0,"description":"Sum of `dailyClicks.numClicks` over the plan lookback window"},"dailyClicks":{"type":"array","description":"One entry per day in the lookback window, zero-filled when no clicks were recorded","items":{"$ref":"#/components/schemas/DailyClickMetric"}}}},"LinkCityBreakdown":{"type":"object","required":["city","clicks"],"properties":{"city":{"type":"string","description":"City name from GeoLite2"},"subdivision":{"type":["string","null"],"description":"Region/state subdivision when available"},"countryCode":{"type":["string","null"],"description":"ISO 3166-1 alpha-2 country code"},"latitude":{"type":["number","null"],"description":"Approximate city latitude for map plotting"},"longitude":{"type":["number","null"],"description":"Approximate city longitude for map plotting"},"clicks":{"type":"integer","minimum":0,"description":"Clicks attributed to this city + subdivision + country bucket in the lookback window"}}},"LinkMetricsDetail":{"type":"object","required":["totalClicks"],"properties":{"totalClicks":{"type":"integer","minimum":0,"description":"Lifetime click count stored on the link"},"topCountryCode":{"type":["string","null"],"description":"ISO country code with the most clicks in the lookback window. Present for Standard and Pro."},"topCity":{"type":["string","null"],"description":"City name with the most clicks in the lookback window (summed across region buckets). **Pro-only** (`analytics.dimension.geo` = `city`). Omitted on Standard. May be `null` on Pro when no city-enriched clicks exist in the window."},"cities":{"type":"array","description":"City breakdown for Pro map views, sorted by click count descending. Grouped by city + subdivision + countryCode. **Pro-only** — omitted on Standard. Empty array when Pro has no city-enriched clicks.","items":{"$ref":"#/components/schemas/LinkCityBreakdown"}},"mobileClicks":{"type":"integer","minimum":0,"description":"Clicks whose user-agent device type is mobile, within the lookback window. Present for Standard and Pro."},"desktopClicks":{"type":"integer","minimum":0,"description":"Non-mobile clicks in the lookback window (total window clicks minus `mobileClicks`). Present for Standard and Pro."}}},"AccountDailyClicks":{"type":"object","required":["metricDate","numClicks"],"properties":{"metricDate":{"type":"string","format":"date","description":"ISO date (`YYYY-MM-DD`) for the daily bucket"},"numClicks":{"type":"integer","minimum":0,"description":"Clicks across every link on the account for `metricDate`"}}},"AccountTopLink":{"type":"object","required":["path","name","longUrl","domain","totalClicks"],"properties":{"path":{"type":"string","description":"Link path"},"name":{"type":["string","null"],"description":"Link display name"},"longUrl":{"type":["string","null"],"description":"Destination URL"},"domain":{"type":["string","null"],"description":"Custom domain, or `null` for the default short domain"},"totalClicks":{"type":"integer","minimum":0,"description":"Lifetime click count stored on the link (not limited to the window)"}}},"CountryClicks":{"type":"object","required":["countryCode","clicks"],"properties":{"countryCode":{"type":"string","description":"ISO 3166-1 alpha-2 country code"},"clicks":{"type":"integer","minimum":0,"description":"Clicks from this country in the window"}}},"AccountAnalyticsSummary":{"type":"object","required":["startDate","endDate","lookbackDays","totalClicks","dailyClicks","topLinks"],"properties":{"startDate":{"type":"string","format":"date","description":"First day of the window (inclusive)"},"endDate":{"type":"string","format":"date","description":"Last day of the window (inclusive, today in UTC)"},"lookbackDays":{"type":"integer","minimum":1,"description":"Window length in days from `analytics.window` (30 on Standard, 365 on Pro)"},"totalClicks":{"type":"integer","minimum":0,"description":"Sum of `dailyClicks.numClicks` over the window (not lifetime)"},"dailyClicks":{"type":"array","description":"One entry per day in the window, oldest first, zero-filled when no clicks were recorded","items":{"$ref":"#/components/schemas/AccountDailyClicks"}},"topLinks":{"type":"array","description":"Up to 10 links by lifetime `totalClicks` descending; ties are ordered by `path`","items":{"$ref":"#/components/schemas/AccountTopLink"}},"topCountryCode":{"type":["string","null"],"description":"Country with the most clicks in the window, or `null` when none are known. Present for Standard and Pro."},"countries":{"type":"array","description":"Clicks per country, descending (ties ordered by `countryCode`). Unknown countries are left out, so this can sum to less than `mobileClicks + desktopClicks`. Present for Standard and Pro.","items":{"$ref":"#/components/schemas/CountryClicks"}},"mobileClicks":{"type":"integer","minimum":0,"description":"Mobile clicks in the window. Present for Standard and Pro."},"desktopClicks":{"type":"integer","minimum":0,"description":"Non-mobile clicks in the window. Present for Standard and Pro."}}},"AttributeRollup":{"type":"object","required":["value","clicks","links"],"properties":{"value":{"type":["string","null"],"description":"Campaign / source / medium value, or `null` for links without one"},"clicks":{"type":"integer","minimum":0,"description":"In-window clicks on links with this value"},"links":{"type":"integer","minimum":0,"description":"Links with this value that had clicks in the window"}}},"NamedClicks":{"type":"object","required":["name","clicks"],"properties":{"name":{"type":"string","description":"Browser or operating system name"},"clicks":{"type":"integer","minimum":0,"description":"Clicks in the window"}}},"DomainClicks":{"type":"object","required":["domain","clicks"],"properties":{"domain":{"type":["string","null"],"description":"Custom domain, or `null` for the default short domain"},"clicks":{"type":"integer","minimum":0,"description":"Clicks served on this domain in the window"}}},"NetworkClicks":{"type":"object","required":["asName","clicks"],"properties":{"asName":{"type":"string","description":"Autonomous system (network) name from GeoLite2-ASN"},"clicks":{"type":"integer","minimum":0,"description":"Clicks from this network in the window"}}},"AccountAnalyticsDetail":{"type":"object","required":["startDate","endDate","lookbackDays","firstBreakdownDate","breakdownClicks","campaigns","sources","mediums","browsers","operatingSystems","domains","networks","heatmap"],"properties":{"startDate":{"type":"string","format":"date","description":"First day of the window (inclusive)"},"endDate":{"type":"string","format":"date","description":"Last day of the window (inclusive, today in UTC)"},"lookbackDays":{"type":"integer","minimum":1,"description":"Window length in days from `analytics.window` (365 on Pro)"},"firstBreakdownDate":{"type":["string","null"],"format":"date","description":"Earliest day in the window with breakdown data, or `null` when there is none. When it is after `startDate`, breakdowns only cover data from this date on."},"breakdownClicks":{"type":"integer","minimum":0,"description":"Clicks counted by the breakdowns (the sum of `heatmap`). Use this as the percentage denominator for every breakdown, not the summary `totalClicks`."},"campaigns":{"type":"array","description":"In-window clicks grouped by link `campaign`, descending. Clicks on deleted links are left out.","items":{"$ref":"#/components/schemas/AttributeRollup"}},"sources":{"type":"array","description":"In-window clicks grouped by link `source`, descending","items":{"$ref":"#/components/schemas/AttributeRollup"}},"mediums":{"type":"array","description":"In-window clicks grouped by link `medium`, descending","items":{"$ref":"#/components/schemas/AttributeRollup"}},"browsers":{"type":"array","description":"Clicks per browser, descending; unknown browsers are left out","items":{"$ref":"#/components/schemas/NamedClicks"}},"operatingSystems":{"type":"array","description":"Clicks per operating system, descending; unknown values are left out","items":{"$ref":"#/components/schemas/NamedClicks"}},"domains":{"type":"array","description":"Clicks per serving domain, descending","items":{"$ref":"#/components/schemas/DomainClicks"}},"networks":{"type":"array","description":"Top 25 networks (ASN) by clicks, descending; unknown networks are left out","items":{"$ref":"#/components/schemas/NetworkClicks"}},"heatmap":{"type":"array","description":"Clicks by `heatmap[dayOfWeek][hour]` in **UTC**, `0` = Sunday. Shift whole-hour time zones client-side by rotating the 168 cells.","minItems":7,"maxItems":7,"items":{"type":"array","minItems":24,"maxItems":24,"items":{"type":"integer","minimum":0}}},"topCity":{"type":["string","null"],"description":"City name with the most clicks in the window (summed across region buckets), or `null` when none are enriched. **Pro-only** (`analytics.dimension.geo` = `city`)."},"cities":{"type":"array","description":"City breakdown for map views, sorted by clicks descending, grouped by city + subdivision + countryCode. **Pro-only** (`analytics.dimension.geo` = `city`).","items":{"$ref":"#/components/schemas/LinkCityBreakdown"}}}},"ErrorBody":{"type":"object","properties":{"error":{"type":"string","description":"`upgrade_required` or `limit_exceeded`","enum":["upgrade_required","limit_exceeded"]},"entitlement":{"type":"string","description":"Entitlement key that was denied"},"tier":{"type":"string","description":"Current plan tier on the account"},"message":{"type":"string"}}},"MessageBody":{"type":"object","properties":{"message":{"type":"string"}}},"SlugErrorBody":{"type":"object","required":["error","message","fields"],"properties":{"error":{"type":"string","description":"`invalid_slug` (400) or `slug_taken` (409)","enum":["invalid_slug","slug_taken"]},"rule":{"type":"string","description":"Validation rule that failed. Present on `invalid_slug` only.","enum":["invalid_type","too_short","too_long","invalid_characters","leading_or_trailing_hyphen","reserved","profanity"]},"message":{"type":"string"},"fields":{"type":"array","items":{"type":"string"},"example":["slug"]}}},"WebhookEventType":{"type":"string","description":"V1 catalog event type (excludes synthetic webhook.test)","enum":["link.created","link.updated","link.deleted","link.click_milestone","domain.verified","domain.verification_failed"]},"Webhook":{"type":"object","required":["webhookId","url","subscribedEvents","enabled","pausedByPlan","consecutiveFailures","createdAt","updatedAt"],"properties":{"webhookId":{"type":"string","description":"Subscription id (`wh_...`)"},"url":{"type":"string","format":"uri","description":"HTTPS delivery endpoint"},"description":{"type":"string","description":"Optional human label"},"subscribedEvents":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventType"},"description":"Non-empty subset of the V1 event catalog"},"enabled":{"type":"boolean","description":"Soft switch; disabled endpoints skip enqueue/delivery"},"pausedByPlan":{"type":"boolean","description":"`true` when the account is not on Pro. Deliveries stop without changing `enabled`, and resume on return to Pro."},"consecutiveFailures":{"type":"integer","minimum":0,"description":"Consecutive failed delivery attempts (auto-disable after 3)"},"lastDeliveryStatus":{"type":"string","enum":["success","failure"],"description":"Status of the most recent delivery attempt, when any"},"lastDeliveryAt":{"type":"string","format":"date-time","description":"When the most recent delivery attempt completed"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"WebhookDeliveryAttempt":{"type":"object","required":["deliveryId","eventId","eventType","attempt","status","latencyMs","at"],"properties":{"deliveryId":{"type":"string","description":"Unique per attempt (`dlv_` + ULID); retries get a new id"},"eventId":{"type":"string","description":"Stable event id shared across retries (`evt_...`)"},"eventType":{"type":"string","description":"Catalog event type or synthetic `webhook.test`"},"attempt":{"type":"integer","minimum":1,"description":"1-based attempt number for this event"},"status":{"type":"string","enum":["success","failure"]},"statusCode":{"type":"integer","description":"HTTP response status from the endpoint, when available"},"latencyMs":{"type":"integer","minimum":0,"description":"Round-trip latency in milliseconds"},"at":{"type":"string","format":"date-time","description":"When this attempt completed"}}},"WebhookCreateInput":{"type":"object","required":["url","subscribedEvents"],"properties":{"url":{"type":"string","format":"uri","pattern":"^https://","description":"HTTPS delivery endpoint (http:// rejected; private hosts rejected)"},"description":{"type":"string","description":"Optional human label"},"subscribedEvents":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventType"},"uniqueItems":true},"enabled":{"type":"boolean","default":true,"description":"Defaults to true when omitted"}}},"WebhookUpdateInput":{"type":"object","properties":{"url":{"type":"string","format":"uri","pattern":"^https://","description":"HTTPS delivery endpoint (http:// rejected; private hosts rejected)"},"description":{"type":["string","null"],"description":"Optional human label; send null to clear"},"subscribedEvents":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventType"},"uniqueItems":true},"enabled":{"type":"boolean"}}},"WebhookEventEnvelope":{"type":"object","required":["id","type","apiVersion","createdAt","data"],"properties":{"id":{"type":"string","description":"Stable event id with `evt_` prefix. Catalog deliveries use a deterministic hash (`evt_` + 26 hex chars); synthetic `webhook.test` uses a ULID. Shared across retries; delivery attempts use X-Trnqify-Delivery-Id."},"type":{"type":"string","description":"Event type from the catalog, or webhook.test"},"apiVersion":{"type":"string","description":"Payload contract version","enum":["2026-09-12"]},"createdAt":{"type":"string","format":"date-time","description":"ISO-8601 UTC when the event was produced"},"data":{"type":"object","description":"Event-specific payload","additionalProperties":true}}},"WebhookLinkEventData":{"type":"object","description":"Link snapshot for link.created / link.updated / link.deleted. Optional fields omitted when unset. Does not include internal-only fields (createdVia, orgId, createdBy, updatedBy).","properties":{"path":{"type":"string"},"shortUrl":{"type":"string","format":"uri","description":"The full short link on the link's own host (custom domain or trnq.io)."},"ownerId":{"type":"string"},"longUrl":{"type":"string"},"name":{"type":"string"},"source":{"type":"string"},"medium":{"type":"string"},"campaign":{"type":"string"},"totalClicks":{"type":"integer"},"previewTitle":{"type":"string"},"previewDescription":{"type":"string"},"previewImageUrl":{"type":"string"},"domain":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"WebhookClickMilestoneData":{"type":"object","required":["path","ownerId","totalClicks","milestone"],"properties":{"path":{"type":"string"},"shortUrl":{"type":"string","format":"uri","description":"The full short link on the link's own host (custom domain or trnq.io)."},"ownerId":{"type":"string"},"longUrl":{"type":"string"},"name":{"type":"string"},"domain":{"type":"string"},"totalClicks":{"type":"integer","description":"Link totalClicks after the write that crossed the boundary"},"milestone":{"type":"integer","description":"Boundary just crossed: floor(totalClicks / 100) * 100 (100, 200, …)"}}},"WebhookDomainEventData":{"type":"object","description":"Public custom-domain shape (toPublicCustomDomain). Internal orchestrator fields such as failedStep are omitted.","properties":{"domain":{"type":"string"},"status":{"type":"string"},"customerCnameHost":{"type":"string"},"customerCnameTarget":{"type":"string"},"acmCertificateStatus":{"type":["string","null"]},"acmDnsValidationName":{"type":["string","null"]},"acmDnsValidationValue":{"type":["string","null"]},"cloudFrontDistributionDomainName":{"type":["string","null"]},"cloudFrontDistributionStatus":{"type":["string","null"]},"retryable":{"type":["boolean","null"]},"lastErrorCode":{"type":["string","null"]},"lastErrorMessage":{"type":["string","null"]},"lastCheckedAt":{"type":["string","null"],"format":"date-time"},"verifiedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"WebhookTestData":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","enum":[true]}}}},"responses":{"Unauthorized":{"description":"Unauthorized — missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Unauthorized"}}}},"Forbidden":{"description":"Plan does not include API access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"NotFound":{"description":"Link not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Not Found"}}}},"RateLimited":{"description":"Usage plan quota or throttle exhausted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"InternalError":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageBody"},"example":{"message":"Internal Server Error"}}}}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"API key","description":"Clerk-issued API key (`ak_...`) as `Authorization: Bearer ak_...`. Use this header for REST `/links` on api.trnqify.com. Native MCP (`/mcp`) uses `x-api-key` instead — do not send both. Mint keys in the Trnqify app (Standard or Pro)."}}},"x-tagGroups":[{"name":"Endpoints","tags":["Links","Metrics","Analytics","Webhooks","MCP"]},{"name":"Guides","tags":["Connect an MCP client","Verifying webhook signatures"]}],"webhooks":{"link.created":{"post":{"tags":["Webhooks"],"summary":"link.created","description":"Fired when a link record is inserted (including API/MCP creates).","operationId":"receive_link_created","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["link.created"]},"data":{"$ref":"#/components/schemas/WebhookLinkEventData"}}}]},"example":{"id":"evt_a1b2c3d4e5f678901234567890","type":"link.created","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"path":"Ab3xYz1","shortUrl":"https://trnq.io/Ab3xYz1","ownerId":"user_...","longUrl":"https://example.com/landing","name":"Launch campaign","source":"newsletter","medium":"email","campaign":"q3-launch","totalClicks":0,"createdAt":"2026-09-12T18:00:00.000Z","updatedAt":"2026-09-12T18:00:00.000Z"}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}},"link.updated":{"post":{"tags":["Webhooks"],"summary":"link.updated","description":"Fired when a user-visible link field changes (longUrl, name, UTM/preview fields, domain). Click-only totalClicks writes do not emit this event.","operationId":"receive_link_updated","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["link.updated"]},"data":{"$ref":"#/components/schemas/WebhookLinkEventData"}}}]},"example":{"id":"evt_a1b2c3d4e5f678901234567890","type":"link.updated","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"path":"Ab3xYz1","shortUrl":"https://trnq.io/Ab3xYz1","ownerId":"user_...","longUrl":"https://example.com/landing-v2","name":"Launch campaign","totalClicks":42,"updatedAt":"2026-09-12T19:00:00.000Z"}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}},"link.deleted":{"post":{"tags":["Webhooks"],"summary":"link.deleted","description":"Fired when a link is removed. `data` is the last known link snapshot.","operationId":"receive_link_deleted","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["link.deleted"]},"data":{"$ref":"#/components/schemas/WebhookLinkEventData"}}}]},"example":{"id":"evt_a1b2c3d4e5f678901234567890","type":"link.deleted","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"path":"Ab3xYz1","shortUrl":"https://trnq.io/Ab3xYz1","ownerId":"user_...","longUrl":"https://example.com/landing","name":"Launch campaign","totalClicks":42}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}},"link.click_milestone":{"post":{"tags":["Webhooks"],"summary":"link.click_milestone","description":"Fired when link totalClicks crosses a multiple of 100 (100, 200, …).","operationId":"receive_link_click_milestone","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["link.click_milestone"]},"data":{"$ref":"#/components/schemas/WebhookClickMilestoneData"}}}]},"example":{"id":"evt_a1b2c3d4e5f678901234567890","type":"link.click_milestone","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"path":"Ab3xYz1","shortUrl":"https://go.example.com/Ab3xYz1","ownerId":"user_...","longUrl":"https://example.com/landing","name":"Launch campaign","domain":"go.example.com","totalClicks":100,"milestone":100}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}},"domain.verified":{"post":{"tags":["Webhooks"],"summary":"domain.verified","description":"Fired when a custom domain first reaches status `verified`.","operationId":"receive_domain_verified","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["domain.verified"]},"data":{"$ref":"#/components/schemas/WebhookDomainEventData"}}}]},"example":{"id":"evt_a1b2c3d4e5f678901234567890","type":"domain.verified","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"domain":"go.example.com","status":"verified","customerCnameHost":"go.example.com","customerCnameTarget":"cname.trnq.io","acmCertificateStatus":"ISSUED","verifiedAt":"2026-09-12T18:55:00.000Z","createdAt":"2026-09-10T12:00:00.000Z","updatedAt":"2026-09-12T18:55:00.000Z"}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}},"domain.verification_failed":{"post":{"tags":["Webhooks"],"summary":"domain.verification_failed","description":"Fired when a custom domain reaches terminal failure (`status: \"failed\"` and `retryable: false`). Transient failures do not emit.","operationId":"receive_domain_verification_failed","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["domain.verification_failed"]},"data":{"$ref":"#/components/schemas/WebhookDomainEventData"}}}]},"example":{"id":"evt_a1b2c3d4e5f678901234567890","type":"domain.verification_failed","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"domain":"go.example.com","status":"failed","retryable":false,"lastErrorCode":"DNS_VALIDATION_FAILED","lastErrorMessage":"ACM DNS validation timed out","createdAt":"2026-09-10T12:00:00.000Z","updatedAt":"2026-09-12T18:55:00.000Z"}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}},"webhook.test":{"post":{"tags":["Webhooks"],"summary":"webhook.test","description":"Synthetic test event from `POST /user/webhooks/{webhookId}/test`. Not part of `subscribedEvents`. Locked `data`: `{ \"ok\": true }`.","operationId":"receive_webhook_test","parameters":[{"name":"Content-Type","in":"header","required":true,"schema":{"type":"string","enum":["application/json; charset=utf-8"]}},{"name":"User-Agent","in":"header","required":true,"schema":{"type":"string","enum":["Trnqify-Webhooks/1.0"]}},{"name":"X-Trnqify-Event","in":"header","required":true,"schema":{"type":"string"},"description":"Same as body type"},{"name":"X-Trnqify-Delivery-Id","in":"header","required":true,"schema":{"type":"string"},"description":"Unique per attempt (`dlv_` + ULID)"},{"name":"X-Trnqify-Timestamp","in":"header","required":true,"schema":{"type":"string"},"description":"Unix seconds (decimal string) when the attempt was signed"},{"name":"X-Trnqify-Signature","in":"header","required":true,"schema":{"type":"string"},"description":"sha256= + lowercase hex HMAC-SHA256 of timestamp.body bytes"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEventEnvelope"},{"type":"object","properties":{"type":{"type":"string","enum":["webhook.test"]},"data":{"$ref":"#/components/schemas/WebhookTestData"}}}]},"example":{"id":"evt_01JABCDEFG...","type":"webhook.test","apiVersion":"2026-09-12","createdAt":"2026-09-12T19:00:00.000Z","data":{"ok":true}}}}},"responses":{"2XX":{"description":"Acknowledge delivery (any 2xx). Non-2xx triggers retry/backoff."}},"security":[]}}}}