Snipt REST API — v1

Base URL: https://snipt.io/api/v1

The Snipt API is a JSON over HTTPS REST surface for managing links, routing rules, split tests and domains programmatically, and for reading your click analytics, AI insights and Impact Receipts. It's designed for CI deployments, custom dashboards, and bulk integration use cases that don't fit the web UI.

Everything the MCP server exposes to an AI agent is available here too, on the same tokens and the same scopes — the two surfaces call the same code.

This document is the authoritative contract. Anything not listed here is not part of v1 — endpoints under /api/auth/*, /api/cron/*, or elsewhere are private to the application and may change without notice.


Table of contents


Authentication

Every request must carry a bearer token in the Authorization header:

Authorization: Bearer snipt_pk_8c2f...

Tokens are minted at Settings → API tokens in the dashboard. They are shown to you exactly once — Snipt stores only a SHA-256 hash. If you lose a token, revoke it and mint a new one.

Each token carries a comma-separated scope list. Endpoints declare the scope they need; a request with an insufficient scope returns 403 INSUFFICIENT_SCOPE. Available scopes:

ScopeGrants
links:readList + read links
links:writeCreate / update / archive / delete
rules:readList routing rules; dry-run them (MCP simulate_rule)
rules:writeCreate / delete routing rules; apply an insight (MCP apply_insight)
domains:readList custom domains
analytics:readRead click analytics, insights, and Impact Receipts
splits:readRead a link's split test + conversion report
splits:writeStart a split test

Follow the principle of least privilege — give each token only the scopes it needs. A read-only token is harmless if leaked; a links:write token can deface or delete every link in your account, and rules:write / splits:write can redirect its live traffic.

splits:read / splits:write are new. Tokens minted before they existed simply don't carry them — mint a new token (or re-mint) to use the split-test endpoints. Every other token keeps working unchanged.

Plan & limits

API access is included on Growth and Premium plans. Free and Core users will receive 403 PLAN_REQUIRED on every endpoint.

PlanTokens maxRequests/min
Free00
Core00
Growth10100
Premium50600

Two endpoints carry a second, feature-level plan gate on top of the apiAccess one above, enforced at request time in the service layer:

  • Split tests require the splitTesting plan feature. It is on for Growth and Premium — i.e. for every plan that has API access at all — so in practice an authenticated caller always passes it. It is still re-checked per request, so a lapsed subscription returns 403 PLAN_REQUIRED even with a live token.
  • Analytics windows are clamped to your plan's click-retention window (Growth 180 days, Premium 365). Asking for more isn't an error — the response tells you it was clamped (clampedToRetention: true).

Rate limits

Each token has its own rate budget. When you exceed it, the response is:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{ "error": "Rate limit exceeded (100 req/min). Try again in 60s.", "code": "RATE_LIMITED" }

Rate limits use a fixed 60-second window keyed on the token id, so bursts that straddle a window boundary may briefly exceed the cap by one window's worth of requests.

Errors

Every error response is JSON in this shape:

{ "error": "human-readable message", "code": "MACHINE_CODE" }

Error codes you can program against:

CodeHTTPMeaning
UNAUTHENTICATED401No Authorization header
INVALID_TOKEN401Token malformed, revoked, or expired
PLAN_REQUIRED403Caller's plan doesn't include API access, or the feature they're using
INSUFFICIENT_SCOPE403Token doesn't carry the required scope
NOT_FOUND404Resource doesn't exist (or isn't yours)
INVALID_JSON400Request body wasn't valid JSON
VALIDATION_ERROR400Body didn't match the expected schema
INVALID_SLUG400Custom slug failed regex / reserved-word check
INVALID_RULE400Routing rule failed validation
INVALID_SCHEDULE400activatesAt is not before expiresAt
URL_FLAGGED422Destination or fallback URL flagged by Google Safe Browsing
SLUG_TAKEN409Custom slug already exists on that domain
DOMAIN_NOT_FOUND404domainId does not belong to caller
DOMAIN_NOT_VERIFIED409Domain exists but isn't DNS-verified yet
SPLIT_TEST_EXISTS409The link already has an active split test
INVALID_STATE409The split test isn't in a state that allows this
ALREADY_APPLIED409The insight's action was already applied (MCP apply_insight)
PLAN_LIMIT_EXCEEDED429Caller hit a plan-level cap (e.g. links/month)
RATE_LIMITED429Caller hit the per-token request rate limit
INTERNAL500Server error — retry with backoff

The API never returns stack traces. Body details are intentionally short to avoid leaking server internals.


Endpoints

GET /me

Returns the authenticated user's plan summary. Useful for clients to verify the token is wired correctly.

curl https://snipt.io/api/v1/me \
  -H "Authorization: Bearer $SNIPT_TOKEN"
{
  "userId": "usr_abc123",
  "tokenId": "tok_def456",
  "scopes": ["links:read", "links:write"],
  "plan": "growth",
  "limits": {
    "apiRequestsPerMinute": 60,
    "linksPerMonth": 500,
    "routingRulesPerLink": 10
  }
}

List your links, newest first.

Query paramDefaultNotes
limit501–200
includeArchivedfalse"true" to include archived

Required scope: links:read.

curl "https://snipt.io/api/v1/links?limit=10" \
  -H "Authorization: Bearer $SNIPT_TOKEN"
{
  "data": [
    {
      "id": "lnk_abc",
      "slug": "promo",
      "destinationUrl": "https://example.com/promo",
      "title": "Spring promo",
      "iosUrl": null,
      "androidUrl": null,
      "domainId": null,
      "requireSignature": false,
      "trustScore": 90,
      "trustReason": "+30 HTTPS, +10 reputable TLD",
      "activatesAt": null,
      "expiresAt": null,
      "fallbackUrl": null,
      "archivedAt": null,
      "createdAt": "2026-05-09T15:24:00.000Z",
      "updatedAt": "2026-05-09T15:24:00.000Z"
    }
  ],
  "pagination": { "limit": 10, "count": 1 }
}

POST /links

Create a link. Required scope: links:write.

FieldRequiredNotes
destinationUrlyesMust be a valid URL. Screened against Google Safe Browsing.
titleno≤ 120 chars
customSlugno3–40 chars, [a-zA-Z0-9_-]. Reserved slugs rejected.
domainIdnoOne of your verified domain IDs
iosUrlnoPremium feature — UA-overridden destination on iOS
androidUrlnoPremium feature — UA-overridden destination on Android
activatesAtnoISO-8601. Core+ — before this instant the link 404s.
expiresAtnoISO-8601. Core+ — after this instant the link 404s, or serves fallbackUrl.
fallbackUrlnoCore+ — where expired traffic goes. Screened by Safe Browsing like destinationUrl.

Unknown fields are rejected with 400 VALIDATION_ERROR.

Scheduling

activatesAt, expiresAt and fallbackUrl require a Core plan or above — a Free token gets 403 PLAN_REQUIRED. Clearing them (sending null) is always allowed, so a lapsed plan can never trap a link in a schedule.

They are absolute instants, compared against the server clock on every redirect:

  • Before activatesAt: 302 to /not-found. A campaign that hasn't started has no destination, so there's no fallback for this case.
  • After expiresAt with a fallbackUrl: 308 to the fallback, and the click is still recorded — traffic arriving at a dead campaign is something the owner needs to see.
  • After expiresAt with no fallbackUrl: 302 to /not-found.

activatesAt must be strictly before expiresAt, checked against the merged state of the row — a PATCH that sets only one of them is still validated against the other. Violations return 400 INVALID_SCHEDULE.

curl https://snipt.io/api/v1/links \
  -X POST \
  -H "Authorization: Bearer $SNIPT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "destinationUrl": "https://example.com/promo",
    "title": "Spring promo",
    "customSlug": "promo"
  }'

Returns 201 with the created link, same shape as GET /links/:id.

GET /links/:id

Fetch a single link. Returns 404 NOT_FOUND if the link doesn't exist or if it isn't yours — these cases are deliberately indistinguishable to prevent enumeration.

Required scope: links:read.

PATCH /links/:id

Update a link. All fields are optional; pass only what you want to change. Required scope: links:write.

FieldTypeNotes
destinationUrlstringRe-screened by Safe Browsing on change
titlestring | nullnull clears
iosUrlstring | nullURL or null to clear
androidUrlstring | nullURL or null to clear
requireSignaturebooleanToggle HMAC-signed redirects
archivedbooleantrue archives, false unarchives
activatesAtstring | nullISO-8601. Core+. null clears
expiresAtstring | nullISO-8601. Core+. null clears
fallbackUrlstring | nullCore+. null clears. Re-screened on change

Unknown fields are rejected with 400 VALIDATION_ERROR. See Scheduling for the expiry semantics.

DELETE /links/:id

Hard-delete a link. Cascades to clicks and rules. Required scope: links:write.

GET /links/:id/rules

List routing rules for a link, in priority order. Required scope: rules:read.

POST /links/:id/rules

Create a routing rule. Required scope: rules:write.

{
  "field": "country",
  "operator": "equals",
  "value": "US",
  "redirectUrl": "https://example.com/us"
}

Supported field values: country, device, browser, referer_host, hour_utc, language, returning. Supported operator values: equals, in, not_in, between, matches.

DELETE /rules/:id

Delete a routing rule. Ownership is enforced via the parent link. Required scope: rules:write.

GET /links/:id/analytics

Click totals, unique visitors, and a per-day series for one link. Required scope: analytics:read.

Query paramDefaultNotes
days301–365, then clamped to your plan's retention

If days exceeds your plan's click-retention window the request still succeeds against the shorter window and clampedToRetention is truewindowDays always tells you what was actually measured.

clicksByDay is zero-filled across the whole window, oldest first.

curl "https://snipt.io/api/v1/links/lnk_abc/analytics?days=30" \
  -H "Authorization: Bearer $SNIPT_TOKEN"
{
  "linkId": "lnk_abc",
  "slug": "promo",
  "windowDays": 30,
  "clampedToRetention": false,
  "totalClicks": 412,
  "uniqueVisitors": 301,
  "clicksByDay": [{ "date": "2026-07-01", "clicks": 12, "uniqueVisitors": 9 }]
}

GET /insights

Your active (non-dismissed) AI insights — anomalies, opportunities, recommendations, and Impact Receipt outcomes — newest first. Required scope: analytics:read.

Query paramDefaultNotes
limit101–50
{
  "data": [
    {
      "id": "ins_abc",
      "type": "opportunity",
      "priority": "high",
      "summary": "38% of this link's clicks come from Germany.",
      "recommendation": "Route DE visitors to your German landing page.",
      "linkSlug": "spring-sale",
      "createdAt": "2026-07-12T03:31:07.000Z"
    }
  ]
}

This is the same payload shape as the MCP list_insights tool and the insight.created webhook data object.

GET /links/:id/split-test

The link's split test (Phase 13.2 A/B testing): the active one if there is one, else the most recent completed one. A link that has never run a test returns { "test": null } — that's an answer, not an error. Required scope: splits:read.

{
  "test": {
    "id": "spl_abc",
    "linkId": "lnk_abc",
    "status": "running",
    "winnerVariantId": null,
    "variants": [
      {
        "id": "var_1",
        "url": "https://example.com/a",
        "weight": 50,
        "assignments": 210,
        "conversions": 18
      },
      {
        "id": "var_2",
        "url": "https://example.com/b",
        "weight": 50,
        "assignments": 205,
        "conversions": 31
      }
    ]
  }
}

status is running, paused, or completed. Weights are integers summing to 100 and are moved by the nightly optimizer once every variant has enough data; a completed test keeps routing 100% to its winner.

POST /links/:id/split-test

Start a split test on a link. Live traffic starts splitting immediately. Required scope: splits:write.

{ "urls": ["https://example.com/a", "https://example.com/b"] }
FieldRequiredNotes
urlsyes2–10 distinct destinations, capped by your plan's variant max (Growth 3, Premium 4). Normalized like any Snipt destination.

Weights start equal. Returns 201 with the same body as GET /links/:id/split-test.

Errors: 403 PLAN_REQUIRED (plan has no split testing), 400 VALIDATION_ERROR (fewer than 2 URLs, a duplicate URL, an unusable URL, or more variants than the plan allows), 409 SPLIT_TEST_EXISTS (the link already has a running or paused test — complete it first).

Pausing, resuming and picking a winner are dashboard-only in v1.

GET /links/:id/receipts

The link's Impact Receipt ledger (Phase 15.2): every change Snipt made on your behalf — an insight you applied, an optimizer weight shift, a self-completed test — with the frozen "before" window, the measured "after" window, and a verdict. Required scope: analytics:read.

{
  "data": [
    {
      "id": "act_abc",
      "linkId": "lnk_abc",
      "source": "insight_apply",
      "description": "Rule country = US → https://example.com/us",
      "appliedAt": "2026-06-03T09:12:00.000Z",
      "verdict": "positive",
      "milestone": "7d",
      "method": "observational",
      "primaryMetric": "conversion_rate",
      "deltaPct": 22.4,
      "insufficientReason": null,
      "baseline": {
        "windowDays": 14,
        "startDate": "2026-05-20",
        "endDate": "2026-06-02",
        "clicks": 640,
        "conversions": 48,
        "conversionRate": 0.075
      },
      "after": {
        "startDate": "2026-06-04",
        "endDate": "2026-06-10",
        "clicks": 331,
        "conversions": 30,
        "conversionRate": 0.0906
      }
    }
  ]
}

Read the honesty rules into your integration, because we enforce them:

  • verdict is null until a full week of "after" data exists, and outcome-derived fields (milestone, method, deltaPct, after) are null with it.
  • verdict: "insufficient_data" means the sample floors weren't met. Every delta is null and insufficientReason carries a progress counter ("14 of 30 clicks since Jun 3"). Do not compute your own percentage from baseline and after in this state — the floors exist because that number would be noise.
  • method is "randomized" only for split-test receipts. Everything else is an observational before/after on a live link: say since the change, never because of it.

GET /domains

List your custom domains. Read-only in v1 — domain provisioning requires DNS interaction outside the API surface and is done via the dashboard.

Required scope: domains:read.


Conversion beacon

GET https://snipt.io/api/beacon

The one public, unauthenticated endpoint in Snipt. You paste it on the page a visitor reaches when they convert (thank-you page, checkout success); each load records one conversion against the link that sent them there. Conversions and revenue roll up nightly into your analytics and feed the split-test optimizer.

Requires the Core plan or above. Below it, hits are accepted and discarded at rollup — the endpoint never tells you which, because it never tells anyone anything (see Response).

Query parameters

ParamRequiredDescription
lyesThe link id (not the slug). Max 64 chars.
vnoOrder value in the account's conversion currency, as plain digits: 49, 49.9, 49.99. Max 7 integer digits and 2 decimals; no signs, exponents, separators or symbols. Anything else is ignored and the conversion is still recorded with no value — a mistyped total must not cost you the conversion.
scnoThe signed click token, echoed back from the landing page's URL. Only present when the link has Exact attribution enabled. Absent, expired (2h), tampered-with, or minted for another link → silently ignored, and the conversion falls back to IP + user-agent matching.

Response

Always 200 with a 1×1 transparent GIF (image/gif, Cache-Control: no-store) — for a valid hit, a bad link id, a malformed value, or a rate-limited request alike. A beacon must never visibly break the page it's embedded in, and a non-200 would tell a stranger whether a given link id exists.

Rate limit

60 requests / minute per (IP, link). Excess hits get the pixel and are dropped.

Snippets

Goal only, no value, IP+UA attribution:

<img src="https://snipt.io/api/beacon?l=LINK_ID" alt="" width="1" height="1" style="display:none">

Value + exact attribution (an <img> tag cannot read its own page URL, so it can never echo sc or send v — that needs JavaScript):

<script>
(function(){var p=new URLSearchParams(location.search),u="https://snipt.io/api/beacon?l=LINK_ID";
var sc=p.get("sc"); if(sc)u+="&sc="+encodeURIComponent(sc);
/* var total = "49.00"; u+="&v="+encodeURIComponent(total); */
new Image().src=u;})();
</script>

What this data is, and isn't

The beacon fires from your page and is unauthenticated: anyone who can view that page can read the URL and call it. Conversion counts and revenue are a signal — good enough to steer routing and report a trend, not a ledger to reconcile against your payment provider. We would rather say that here than have you discover it later.


MCP server

Snipt ships a Model Context Protocol server so AI agents (Claude, Cursor, Windsurf, and any MCP-capable client) can drive your account directly. It is a thin adapter over this same REST API — the same bearer tokens, the same scopes, and the same per-token rate limits. No separate credential to manage.

Endpoint (streamable HTTP transport):

https://snipt.io/api/mcp/mcp

Client config. Add Snipt to your MCP client (the exact file differs per client — e.g. claude_desktop_config.json for Claude Desktop):

{
  "mcpServers": {
    "snipt": {
      "url": "https://snipt.io/api/mcp/mcp",
      "headers": {
        "Authorization": "Bearer snipt_pk_8c2f..."
      }
    }
  }
}

Replace the token with one minted at Settings → API tokens carrying the scopes your agent needs. A missing or invalid token returns a JSON-RPC authentication error; a call needing a scope your token lacks returns an INSUFFICIENT_SCOPE error; exceeding your plan's per-minute limit returns RATE_LIMITED — exactly mirroring the REST 401 / 403 / 429 responses.

Tools

Snipt's MCP server doesn't just let an agent read your account — it lets one operate it. Four of the eleven tools mutate the account and change where live visitors are redirected: create_link, create_routing_rule, apply_insight, and create_split_test. The other seven are strictly read-only.

That distinction is enforced by scopes, not by trust. A read-only agent gets a token with links:read, rules:read, analytics:read, splits:read — it can list, analyse, dry-run a rule and read receipts, and cannot change one byte of your account. An autonomous agent additionally needs links:write (create links), rules:write (create rules and apply insights), and splits:write (start split tests). Mint the narrowest token that does the job.

ToolDoesMutates?Required scope
create_linkCreate a short link (same normalization, Safe Browsing, reachability, and monthly-link limit as POST /links)yeslinks:write
list_linksList your links, newest firstnolinks:read
get_link_analyticsClick totals, unique visitors, and a per-day series for a link (clamped to your plan's retention window)noanalytics:read
list_insightsList active AI insights (anomalies, opportunities, recommendations, receipt outcomes)noanalytics:read
apply_insightExecute an insight's recommended action — for a create_rule insight this creates a live routing rule. Idempotent (a second call returns ALREADY_APPLIED); records an Impact Receipt. Not undoable via MCP.yesrules:write
create_routing_ruleAdd a smart-routing rule to a link (Core+; same validation + per-link cap as the REST rules endpoint)yesrules:write
list_routing_rulesList a link's routing rules in priority ordernorules:read
simulate_ruleDry-run the link's rules against a hypothetical visitor (country, device, browser, referrer, hour, language, returning) and see where they'd land. Writes nothing.norules:read
create_split_testStart an A/B/n split test — real traffic starts splitting immediately (Growth+; one active test per link)yessplits:write
get_conversion_reportPer-variant assignments, conversions, conversion rate, and a leadingVariant — only once a variant clears the minimum-sample floornosplits:read
list_receiptsA link's Impact Receipt ledger: what Snipt changed, and what the numbers did sincenoanalytics:read

Tool results use the same field names as the REST serializers documented above (they call the same code), so anything you learn from the REST contract applies to the tool outputs verbatim. Failures come back as the same { error, code } codes: NOT_FOUND, PLAN_REQUIRED, INSUFFICIENT_SCOPE, RATE_LIMITED, SPLIT_TEST_EXISTS, ALREADY_APPLIED.


Webhooks

Snipt can push engine output to your own HTTPS endpoint the moment it happens, so you don't have to poll. Configure webhooks at Settings → Webhooks (Growth and Premium plans). Each plan caps the number of endpoints (Growth: 2, Premium: 10).

Events

EventFires when
insight.createdThe nightly AI engine writes a new insight for you
alert.spikeA link's clicks spike versus its recent baseline
alert.link_brokenA link's destination fails two consecutive nightly health checks
autopilot.action_appliedAutopilot applied a change to a link you armed it on
autopilot.action_revertedAutopilot undid one of its own changes after a negative receipt

The two autopilot.* events are separate from insight.created on purpose: an autonomous mutation of your live routing is not the same fact as a piece of advice, and you must be able to subscribe to mutations only — to page an on-call, or to mirror them into your own audit log — without parsing a payload to find out whether your infrastructure changed.

Payload

Every delivery is a POST with this JSON body:

{
  "id": "f1e2d3c4-...",
  "event": "insight.created",
  "createdAt": "2026-06-12T03:31:07.000Z",
  "data": {
    "id": "...",
    "type": "opportunity",
    "priority": "high",
    "summary": "...",
    "recommendation": "...",
    "linkSlug": "spring-sale",
    "createdAt": "2026-06-12T03:31:07.000Z"
  }
}

The data shape depends on the event (alert.spike carries linkSlug, clicksInWindow, windowLabel, topReferrer; alert.link_broken carries linkSlug, destinationUrl, statusCode, reason; both autopilot.* events carry linkSlug, description, appliedActionId, reason, at).

Headers

HeaderValue
Content-Typeapplication/json
X-Snipt-Eventthe event name, e.g. insight.created
X-Snipt-Signaturesha256=<hex> — HMAC-SHA256 of the raw request body keyed with your webhook's signing secret

Verifying the signature

The signing secret (whsec_…) is shown once when you create the webhook. Recompute the HMAC over the raw body and compare in constant time:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifySniptSignature(rawBody, header, secret) {
  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Always verify against the raw bytes you received, before any JSON parsing — re-serializing the parsed object can change whitespace and break the signature.

Delivery, retries, and auto-disable

Each delivery has a 5-second timeout and is retried once after 30 seconds on failure. A 2xx response resets the failure counter; after 10 consecutive failures the webhook is automatically disabled and an insight is created so you know. Use the Send test event button in Settings to fire a { "event": "test" } payload and see the HTTP status your endpoint returns.

Endpoint URLs must be https and must resolve to a public host — private, loopback, and link-local addresses are rejected at creation and at every delivery (SSRF protection).


Security model

The Snipt API is built against the OWASP API Security Top 10 (2023):

  • API1 — BOLA: every endpoint scopes its DB query to the authenticated user's id from the token. We never accept a userId in the request body or path. A request for link.id = 'X' returns 404 if X belongs to another user.
  • API2 — Broken Auth: tokens are 256 bits of CSPRNG entropy stored as SHA-256 hashes. Verification is a single equality lookup against a unique index — no partial-match timing leak.
  • API3 — Property-level auth: zod schemas declare exactly which fields a caller can write. Unknown fields are rejected.
  • API4 — Resource exhaustion: per-token rate limits via Redis. Plan caps on monthly link creation, routing rules per link, etc.
  • API5 — Function-level auth: scope checks per endpoint.
  • API6 — Sensitive flows: API access is gated to paid plans.
  • API7 — SSRF: destination URLs are screened by Google Safe Browsing before persistence. Following the redirect itself is opt-in for the visitor — that's the product.
  • API8 — Misconfiguration: every response is private, no-store; errors never include stack traces; /api/v1/* is opt-in by route, not opt-out.
  • API9 — Inventory: this document is the inventory.
  • API10 — Unsafe consumption: not applicable — we don't make third-party API calls based on token-user input on this surface.

If you find a security issue, email security@snipt.io.

Versioning

This is v1. Adding fields to a response or endpoint is a non-breaking change. Removing or renaming fields is a v2.

Breaking changes will be announced via a deprecation header (Snipt-Deprecation: 2026-XX-XX) at least 90 days before the breaking version takes effect.