# IPForge — the complete machine-readable guide > Version 2.3 — 2026-09-23 · contract 1.1.0 · GENERATED by scripts/gen-llms-full.mjs > from the v1 contract (the same generator that serves https://ipforge.xyz/openapi.json), the payments > config and the MCP tool registry — not edited by hand; `npm test` fails on drift. The concise > index is https://ipforge.xyz/llms.txt; the human-readable guide is https://ipforge.xyz/docs/integration. IPForge is an owner-operated link and landing-page measurement service. It helps a workspace understand technical delivery and interaction evidence for its own links, landing pages, and authorized tests. It is not an identity service, a public visitor-data feed, or a way to prove who a person is. ## Quickstart — token → create link → read summary → add domain → buy credits with USDC → verify Already have assets? The one call that answers "what real activity happened?" across every link and SVG is GET /api/v1/activity (MCP: `get_activity_overview`) — see step 3. 0. A human issues the API token, once, in the dashboard: https://ipforge.xyz/settings → API tokens → New token. The `ipf_…` secret is shown once; the default scopes are every scope except `captures:raw`. No /api/v1 operation and no MCP tool issues a token or creates an account, and this guide does not describe account creation — the account and its first token are a human's, made in the browser. Send the token on every request: Authorization: Bearer ipf_… 1. Prove the token — GET /api/v1/ping (no scope needed): curl https://ipforge.xyz/api/v1/ping -H "Authorization: Bearer ipf_…" → 200 { "ok": true, "owner": { "id" }, "token": { "id", "name", "prefix", "scopes": [...] } } 2. Create a link — POST /api/v1/links (scope links:write; costs at least 1 credit): curl -X POST https://ipforge.xyz/api/v1/links \ -H "Authorization: Bearer ipf_…" -H "Content-Type: application/json" \ -d '{"title":"Launch","type":"redirect","redirectUrl":"https://example.com/launch"}' → 201 { "link": { "id", "url", "shortId", "type", "stats": {...}, ... } } Share `link.url` exactly as returned — never compose one. Optional: `domain` — a platform domain (ipforge.xyz, brokolli.xyz, tarology.xyz; omitted → ipforge.xyz) or a verified custom domain — a `customSlug` (lowercase, may contain `/`), `autoContentSlug` to make repeated creates never collide. Not enough credits → 402 `insufficient_credits` with `required` and `available`. 3. Read what real activity happened — GET /api/v1/links/{id}/captures (scope captures:summary): curl "https://ipforge.xyz/api/v1/links/{id}/captures?limit=50" -H "Authorization: Bearer ipf_…" → 200 { "view": "summary", "items": [ { "id", "class", "hasDedupKey", "device", "os", "browser", "country", "firstSeenAt", "lastSeenAt", "visits" } ], "next_cursor" } One row per deduplicated human/unknown visitor; bots, duplicates and the owner's own visits are excluded. Page with `cursor`, poll with `since=` (there is no webhook). `?view=raw` returns the dashboard's capture records and needs the `captures:raw` scope the owner grants per token — a default token gets 403 `insufficient_scope`. Across every asset at once — GET /api/v1/activity (scopes links:read + captures:summary): every live link and SVG with its deduplicated real visitors, capture count, last real visit and top countries / devices in the window (`since`, default 30 days; at most 100 assets). One call, not one per asset: curl "https://ipforge.xyz/api/v1/activity?since=" -H "Authorization: Bearer ipf_…" → 200 { "since", "kind": null, "assets": [ { "kind", "id", "title", "url", "realVisitors", "captures", "lastSeenAt", "topCountries", "topDevices" } ], "truncated", "totals" } The same for a tracking SVG image, IPForge's second asset: POST /api/v1/svgs with `{"title","template"}` (template: document | image | chart | blank | invoice; optional `redirectUrl`) → 201 `{ "svg": { "id", "url", … } }` — `svg.url` serves `image/svg+xml`, embed it as returned — and GET /api/v1/svgs/{id}/captures reads its visitors through the very same projection, views and scopes as a link's. Same scopes throughout: a token that sees links sees SVGs. 4. Add a custom domain — POST /api/v1/domains (scope domains:write), set the two DNS records it returns, then POST /api/v1/domains/{id}/check: curl -X POST https://ipforge.xyz/api/v1/domains \ -H "Authorization: Bearer ipf_…" -H "Content-Type: application/json" \ -d '{"domain":"go.example.com"}' → 201 { "domain": { "id", "status": "pending", "instructions": { "aRecord": { "name", "value" }, "txtRecord": { "name", "value" } }, "tls": {...} } } curl -X POST https://ipforge.xyz/api/v1/domains/{id}/check -H "Authorization: Bearer ipf_…" → 200 { "verified": true|false, "checks": { "aRecord", "txtRecord", ... }, "domain": { "status": "verified"|"failed", ... } } Then create links with `"domain": "go.example.com"`. A pending domain may obtain a TLS certificate only within `tls.pendingWindowEndsAt` (72 h) and `tls.asksBudget`; verify before either runs out. 5. Buy credits with USDC — GET /api/v1/credits, POST /api/v1/orders (scope credits:write), pay from any wallet, POST /api/v1/orders/{id}/verify: curl https://ipforge.xyz/api/v1/credits -H "Authorization: Bearer ipf_…" → 200 { "credits": 3 } curl -X POST https://ipforge.xyz/api/v1/orders \ -H "Authorization: Bearer ipf_…" -H "Content-Type: application/json" \ -d '{"bundleId":"starter","chain":"polygon"}' → 201 { "order": { "id", "status": "pending", "amountUsdc", "expiresAt", ... }, "pay": { "to", "amountUsdc", "currency": "USDC", "chain", "usdcContract", "decimals", "expiresAt", "verify" } } Send exactly `pay.amountUsdc` USDC to `pay.to` on `pay.chain` before `pay.expiresAt` (one hour) — the address comes from the order, never from anywhere else. Then: curl -X POST https://ipforge.xyz/api/v1/orders/{id}/verify \ -H "Authorization: Bearer ipf_…" -H "Content-Type: application/json" \ -d '{"txHash":""}' → 200 { "order": { "status": "confirmed", ... }, "creditsAdded": 13, "credits": 16 } USDC is the only way to pay; there is no card or fiat rail. A failed verification (400 `payment_verification_failed`, `reason` says why) can be retried with the correct hash — 10 attempts per 5 minutes. Every error, on every route, is one envelope: { "error": { "code": "", "message": "…", "field"?: "…", "request_id": "…" } } Switch on `code`, never on `message`. Quote `request_id` when reporting a problem. Another owner's id, a deleted resource and a made-up id are the same 404 `not_found`. ## Authentication and scopes Every operation under https://ipforge.xyz/api/v1 and every MCP tool at https://ipforge.xyz/mcp is authenticated with an owner API token: `Authorization: Bearer ipf_…` (43 base62 characters after the prefix). A human issues it in the dashboard — Settings → API tokens — and chooses its scopes; the secret is shown once and stored hashed; the owner revokes it in the same list. A hand-issued token never expires. Hosted connectors obtain one through the OAuth 2.1 sign-in below; that token expires and is refreshed. No /api/v1 operation and no MCP tool issues a token or creates an account; the dashboard's own session-bound routes are not part of this contract. Scopes: - links:read — List your links and read their summaries - links:write — Create, edit, pause and delete links - captures:summary — Deduplicated, human-only activity summaries per link - captures:raw — Raw visitor records: IP address, geo/ASN, fingerprint, device, behavior — OPT-IN, never in the default set; not available to any MCP tool - domains:read — List your custom domains and their verification status - domains:write — Add and remove custom domains - credits:read — Read your credit balance and payment history - credits:write — Create USDC orders to buy credits Default set (a token issued without an explicit scope list): links:read links:write captures:summary domains:read domains:write credits:read credits:write. A token lacking a scope an operation requires → 403 `insufficient_scope` with `required` and `missing`. No token → 401 `missing_token`; unknown → 401 `invalid_token`; revoked → 401 `token_revoked`; an expired OAuth access token → 401 `token_expired` (refresh it); a suspended owner → 403 `account_suspended`. ## Rate limits Default: 120 requests per 60 s per token (per client IP when no well-formed token is presented). Every response — success, error, and the MCP JSON response — carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and X-Request-Id. Over the limit → 429 `rate_limited` with `Retry-After` (seconds). Operations with their own limit: - POST /api/v1/orders/{id}/verify — 10 per 300 s ## Error codes The closed enum (`components.schemas.ErrorCode` in /openapi.json). `error` may carry extra machine-readable members per code: `required`/`available` (insufficient_credits), `required`/`missing` (insufficient_scope), `reason` (payment_verification_failed), `status` (order_not_payable), `field` whenever one request property is at fault. - invalid_request → 400 — The body or query did not validate. `field` names the offending property when there is one. - token_limit_reached → 400 — The owner already holds the maximum number of active API tokens. - slug_reserved → 400 — The requested custom slug is a reserved path (`field`: customSlug). - domain_not_available → 400 — The requested link domain is neither a system domain nor one of the owner's verified custom domains (`field`: domain). - domain_not_allowed → 400 — The domain cannot be added as a custom domain: it is a system domain or already managed by the platform (`field`: domain). - domain_limit_reached → 400 — The owner already has the maximum number of custom domains. - order_not_payable → 400 — The order is not in a state that accepts a payment (already confirmed, or currently verifying). `status` carries the order's state. - payment_verification_failed → 400 — The transaction did not verify on-chain for this order. `reason` says why; the order can be retried with a correct hash. - missing_token → 401 — No `Authorization: Bearer ipf_…` header was presented. - invalid_token → 401 — The token is unknown. - token_revoked → 401 — The token was revoked by its owner. - token_expired → 401 — The access token's lifetime is over (OAuth grants expire; refresh it at `/oauth/token`). A hand-issued `ipf_` token never expires. - insufficient_credits → 402 — The account lacks the credits this operation costs. `required` and `available` carry the numbers. - account_suspended → 403 — The token's owner account is suspended. - insufficient_scope → 403 — The token lacks a scope this operation requires. `required` lists the scopes needed, `missing` the ones absent. - not_found → 404 — No such resource for this owner. Another owner's id, a deleted resource and a made-up id are indistinguishable. - slug_taken → 409 — The custom slug is already used on that domain (`field`: customSlug). - domain_taken → 409 — The domain is already registered as a custom domain. - tx_hash_used → 409 — The transaction hash already paid for a different order. - order_expired → 410 — The order's payment window has closed; create a new order. - rate_limited → 429 — Too many requests for this token. `Retry-After` and `X-RateLimit-Reset` say when to try again. - internal_error → 500 — An unexpected server error. `request_id` identifies it in the server log. ## Operations (20) — https://ipforge.xyz/openapi.json is the contract Base URL https://ipforge.xyz. JSON in, JSON out. Tags: meta (Token introspection.); activity (Start here: what real activity happened across every link and SVG, in one bounded answer (summary projection only).); links (Tracking links. Creating one costs credits; every link response carries its resolved public `url`.); svgs (Tracking SVG images — the product's second asset, under the same scopes and the same contract as links. Creating one costs credits; every SVG response carries the resolved public `url` it is served at (`/s/{shortId}`, `image/svg+xml`).); captures (What happened on a link or an SVG — one projection for both: summaries by default, raw behind `captures:raw`.); domains (Custom domains: add → set the A + TXT records → check → verified.); credits (The balance.); orders (Buying credits with USDC on Solana, Polygon or BNB Chain: create an order, pay from any wallet, verify with the transaction hash.) ### GET /api/v1/ping — Who am I, what may I do The smallest proof a token works: its owner id and its own scopes. Requires no scope. operationId ping · tag meta · scopes: (any live token) · rate limit 120/60s response: 200 → Ping errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, rate_limited, internal_error ### GET /api/v1/activity — What real activity happened, across every link and SVG Start here. One call: every live asset of the owner — links and SVGs — with its deduplicated real visitors, capture count, last real visit and top countries / device families in the window (`since`, default the last 30 days), most recently active first, at most 100 assets. Summary projection only. Then read one asset's visitors with its `captures` operation. operationId getActivityOverview · tag activity · scopes: links:read captures:summary · rate limit 120/60s query parameters: - since (string (date-time), optional) — Start of the window (ISO 8601). Omitted → the last 30 days. - kind ("link" | "svg", optional) — Only links, or only SVGs. Omitted → both. response: 200 → ActivityOverview errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, rate_limited, internal_error ### GET /api/v1/links — List your links operationId listLinks · tag links · scopes: links:read · rate limit 120/60s query parameters: - cursor (string, optional, minLength 1) — `next_cursor` from the previous page. - limit (integer, optional, default 50, min 1, max 200) response: 200 → LinkPage errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, rate_limited, internal_error ### POST /api/v1/links — Create a link Costs credits (base 1, more for advanced tracking / smart routing / paid templates). The response carries the resolved public `url`. operationId createLink · tag links · scopes: links:write · rate limit 120/60s body (application/json): - title (string, required, minLength 1, maxLength 200) - type ("redirect" | "landing", required) - redirectUrl (string (uri), optional) — Required when type is `redirect`. - domain (string, optional) — A platform domain (`ipforge.xyz`, `brokolli.xyz`, `tarology.xyz`) or one of the owner's verified custom domains; omitted → `ipforge.xyz`. - customSlug (string, optional, minLength 1, maxLength 100) — The path the link is served at on its domain. Omitted → a generated content-shaped slug. - urlExtension (string, optional) - autoContentSlug (boolean, optional) — Append a random suffix to `customSlug` so repeated creates never collide. - behaviorTrackingEnabled (boolean, optional) - trackingConfig (object, optional) - smartRouting (object | null, optional) - rules (object[], optional) - id (string, required) - name (string, required, minLength 1) - conditions (object (type: "device" | "country" | "browser" | "os" | "time")[], required, minItems 1) - destinationUrl (string (uri), required) - expiration (object, optional) - expiresAt (string (date-time), required) - fallbackUrl (string (uri), optional) - abTest (object[], optional) - id (string, required) - name (string, required, minLength 1) - url (string (uri), required) - weight (number, required, min 0, max 100) - landingPage (object, optional) - name (string, required, minLength 1) - templateId (string, optional) - customHtml (string, optional) - customCss (string, optional) - configJson (object, optional) response: 201 → LinkResponse errors: invalid_request, slug_reserved, domain_not_available, missing_token, invalid_token, token_revoked, token_expired, insufficient_credits, account_suspended, insufficient_scope, slug_taken, rate_limited, internal_error ### GET /api/v1/links/{id} — Get one link operationId getLink · tag links · scopes: links:read · rate limit 120/60s path parameters: {id} response: 200 → LinkResponse errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### DELETE /api/v1/links/{id} — Delete a link Soft-delete: the link stops resolving everywhere; its records are retained. operationId deleteLink · tag links · scopes: links:write · rate limit 120/60s path parameters: {id} response: 200 → Deleted errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### GET /api/v1/links/{id}/captures — What real activity happened on a link Default `view=summary`: one row per deduplicated human/unknown visitor — class, device/OS/browser family, country, first/last seen, visit count; never an IP, ASN, fingerprint, user-agent, wallet or behavior record. `view=raw` returns the dashboard's capture rows and requires the `captures:raw` scope the owner grants per token. Newest first; page with `cursor`, poll with `since`. operationId listLinkCaptures · tag captures · scopes: captures:summary; with view=raw: captures:raw · rate limit 120/60s path parameters: {id} query parameters: - view ("summary" | "raw", optional, default "summary") — `summary` (default) needs `captures:summary`; `raw` needs the `captures:raw` scope the owner grants per token. - since (string (date-time), optional) — Only visitors seen at or after this instant (summary: by last visit; raw: by capture time). - cursor (string, optional, minLength 1) — `next_cursor` from the previous page. - limit (integer, optional, default 50, min 1, max 200) response: 200 → CapturesPage errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### GET /api/v1/svgs — List your SVGs Your tracking SVG images, newest first, each with its resolved public `url` and its capture stats. The `links:read` scope covers SVGs: a scope names what data a token may see, not which table it lives in. operationId listSvgs · tag svgs · scopes: links:read · rate limit 120/60s query parameters: - cursor (string, optional, minLength 1) — `next_cursor` from the previous page. - limit (integer, optional, default 50, min 1, max 200) response: 200 → SvgPage errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, rate_limited, internal_error ### POST /api/v1/svgs — Create an SVG Costs credits (base 1, one more per enabled advanced tracking group) — the same rule as the dashboard. The response carries the resolved public `url` the image is served at. operationId createSvg · tag svgs · scopes: links:write · rate limit 120/60s body (application/json): - title (string, required, minLength 1, maxLength 200) - template ("document" | "image" | "chart" | "blank" | "invoice", required) — What the image renders as: a document, an image placeholder, a chart, a blank canvas or an invoice. - redirectUrl (string (uri), optional) — Where a click on the rendered SVG goes. Omitted → the image is not clickable. - trackingConfig (object, optional) — Collector toggles by id (canvas, webgl, audio, fonts, navigator, screen, timezone, …). Omitted → the SVG defaults. Each enabled `Extensions`/`Advanced` group costs one more credit. response: 201 → SvgResponse errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, insufficient_credits, account_suspended, insufficient_scope, rate_limited, internal_error ### GET /api/v1/svgs/{id} — Get one SVG operationId getSvg · tag svgs · scopes: links:read · rate limit 120/60s path parameters: {id} response: 200 → SvgResponse errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### DELETE /api/v1/svgs/{id} — Delete an SVG Soft-delete: the image stops resolving everywhere; its records are retained. operationId deleteSvg · tag svgs · scopes: links:write · rate limit 120/60s path parameters: {id} response: 200 → Deleted errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### GET /api/v1/svgs/{id}/captures — What real activity happened on an SVG The same projection as a link's captures. Default `view=summary`: one row per deduplicated human/unknown visitor — class, device/OS/browser family, country, first/last seen, visit count; never an IP, ASN, fingerprint, user-agent, wallet or behavior record. `view=raw` returns the dashboard's capture rows and requires the `captures:raw` scope the owner grants per token. Newest first; page with `cursor`, poll with `since`. operationId listSvgCaptures · tag captures · scopes: captures:summary; with view=raw: captures:raw · rate limit 120/60s path parameters: {id} query parameters: - view ("summary" | "raw", optional, default "summary") — `summary` (default) needs `captures:summary`; `raw` needs the `captures:raw` scope the owner grants per token. - since (string (date-time), optional) — Only visitors seen at or after this instant (summary: by last visit; raw: by capture time). - cursor (string, optional, minLength 1) — `next_cursor` from the previous page. - limit (integer, optional, default 50, min 1, max 200) response: 200 → CapturesPage errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### GET /api/v1/domains — List your custom domains operationId listDomains · tag domains · scopes: domains:read · rate limit 120/60s response: 200 → DomainList errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, rate_limited, internal_error ### POST /api/v1/domains — Add a custom domain Returns the exact A and TXT records to set. The domain is `pending` until `check` sees both; a pending domain can obtain a TLS certificate only within `tls.pendingWindowEndsAt` and `tls.asksBudget`. operationId createDomain · tag domains · scopes: domains:write · rate limit 120/60s body (application/json): - domain (string, required, minLength 1, maxLength 253) — A hostname you control, lowercase, e.g. `go.example.com`. response: 201 → DomainResponse errors: invalid_request, domain_not_allowed, domain_limit_reached, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, domain_taken, rate_limited, internal_error ### GET /api/v1/domains/{id} — Get one custom domain operationId getDomain · tag domains · scopes: domains:read · rate limit 120/60s path parameters: {id} response: 200 → DomainResponse errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### DELETE /api/v1/domains/{id} — Remove a custom domain Links on the domain are deactivated. operationId deleteDomain · tag domains · scopes: domains:write · rate limit 120/60s path parameters: {id} response: 200 → Deleted errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### POST /api/v1/domains/{id}/check — Check a domain's DNS and update its status Resolves the A record (must include the platform IP) and the `_ipforge-verify` TXT record (must equal the token). Both present → `verified`; otherwise `failed` (re-check any time). operationId checkDomain · tag domains · scopes: domains:write · rate limit 120/60s path parameters: {id} response: 200 → DomainCheck errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, rate_limited, internal_error ### GET /api/v1/credits — Your credit balance operationId getCredits · tag credits · scopes: credits:read · rate limit 120/60s response: 200 → Credits errors: missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, rate_limited, internal_error ### POST /api/v1/orders — Create a USDC order for credits Pick a bundle or a custom amount and a chain. Send exactly `pay.amountUsdc` USDC to `pay.to` on `pay.chain` within the hour, then verify with the transaction hash. operationId createOrder · tag orders · scopes: credits:write · rate limit 120/60s body (application/json): A priced bundle (`starter` 13 / `pro` 50 / `bulk` 200 credits) or `custom` with `customCredits`. one of: (1) - bundleId ("starter" | "pro" | "bulk", required) - chain ("solana" | "polygon" | "bsc", required) - customCredits (must be absent, optional) (2) - bundleId (literal "custom", required) - chain ("solana" | "polygon" | "bsc", required) - customCredits (integer, required, min 1, max 1000) — 1 USDC = 1 credit, 1–1000. response: 201 → OrderResponse errors: invalid_request, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, rate_limited, internal_error ### POST /api/v1/orders/{id}/verify — Verify an order's payment and credit the account Checks the transaction on-chain against the order's amount and wallet. Success credits the account atomically. A failed verification can be retried with the correct hash. Limited to 10 attempts per 5 minutes. operationId verifyOrder · tag orders · scopes: credits:write · rate limit 10/300s path parameters: {id} body (application/json): - txHash (string, required, minLength 10, maxLength 128) — The USDC transfer's transaction hash / signature on the order's chain. response: 200 → OrderVerified errors: invalid_request, order_not_payable, payment_verification_failed, missing_token, invalid_token, token_revoked, token_expired, account_suspended, insufficient_scope, not_found, tx_hash_used, order_expired, rate_limited, internal_error ## Response schemas (27 components) ### ActivityAsset — One asset's activity in the window — summary projection only: counts, families, countries; never a visitor record. - kind ("link" | "svg", required) - id (string, required) — Use it as `{ kind, id }` with `list_real_capture_summaries` / `compare_device_profiles` (MCP), or with the asset's own `captures` operation. - title (string, required) - url (string (uri), required) — The asset's resolved public URL. - realVisitors (integer, required) — Deduplicated human/unknown visitors in the window — the same rule as a capture summary row. Image proxies are NOT counted here; see `proxyRenders`. - proxyRenders (integer, required) — Fetches by a caching image proxy in the window — a floor on how often the asset was displayed somewhere, never a number of people. Read it with the overview's `reading`. - lastProxyRenderAt (string (date-time) | null, required) — The latest proxy render in the window, or null. Use it for "rendered as recently as …". - captures (integer, required) — Every capture in the window, bots, proxies and duplicates included. - lastSeenAt (string (date-time) | null, required) — The latest real visit in the window, or null when there was none. - topCountries (object[], required) — Up to 3, by deduplicated visitors; visitors without a known country are not listed. - country (string, required) — ISO 3166-1 alpha-2. - visitors (integer, required) - topDevices (object[], required) — Up to 3 device families, by deduplicated visitors. - device ("desktop" | "mobile" | "tablet" | "unknown", required) - visitors (integer, required) ### ActivityOverview — What real activity happened, across every link and SVG, in one answer. Window default 30 days; at most 100 assets, 3 countries and devices each. Summary projection only. - since (string (date-time), required) — The window's start, as applied. - kind ("link" | "svg" | null, required) — The kind filter applied, or null for both. - assets (ActivityAsset[], required) — Every live asset of the owner (with zeros when idle), most recently active first, at most 100. - truncated (boolean, required) — true when the owner has more than 100 assets — the idle tail was cut; page them with the list operations. - reading (literal "A proxy render is a caching image proxy (GitHub Camo, the Gmail image proxy) fetching the asset for a reader we never see. N renders means the asset was displayed somewhere at least N times: one person refreshing five times and five people looking once produce the same number, and the proxy's cache removes renders from the count entirely. Read it as evidence of activity, never as an audience, a location or a device.", required) — How to read every `proxyRenders` below. Quoted from the published measurement rules, not paraphrased. - totals (object, required) - assets (integer, required) — Live assets counted, before the cap. - realVisitors (integer, required) — Sum over the listed assets. - proxyRenders (integer, required) — Sum over the listed assets. - captures (integer, required) — Sum over the listed assets. ### CaptureRaw — The dashboard's capture record. Requires the `captures:raw` scope. - id (string, required) - linkId (string | null, required) — The link this capture belongs to, or null when it belongs to an SVG. - svgId (string | null, required) — The SVG this capture belongs to, or null when it belongs to a link. - ipAddress (string | null, required) - ipGeo (object | null, required) - country (string, optional) - countryCode (string, optional) - city (string, optional) - region (string, optional) - isp (string, optional) - asn (string, optional) - asnOrganization (string, optional) - isAnonymous (boolean, optional) - isAnonymousProxy (boolean, optional) - isAnonymousVpn (boolean, optional) - isHostingProvider (boolean, optional) - isPublicProxy (boolean, optional) - isTorExitNode (boolean, optional) - lat (number, optional) - lng (number, optional) - userAgent (string | null, required) - acceptLanguage (string | null, required) - referer (string | null, required) - fingerprint (object | null, required) — The full client fingerprint, including any detected wallets. - fingerprintHash (string | null, required) - deviceType (string | null, required) - os (string | null, required) - browser (string | null, required) - isDuplicate (boolean, required) - isOwner (boolean, required) - captureClass ("human" | "bot" | "unknown", required) - captureClassReason (string, required) - ipQuality ("residential" | "vpn" | "datacenter" | "tor" | "unknown", required) - ja4 (string | null, required) - smartRouteMatch (any | null, required) - note (string | null, required) - tags (string[] | null, required) - createdAt (string (date-time), required) - visitCount (integer, required) - behaviorSession (object | null, required) - id (string, required) - captureId (string, required) - firstSeenAt (string (date-time), required) - lastSeenAt (string (date-time), required) - visibleDwellMs (integer, required) - engaged (boolean, required) - scrollDepthBucket (string, required) - ctaOutcome (string, required) - expiresAt (string (date-time), required) ### CapturesPage — `summary` rows are deduplicated human-only visitors (default token). `raw` rows are the dashboard's capture records and need the `captures:raw` scope. one of: (1) - view (literal "summary", required) - items (CaptureSummary[], required) - next_cursor (string | null, required) (2) - view (literal "raw", required) - items (CaptureRaw[], required) - next_cursor (string | null, required) ### CaptureSummary — One deduplicated human/unknown visitor — what a default token sees. Never an IP, ASN, ISP, city, coordinates, JA4, user-agent, fingerprint, wallet, behavior record or owner note. - id (string, required) — Id of this visitor's most recent capture. A handle for `since`-style follow-ups, not a visitor identifier. - class ("human" | "proxy" | "unknown", required) — `human` = a browser with no bot signal; `proxy` = a caching image proxy (GitHub Camo, the Gmail image proxy) fetched the asset for a reader we never see — a render, not a person, and never a location or a device; `unknown` = too few signals to say. Bots never appear. - hasDedupKey (boolean, required) — A device fingerprint hash exists, so this visitor's repeat visits were collapsed into this row. - device ("desktop" | "mobile" | "tablet" | "unknown", required) - os (string | null, required) — Family only — `macOS`, `Windows`, `iOS`, `Android`, … — never a version. - browser (string | null, required) — Family only — `Chrome`, `Safari`, … — never a version or the user-agent string. - country (string | null, required) — ISO 3166-1 alpha-2 from IP geolocation. Never the city, region, coordinates, ISP or ASN. - firstSeenAt (string (date-time), required) - lastSeenAt (string (date-time), required) - visits (integer, required, min 1) — Captures collapsed into this row (1 when there is no dedup key). ### Credits - credits (integer, required) — Credits available to spend. Creating a link costs at least 1. ### Deleted - deleted (literal true, required) - id (string, required) ### DnsInstructions — Set both records at the registrar, then call `check`. - aRecord (object, required) - type (literal "A", required) - name (string, required) - value (string, required) - txtRecord (object, required) - type (literal "TXT", required) - name (string, required) - value (string, required) ### Domain - id (string, required) - domain (string, required) - status ("pending" | "verified" | "failed", required) - verifiedAt (string (date-time) | null, required) - createdAt (string (date-time), required) - instructions (DnsInstructions, required) - tls (TlsState, required) ### DomainCheck — The DNS check just performed and the domain's resulting state. - domain (Domain, required) - verified (boolean, required) - checks (object, required) - aRecord (boolean, required) - txtRecord (boolean, required) - aRecordValues (string[], required) - txtRecordValues (string[], required) ### DomainList - items (Domain[], required) ### DomainResponse - domain (Domain, required) ### Error — The one error envelope every v1 route answers with. - error (object, required) — May carry extra machine-readable members per code: `required`/`available` (insufficient_credits), `required`/`missing` (insufficient_scope), `reason` (payment_verification_failed), `status` (order_not_payable). - code (ErrorCode, required) - message (string, required) — Human-readable; never parse it — switch on `code`. - field (string, optional) — The request property at fault, when there is one. - request_id (string, required) — Quote it when reporting a problem; it is in the server log. ### ErrorCode — The closed set of v1 error codes. enum: invalid_request, token_limit_reached, slug_reserved, domain_not_available, domain_not_allowed, domain_limit_reached, order_not_payable, payment_verification_failed, missing_token, invalid_token, token_revoked, token_expired, insufficient_credits, account_suspended, insufficient_scope, not_found, slug_taken, domain_taken, tx_hash_used, order_expired, rate_limited, internal_error ### Link - id (string, required) - shortId (string, required) - title (string, required) - type ("redirect" | "landing", required) - url (string (uri), required) — The resolved public URL — share this; never compose it. - domain (string | null, required) - customSlug (string | null, required) - redirectUrl (string | null, required) - isActive (boolean, required) - captureCount (integer, required) - createdAt (string (date-time), required) - stats (LinkStats, required) ### LinkPage - items (Link[], required) - next_cursor (string | null, required) ### LinkResponse - link (Link, required) ### LinkStats — Capture counts by class. The same shape for a link and for an SVG. - total (integer, required) - real (integer, required) — Human/unknown, not a proxy, not duplicate, not the owner — what `captures` lists in summary view. - bot (integer, required) - proxy (integer, required) — Fetches by a caching image proxy (GitHub Camo, the Gmail image proxy) — the asset was rendered somewhere at least this many times, by readers we never see. Never a count of people: `real` is the only visitor number. - duplicate (integer, required) - owner (integer, required) — The owner's own test visits. ### Order - id (string, required) - status ("pending" | "verifying" | "confirmed" | "expired" | "failed", required) - bundleId ("starter" | "pro" | "bulk" | "custom", required) - chain ("solana" | "polygon" | "bsc", required) - credits (integer, required) - amountUsdc (string, required) — Decimal string, e.g. `9.50`. - txHash (string | null, required) - expiresAt (string (date-time), required) - confirmedAt (string (date-time) | null, required) - createdAt (string (date-time), required) ### OrderResponse - order (Order, required) - pay (PaymentInstructions, required) ### OrderVerified - order (Order, required) - creditsAdded (integer, required) - credits (integer, required) — The account balance after crediting. ### PaymentInstructions — Everything a wallet needs. No browser signing: send, then verify with the tx hash. - amountUsdc (string, required) - currency (literal "USDC", required) - chain ("solana" | "polygon" | "bsc", required) - chainName (string, required) - to (string, required) — The receiving wallet. Send exactly `amountUsdc` of USDC on `chain`. - usdcContract (string, required) — The USDC token mint/contract on that chain. - decimals (integer, required) - expiresAt (string (date-time), required) - verify (string, required) — Where to submit the transaction hash once sent. ### Ping — Who am I, what may I do. - ok (literal true, required) - owner (object, required) - id (string, required) - token (object, required) - id (string, required) - name (string, required) - prefix (string, required) - scopes (string[], required) ### Svg — A tracking SVG image. Same contract as a link: `url` resolved, `stats` attached, visitors never inlined. - id (string, required) - shortId (string, required) - title (string, required) - template ("document" | "image" | "chart" | "blank" | "invoice", required) — What the image renders as. - url (string (uri), required) — The resolved public URL of the image (`/s/{shortId}`) — embed or share this; never compose it. - redirectUrl (string | null, required) — Where a click on the image goes, or null. - isActive (boolean, required) - captureCount (integer, required) - createdAt (string (date-time), required) - stats (LinkStats, required) ### SvgPage - items (Svg[], required) - next_cursor (string | null, required) ### SvgResponse - svg (Svg, required) ### TlsState — The pending-TLS budget: verify before the window or the budget runs out. - certificateEligible (boolean, required) — Whether the edge may issue a TLS certificate for this domain right now. - reason ("verified" | "pending_within_window" | "pending_window_expired" | "pending_budget_exhausted" | "failed", required) - pendingWindowEndsAt (string (date-time) | null, required) — A pending domain may obtain a certificate only until this instant (72h after creation). - asksUsed (integer | null, required) — Certificate asks spent inside the pending window; null when no counter exists yet. - asksBudget (integer, required) — Asks a pending domain may spend before verification. ## Credits, bundles and chains Creating a link or an SVG costs credits: base 1, more for advanced tracking, smart routing or a paid template (`prepare_link` / `prepare_svg` on MCP price a draft before anything is spent; `publish_draft` spends). Credits are bought with USDC only — there is no card or fiat payment. An order is paid by sending USDC from any wallet to the address the order returns (`pay.to`), then verified with the transaction hash. Bundles (`bundleId`): - starter — Starter: 13 credits for 9.50 USDC - pro — Pro: 50 credits for 27.55 USDC - bulk — Bulk: 200 credits for 95.00 USDC - custom — `customCredits` 1–1000 at 1.00 USDC per credit Chains (`chain`) — the USDC token contract the transfer must use; the receiving address is in each order: - solana — Solana: USDC EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals) - polygon — Polygon: USDC 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 (6 decimals) - bsc — BNB Chain: USDC 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d (18 decimals) An order is payable for 1 hour (`expiresAt`); after that it is 410 `order_expired` — create a new one. Order status: pending → verifying → confirmed | expired | failed. A transaction hash pays for one order only (409 `tx_hash_used`). ## MCP server — POST https://ipforge.xyz/mcp Remote MCP over Streamable HTTP, stateless, JSON responses (no SSE stream; GET answers 405). Server name `ipforge` 1.1.0. Authentication is the same bearer token as the API (any live token opens the connection; each tool checks its own scopes) — or, for hosted connectors, the OAuth 2.1 sign-in below. The same rate limit and headers apply per HTTP request. Connect from Claude Code or another CLI/IDE host: claude mcp add --transport http ipforge https://ipforge.xyz/mcp --header "Authorization: Bearer ipf_…" Connect from claude.ai or ChatGPT: add a custom connector at https://ipforge.xyz/mcp and sign in. Tools (13) — each is a v1 operation with that operation's scopes (an asset-generic tool is one operation per asset kind — a link's and an SVG's — named by `{ kind, id }`); inputs are the operation's own fields. Start with `get_activity_overview`. Raw capture records have NO tool: every token, even one holding `captures:raw`, gets summaries here. Errors are the v1 envelope as the tool result's text with `isError: true`. ### get_activity_overview — What real activity happened — every link and SVG, one answer START HERE for "what happened?": every link and SVG with its deduplicated real visitors, proxy renders, captures, last real visit and top countries / devices in the window (`since`, default 30 days; at most 100 assets, most recently active first). `realVisitors` and `proxyRenders` answer DIFFERENT questions — never add them together or report them as one number. A visitor is a browser that reached us. A render is a caching image proxy (GitHub Camo, the Gmail image proxy) fetching the asset for a reader we never see. An asset embedded in a GitHub README or an email is normally ALL renders and no visitors: say "rendered at least N times", never "N visitors", and never turn renders into people, countries or devices. The response's `reading` is the wording to quote when the distinction matters. One call answers the question; `list_real_capture_summaries` is for one asset's individual visitors. Summary projection only. derives from GET /api/v1/activity · scopes: links:read captures:summary · read-only · spends nothing input: - since (string (date-time), optional) — Start of the window (ISO 8601). Omitted → the last 30 days. - kind ("link" | "svg", optional) — Only links, or only SVGs. Omitted → both. output: ActivityOverview ### list_assets — List your links and SVGs The inventory: your links and SVGs together (or one `kind`), newest first, each tagged with `kind` and carrying its resolved public `url` and capture stats. Page with `cursor`. For activity, prefer `get_activity_overview`. derives from GET /api/v1/links + GET /api/v1/svgs · scopes: links:read · read-only · spends nothing input: - cursor (string, optional, minLength 1) — `next_cursor` from the previous page. - limit (integer, optional, default 50, min 1, max 200) - kind ("link" | "svg", optional) — Only links, or only SVGs. Omitted → both. output: AssetPage ### get_asset — Get one link or SVG One link or SVG (`kind` + `id`) with its stats — never its visitors (`list_real_capture_summaries` for those). Another owner's id, a deleted asset and a made-up id are all `not_found`. derives from GET /api/v1/links/{id} + GET /api/v1/svgs/{id} · scopes: links:read · read-only · spends nothing input: - kind ("link" | "svg", required) — `link` or `svg`. - id (string, required, minLength 1) — The asset's `id` — from `get_activity_overview` or `list_assets`. output: AssetResponse ### list_real_capture_summaries — One asset's real visitors One asset's visitors (`asset: { kind, id }`): one row per deduplicated human/unknown visitor — class, device / OS / browser family, country, first and last seen, visits. Never an IP, fingerprint, user-agent, wallet or behavior record, for any token. A row with `class: "proxy"` is a caching image proxy (GitHub Camo, the Gmail image proxy) that fetched the asset for a reader we never see — it IS a capture and is listed in full, because for an email pixel or a README badge it is the only capture that will ever exist. Report those rows as renders, never as people, and never read a country or device off one. Newest first; page with `cursor`, poll with `since`. For all assets at once, `get_activity_overview`. derives from GET /api/v1/links/{id}/captures + GET /api/v1/svgs/{id}/captures · scopes: captures:summary · read-only · spends nothing input: - asset (object, required) — Which asset: a link or an SVG, by id. - kind ("link" | "svg", required) — `link` or `svg`. - id (string, required, minLength 1) — The asset's `id` — from `get_activity_overview` or `list_assets`. - since (string (date-time), optional) — Only visitors seen at or after this instant (summary: by last visit; raw: by capture time). - cursor (string, optional, minLength 1) — `next_cursor` from the previous page. - limit (integer, optional, default 50, min 1, max 200) output: { view: "summary", items: CaptureSummary[], next_cursor } ### compare_device_profiles — Compare two visitor profiles Two visitor summaries (each `{ asset: { kind, id }, id }`, same or different assets) compared on their coarse signals — device, OS family, browser family, country. Read the verdict as “same likely browser/device profile”, never as a verified identity. derives from GET /api/v1/links/{id}/captures + GET /api/v1/svgs/{id}/captures (two summary reads of listLinkCaptures / listSvgCaptures, compared) · scopes: captures:summary · read-only · spends nothing input: - first (object, required) — One visitor summary to compare: which asset, which row. - asset (object, required) — Which asset: a link or an SVG, by id. - kind ("link" | "svg", required) — `link` or `svg`. - id (string, required, minLength 1) — The asset's `id` — from `get_activity_overview` or `list_assets`. - id (string, required, minLength 1) — A visitor's `id` from `list_real_capture_summaries` (its most recent capture). - second (object, required) — One visitor summary to compare: which asset, which row. - asset (object, required) — Which asset: a link or an SVG, by id. - kind ("link" | "svg", required) — `link` or `svg`. - id (string, required, minLength 1) — The asset's `id` — from `get_activity_overview` or `list_assets`. - id (string, required, minLength 1) — A visitor's `id` from `list_real_capture_summaries` (its most recent capture). output: DeviceProfileComparison ### prepare_link — Prepare a link (draft, no credit) Validates a link as `publish_draft` would — domain resolved, slug judged, cost against your balance — and returns a priced draft. Spends nothing, writes nothing. Then `publish_draft` with `kind: "link"`, the `draft` and `confirm: true`. derives from POST /api/v1/links · scopes: links:write · read-only · spends nothing input: - title (string, required, minLength 1, maxLength 200) - type ("redirect" | "landing", required) - redirectUrl (string (uri), optional) — Required when type is `redirect`. - domain (string, optional) — A platform domain (`ipforge.xyz`, `brokolli.xyz`, `tarology.xyz`) or one of the owner's verified custom domains; omitted → `ipforge.xyz`. - customSlug (string, optional, minLength 1, maxLength 100) — The path the link is served at on its domain. Omitted → a generated content-shaped slug. - urlExtension (string, optional) - autoContentSlug (boolean, optional) — Append a random suffix to `customSlug` so repeated creates never collide. - behaviorTrackingEnabled (boolean, optional) - trackingConfig (object, optional) - smartRouting (object | null, optional) - rules (object[], optional) - id (string, required) - name (string, required, minLength 1) - conditions (object (type: "device" | "country" | "browser" | "os" | "time")[], required, minItems 1) - destinationUrl (string (uri), required) - expiration (object, optional) - expiresAt (string (date-time), required) - fallbackUrl (string (uri), optional) - abTest (object[], optional) - id (string, required) - name (string, required, minLength 1) - url (string (uri), required) - weight (number, required, min 0, max 100) - landingPage (object, optional) - name (string, required, minLength 1) - templateId (string, optional) - customHtml (string, optional) - customCss (string, optional) - configJson (object, optional) output: LinkDraft ### prepare_svg — Prepare an SVG (draft, no credit) Validates a tracking SVG image as `publish_draft` would — template, optional click-through URL, tracking toggles — and prices it against your balance. Spends nothing, writes nothing. Then `publish_draft` with `kind: "svg"`, the `draft` and `confirm: true`. derives from POST /api/v1/svgs · scopes: links:write · read-only · spends nothing input: - title (string, required, minLength 1, maxLength 200) - template ("document" | "image" | "chart" | "blank" | "invoice", required) — What the image renders as: a document, an image placeholder, a chart, a blank canvas or an invoice. - redirectUrl (string (uri), optional) — Where a click on the rendered SVG goes. Omitted → the image is not clickable. - trackingConfig (object, optional) — Collector toggles by id (canvas, webgl, audio, fonts, navigator, screen, timezone, …). Omitted → the SVG defaults. Each enabled `Extensions`/`Advanced` group costs one more credit. output: SvgDraft ### publish_draft — Publish a draft (spends credits) The only tool that spends credits: creates the link or SVG a `prepare_*` draft describes. Pass its `kind` and `draft` unchanged plus `confirm: true` — literally true, the owner's explicit confirmation; anything else is rejected before any code runs. Returns the asset with its resolved public `url`; share it, never compose one. derives from POST /api/v1/links + POST /api/v1/svgs · scopes: links:write · write · SPENDS credits input: - kind ("link" | "svg", required) — `link` or `svg`. - draft (object (one of 2), required) — The `draft` a `prepare_link` / `prepare_svg` result carried, unchanged. one of: (1) - title (string, required, minLength 1, maxLength 200) - type ("redirect" | "landing", required) - redirectUrl (string (uri), optional) — Required when type is `redirect`. - domain (string, optional) — A platform domain (`ipforge.xyz`, `brokolli.xyz`, `tarology.xyz`) or one of the owner's verified custom domains; omitted → `ipforge.xyz`. - customSlug (string, optional, minLength 1, maxLength 100) — The path the link is served at on its domain. Omitted → a generated content-shaped slug. - urlExtension (string, optional) - autoContentSlug (boolean, optional) — Append a random suffix to `customSlug` so repeated creates never collide. - behaviorTrackingEnabled (boolean, optional) - trackingConfig (object, optional) - smartRouting (object | null, optional) - rules (object[], optional) (nested object — see https://ipforge.xyz/openapi.json) - expiration (object, optional) (nested object — see https://ipforge.xyz/openapi.json) - abTest (object[], optional) (nested object — see https://ipforge.xyz/openapi.json) - landingPage (object, optional) - name (string, required, minLength 1) - templateId (string, optional) - customHtml (string, optional) - customCss (string, optional) - configJson (object, optional) (2) - title (string, required, minLength 1, maxLength 200) - template ("document" | "image" | "chart" | "blank" | "invoice", required) — What the image renders as: a document, an image placeholder, a chart, a blank canvas or an invoice. - redirectUrl (string (uri), optional) — Where a click on the rendered SVG goes. Omitted → the image is not clickable. - trackingConfig (object, optional) — Collector toggles by id (canvas, webgl, audio, fonts, navigator, screen, timezone, …). Omitted → the SVG defaults. Each enabled `Extensions`/`Advanced` group costs one more credit. - confirm (literal true, required) — Must be literally `true`. This is the owner's explicit confirmation that a credit will be spent. output: AssetResponse ### add_domain — Add a custom domain Registers a hostname you control and returns the exact A and TXT records to set. The domain is `pending` until `check_domain` sees both; `tls` says how long a pending domain may still obtain a certificate. derives from POST /api/v1/domains · scopes: domains:write · write · spends nothing input: - domain (string, required, minLength 1, maxLength 253) — A hostname you control, lowercase, e.g. `go.example.com`. output: DomainResponse ### check_domain — Check a domain's DNS Resolves the A and `_ipforge-verify` TXT records now and updates the domain to `verified` or `failed`. Re-check any time. derives from POST /api/v1/domains/{id}/check · scopes: domains:write · write · spends nothing input: - id (string, required, minLength 1) — The custom domain's `id` (from `add_domain`). output: DomainCheck ### get_credits — Your credit balance Credits available to spend. Creating a link or an SVG costs at least 1. derives from GET /api/v1/credits · scopes: credits:read · read-only · spends nothing input: none output: Credits ### create_order — Create a USDC order for credits A priced bundle or a custom amount, on one chain. Returns the order plus what a wallet needs: send exactly `pay.amountUsdc` USDC to `pay.to` on `pay.chain` within the hour, then `verify_order` with the tx hash. Charges nothing itself. derives from POST /api/v1/orders · scopes: credits:write · write · spends nothing input: - order (object (one of 2), required) — A priced bundle (`starter` 13 / `pro` 50 / `bulk` 200 credits) or `custom` with `customCredits`. one of: (1) - bundleId ("starter" | "pro" | "bulk", required) - chain ("solana" | "polygon" | "bsc", required) - customCredits (must be absent, optional) (2) - bundleId (literal "custom", required) - chain ("solana" | "polygon" | "bsc", required) - customCredits (integer, required, min 1, max 1000) — 1 USDC = 1 credit, 1–1000. output: OrderResponse ### verify_order — Verify an order's payment Checks the transaction on-chain against the order's amount and wallet and credits your account. A failed verification can be retried with the correct hash. Limited to 10 attempts per 5 minutes. derives from POST /api/v1/orders/{id}/verify · scopes: credits:write · write · spends nothing input: - id (string, required, minLength 1) — The order's `id` (from `create_order`). - txHash (string, required, minLength 10, maxLength 128) — The USDC transfer's transaction hash / signature on the order's chain. output: OrderVerified MCP-only output schemas (compositions over the v1 components above — `Asset` is `Link` | `Svg` tagged by `kind`): ### Asset — One tracking asset — a link or an SVG — with its resolved public `url` and its `stats`. AssetLink | AssetSvg ### AssetPage — Links and SVGs together, newest first; one cursor pages across both kinds. - items (Asset[], required) - next_cursor (string | null, required) ### AssetResponse — One asset — what `get_asset` and `publish_draft` return. - asset (Asset, required) ### LinkDraft — A reviewable, priced link draft — pass `kind` and `draft` to `publish_draft` unchanged with `confirm: true`. Nothing was written and nothing was spent. - kind (literal "link", required) — Pass this `kind` to `publish_draft`. - draft (object, required) - title (string, required, minLength 1, maxLength 200) - type ("redirect" | "landing", required) - redirectUrl (string (uri), optional) — Required when type is `redirect`. - domain (string, optional) — A platform domain (`ipforge.xyz`, `brokolli.xyz`, `tarology.xyz`) or one of the owner's verified custom domains; omitted → `ipforge.xyz`. - customSlug (string, optional, minLength 1, maxLength 100) — The path the link is served at on its domain. Omitted → a generated content-shaped slug. - urlExtension (string, optional) - autoContentSlug (boolean, optional) — Append a random suffix to `customSlug` so repeated creates never collide. - behaviorTrackingEnabled (boolean, optional) - trackingConfig (object, optional) - smartRouting (object | null, optional) - rules (object[], optional) (nested object — see https://ipforge.xyz/openapi.json) - expiration (object, optional) (nested object — see https://ipforge.xyz/openapi.json) - abTest (object[], optional) (nested object — see https://ipforge.xyz/openapi.json) - landingPage (object, optional) - name (string, required, minLength 1) - templateId (string, optional) - customHtml (string, optional) - customCss (string, optional) - configJson (object, optional) - domain (string, required) — The domain the link will be created on — resolved, so `publish_draft` cannot land elsewhere. - slug (string | null, required) — The exact path the link will be served at, or null when a random suffix is generated at publish time. - url (string | null, required) — The public URL `publish_draft` will return, when it can be known now. - cost (object, required) — What `publish_draft` will deduct. - credits (integer, required, min 1) - credits (object, required) - available (integer, required) - sufficient (boolean, required) — false → `publish_draft` would fail with `insufficient_credits`; buy credits first (`create_order`). - spends (literal false, required) — A draft never spends. Only `publish_draft` does. ### SvgDraft — A reviewable, priced SVG draft — pass `kind` and `draft` to `publish_draft` unchanged with `confirm: true`. Nothing was written and nothing was spent. - kind (literal "svg", required) — Pass this `kind` to `publish_draft`. - draft (object, required) - title (string, required, minLength 1, maxLength 200) - template ("document" | "image" | "chart" | "blank" | "invoice", required) — What the image renders as: a document, an image placeholder, a chart, a blank canvas or an invoice. - redirectUrl (string (uri), optional) — Where a click on the rendered SVG goes. Omitted → the image is not clickable. - trackingConfig (object, optional) — Collector toggles by id (canvas, webgl, audio, fonts, navigator, screen, timezone, …). Omitted → the SVG defaults. Each enabled `Extensions`/`Advanced` group costs one more credit. - cost (object, required) — What `publish_draft` will deduct. - credits (integer, required, min 1) - credits (object, required) - available (integer, required) - sufficient (boolean, required) — false → `publish_draft` would fail with `insufficient_credits`; buy credits first (`create_order`). - spends (literal false, required) — A draft never spends. Only `publish_draft` does. ### DeviceProfileComparison — Agreement or change among the coarse signals of two visitor summaries. Never an identity. - verdict ("same_likely_profile" | "partial_agreement" | "changed" | "insufficient_signals", required) — `same_likely_profile` = every comparable coarse signal agrees; `changed` = none does; `partial_agreement` = some do; `insufficient_signals` = neither row carries a comparable signal. - reading (literal "Device-confidence language describes agreement or change among coarse browser/device signals. It must be read as “same likely browser/device profile,” never as a verified identity.", required) — How to read `verdict`. Quoted from the published measurement rules. - agreeing ("device" | "os" | "browser" | "country"[], required) - differing ("device" | "os" | "browser" | "country"[], required) - signals (object, required) - device (object, required) - first (string | null, required) - second (string | null, required) - same (boolean, required) - compared (boolean, required) - os (object, required) - first (string | null, required) - second (string | null, required) - same (boolean, required) - compared (boolean, required) - browser (object, required) - first (string | null, required) - second (string | null, required) - same (boolean, required) - compared (boolean, required) - country (object, required) - first (string | null, required) - second (string | null, required) - same (boolean, required) - compared (boolean, required) - first (CaptureSummary, required) - second (CaptureSummary, required) ## Hosted connectors — the OAuth 2.1 sign-in (claude.ai, ChatGPT) A hosted connector cannot be handed a bearer token; it mounts the MCP server through the MCP authorization flow. IPForge is its own authorization server, on the same origin. The owner signs in to the dashboard and consents once; the grant that results IS an API token (an `ipf_…` access token that expires and is refreshed) — listed and revocable in Settings → API tokens like any other, holding only the scopes the connector asked for and the owner ticked (`captures:raw` is unticked by default and never granted unless asked for and ticked). Discovery (RFC 9728, also served at /.well-known/oauth-protected-resource/mcp) — GET https://ipforge.xyz/.well-known/oauth-protected-resource: {"resource":"https://ipforge.xyz/mcp","authorization_servers":["https://ipforge.xyz"],"scopes_supported":["links:read","links:write","captures:summary","domains:read","domains:write","credits:read","credits:write"],"bearer_methods_supported":["header"],"resource_name":"IPForge MCP"} Authorization server metadata (RFC 8414) — GET https://ipforge.xyz/.well-known/oauth-authorization-server: {"issuer":"https://ipforge.xyz","authorization_endpoint":"https://ipforge.xyz/oauth/authorize","token_endpoint":"https://ipforge.xyz/oauth/token","registration_endpoint":"https://ipforge.xyz/oauth/register","revocation_endpoint":"https://ipforge.xyz/oauth/revoke","scopes_supported":["links:read","links:write","captures:summary","captures:raw","domains:read","domains:write","credits:read","credits:write"],"response_types_supported":["code"],"response_modes_supported":["query"],"grant_types_supported":["authorization_code","refresh_token"],"token_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post"],"revocation_endpoint_auth_methods_supported":["none","client_secret_basic","client_secret_post"],"code_challenge_methods_supported":["S256"],"authorization_response_iss_parameter_supported":true} The flow, as the connector runs it: 1. POST /mcp without a token → 401 with `WWW-Authenticate: Bearer realm="ipforge", resource_metadata="https://ipforge.xyz/.well-known/oauth-protected-resource", scope="links:read links:write captures:summary domains:read domains:write credits:read credits:write"`. 2. POST /oauth/register — dynamic client registration (RFC 7591), JSON; redirect URIs must be https or loopback; a public client (`token_endpoint_auth_method: "none"`) needs no secret. An unused registration expires after 1 day. 3. GET /oauth/authorize?response_type=code&client_id&redirect_uri&code_challenge&code_challenge_method=S256&resource=https://ipforge.xyz/mcp&scope&state — PKCE S256 only; `resource` must be the canonical MCP URL; the owner signs in and approves or denies on /oauth/consent; the code (valid 5 minutes, single use) returns to the redirect URI with `state` and `iss`. 4. POST /oauth/token (application/x-www-form-urlencoded) — grant_type=authorization_code with code, redirect_uri, code_verifier, resource → { access_token "ipf_…", token_type "Bearer", expires_in 3600, refresh_token "ipfr_…", scope }. Access tokens live 1 hour; grant_type=refresh_token rotates the refresh token (valid 30 days from the last rotation; reusing a rotated one revokes the grant); a scope may not widen on refresh. 5. POST /oauth/revoke (RFC 7009) with the access or refresh token — or the owner deletes the grant in Settings. Either way /mcp answers 401 `token_revoked` from then on. Errors on these endpoints use the OAuth shape `{ "error", "error_description" }` (RFC 6749 §5.2), not the v1 envelope. The `/api/v1` routes are not advertised as an OAuth resource (their 401 stays `Bearer realm="ipforge"`), but a token obtained through the sign-in is an API token and works there too, within its scopes and lifetime. ## Measurement rules and acceptable use — from /llms.txt, verbatim The words below are the published policy; this file quotes them and adds nothing. ## Supported, authorized uses - Measure an organization’s own links and persistent landing pages. - Run transparent, controlled delivery or compatibility tests with owned or authorized recipients, inboxes, chats, and destinations. - Review deduplicated activity and coarse device/session evidence in the owning workspace. ## Measurement rules - `human` means the capture contained browser signals and no currently known bot, previewer, headless, or hosting-network signal. It is a classifier result, not proof of a person, identity, location, intent, or consent. - `unknown` means the available signals were insufficient for that classifier; it is not counted as human activity. - A duplicate is a repeat capture for the same resource and fingerprint hash during a 24-hour window. Deduplication reduces repeat counts; it cannot prove unique people because browsers, devices, networks, and fingerprints change. - Device-confidence language describes agreement or change among coarse browser/device signals. It must be read as “same likely browser/device profile,” never as a verified identity. - Active dwell is opt-in, visible-tab time accumulated on a persistent IPForge landing page. It does not measure time, reading, or activity after a redirect to another site. ## Privacy and acceptable use Use IPForge only in a workspace you own or are authorized to operate, with a clear purpose and appropriate notice or consent. Do not use it to secretly identify, profile, locate, or monitor people; to disguise tracking as content; to collect credentials, keystrokes, form values, page contents, or precise pointer paths; or to expose customer links, capture records, IP addresses, fingerprints, tokens, or secrets to an agent or public endpoint. ## Legal and data protection The binding documents, each at a stable URL: - https://ipforge.xyz/terms — Terms of Service. - https://ipforge.xyz/privacy — Privacy Policy. Part A is the account holder; Part B itemizes every signal a tracked link asks a visitor's browser for, including the browser-environment and local-network probes, and says which groups an owner can switch off per link. - https://ipforge.xyz/acceptable-use — Acceptable Use Policy; the prohibited uses below in enumerated form, with the enforcement and reporting route. - https://ipforge.xyz/cookies — every cookie the site sets. A tracked link sets none, and writes nothing to a visitor's browser storage. - https://ipforge.xyz/dpa — Article 28 processing terms and the complete sub-processor list. The workspace owner is the controller of visitor data; IPForge is the processor. An agent answering a question about what IPForge collects, keeps or discloses should read /privacy and /dpa rather than inferring it from this index. ## Not offered - Account creation or token issuance by API — a human does both in the browser, once. - Card or fiat payment — USDC only. - Raw visitor records over MCP — `captures:raw` exists only on `?view=raw` of GET /api/v1/links/{id}/captures and GET /api/v1/svgs/{id}/captures. - Webhooks — poll GET /api/v1/links/{id}/captures (or /api/v1/svgs/{id}/captures) with `since` and `cursor`. - Registrar or DNS automation — add_domain returns the records; you set them. Links: https://ipforge.xyz/llms.txt · https://ipforge.xyz/openapi.json · https://ipforge.xyz/mcp · https://ipforge.xyz/docs/integration