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, and domains programmatically. It's designed for CI deployments, custom dashboards, and bulk integration use cases that don't fit the web UI.
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:
| Scope | Grants |
|---|---|
links:read | List + read links |
links:write | Create / update / archive / delete |
rules:read | List routing rules |
rules:write | Create / delete routing rules |
domains:read | List custom domains |
analytics:read | (Reserved for future analytics endpoints) |
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.
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 | 5 | 60 |
| Premium | 25 | 300 |
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 (60 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 |
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 |
URL_FLAGGED | 422 | Destination 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 |
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": 5
}
}
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",
"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 |
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 |
Unknown fields are rejected with 400 VALIDATION_ERROR.
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 /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.
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.