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
- Plan & limits
- Rate limits
- Errors
- Endpoints
- Conversion beacon
- MCP server
- Webhooks
- Security model
- Versioning
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:
| Scope | Grants |
|---|---|
links:read | List + read links |
links:write | Create / update / archive / delete |
rules:read | List routing rules; dry-run them (MCP simulate_rule) |
rules:write | Create / delete routing rules; apply an insight (MCP apply_insight) |
domains:read | List custom domains |
analytics:read | Read click analytics, insights, and Impact Receipts |
splits:read | Read a link's split test + conversion report |
splits:write | Start 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.
| Plan | Tokens max | Requests/min |
|---|---|---|
| Free | 0 | 0 |
| Core | 0 | 0 |
| Growth | 10 | 100 |
| Premium | 50 | 600 |
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
splitTestingplan 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 returns403 PLAN_REQUIREDeven 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:
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | No Authorization header |
INVALID_TOKEN | 401 | Token malformed, revoked, or expired |
PLAN_REQUIRED | 403 | Caller's plan doesn't include API access, or the feature they're using |
INSUFFICIENT_SCOPE | 403 | Token doesn't carry the required scope |
NOT_FOUND | 404 | Resource doesn't exist (or isn't yours) |
INVALID_JSON | 400 | Request body wasn't valid JSON |
VALIDATION_ERROR | 400 | Body didn't match the expected schema |
INVALID_SLUG | 400 | Custom slug failed regex / reserved-word check |
INVALID_RULE | 400 | Routing rule failed validation |
INVALID_SCHEDULE | 400 | activatesAt is not before expiresAt |
URL_FLAGGED | 422 | Destination or fallback URL flagged by Google Safe Browsing |
SLUG_TAKEN | 409 | Custom slug already exists on that domain |
DOMAIN_NOT_FOUND | 404 | domainId does not belong to caller |
DOMAIN_NOT_VERIFIED | 409 | Domain exists but isn't DNS-verified yet |
SPLIT_TEST_EXISTS | 409 | The link already has an active split test |
INVALID_STATE | 409 | The split test isn't in a state that allows this |
ALREADY_APPLIED | 409 | The insight's action was already applied (MCP apply_insight) |
PLAN_LIMIT_EXCEEDED | 429 | Caller hit a plan-level cap (e.g. links/month) |
RATE_LIMITED | 429 | Caller hit the per-token request rate limit |
INTERNAL | 500 | Server 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
}
}
GET /links
List your links, newest first.
| Query param | Default | Notes |
|---|---|---|
limit | 50 | 1–200 |
includeArchived | false | "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.
| Field | Required | Notes |
|---|---|---|
destinationUrl | yes | Must be a valid URL. Screened against Google Safe Browsing. |
title | no | ≤ 120 chars |
customSlug | no | 3–40 chars, [a-zA-Z0-9_-]. Reserved slugs rejected. |
domainId | no | One of your verified domain IDs |
iosUrl | no | Premium feature — UA-overridden destination on iOS |
androidUrl | no | Premium feature — UA-overridden destination on Android |
activatesAt | no | ISO-8601. Core+ — before this instant the link 404s. |
expiresAt | no | ISO-8601. Core+ — after this instant the link 404s, or serves fallbackUrl. |
fallbackUrl | no | Core+ — 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:302to/not-found. A campaign that hasn't started has no destination, so there's no fallback for this case. - After
expiresAtwith afallbackUrl:308to the fallback, and the click is still recorded — traffic arriving at a dead campaign is something the owner needs to see. - After
expiresAtwith nofallbackUrl:302to/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.
| Field | Type | Notes |
|---|---|---|
destinationUrl | string | Re-screened by Safe Browsing on change |
title | string | null | null clears |
iosUrl | string | null | URL or null to clear |
androidUrl | string | null | URL or null to clear |
requireSignature | boolean | Toggle HMAC-signed redirects |
archived | boolean | true archives, false unarchives |
activatesAt | string | null | ISO-8601. Core+. null clears |
expiresAt | string | null | ISO-8601. Core+. null clears |
fallbackUrl | string | null | Core+. 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 param | Default | Notes |
|---|---|---|
days | 30 | 1–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 true —
windowDays 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 param | Default | Notes |
|---|---|---|
limit | 10 | 1–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"] }
| Field | Required | Notes |
|---|---|---|
urls | yes | 2–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:
verdictisnulluntil a full week of "after" data exists, andoutcome-derived fields (milestone,method,deltaPct,after) arenullwith it.verdict: "insufficient_data"means the sample floors weren't met. Every delta isnullandinsufficientReasoncarries a progress counter ("14 of 30 clicks since Jun 3"). Do not compute your own percentage frombaselineandafterin this state — the floors exist because that number would be noise.methodis"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
| Param | Required | Description |
|---|---|---|
l | yes | The link id (not the slug). Max 64 chars. |
v | no | Order 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. |
sc | no | The 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.
| Tool | Does | Mutates? | Required scope |
|---|---|---|---|
create_link | Create a short link (same normalization, Safe Browsing, reachability, and monthly-link limit as POST /links) | yes | links:write |
list_links | List your links, newest first | no | links:read |
get_link_analytics | Click totals, unique visitors, and a per-day series for a link (clamped to your plan's retention window) | no | analytics:read |
list_insights | List active AI insights (anomalies, opportunities, recommendations, receipt outcomes) | no | analytics:read |
apply_insight | Execute 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. | yes | rules:write |
create_routing_rule | Add a smart-routing rule to a link (Core+; same validation + per-link cap as the REST rules endpoint) | yes | rules:write |
list_routing_rules | List a link's routing rules in priority order | no | rules:read |
simulate_rule | Dry-run the link's rules against a hypothetical visitor (country, device, browser, referrer, hour, language, returning) and see where they'd land. Writes nothing. | no | rules:read |
create_split_test | Start an A/B/n split test — real traffic starts splitting immediately (Growth+; one active test per link) | yes | splits:write |
get_conversion_report | Per-variant assignments, conversions, conversion rate, and a leadingVariant — only once a variant clears the minimum-sample floor | no | splits:read |
list_receipts | A link's Impact Receipt ledger: what Snipt changed, and what the numbers did since | no | analytics: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
| Event | Fires when |
|---|---|
insight.created | The nightly AI engine writes a new insight for you |
alert.spike | A link's clicks spike versus its recent baseline |
alert.link_broken | A link's destination fails two consecutive nightly health checks |
autopilot.action_applied | Autopilot applied a change to a link you armed it on |
autopilot.action_reverted | Autopilot 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
| Header | Value |
|---|---|
Content-Type | application/json |
X-Snipt-Event | the event name, e.g. insight.created |
X-Snipt-Signature | sha256=<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
userIdin the request body or path. A request forlink.id = 'X'returns 404 ifXbelongs 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.