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:

ScopeGrants
links:readList + read links
links:writeCreate / update / archive / delete
rules:readList routing rules
rules:writeCreate / delete routing rules
domains:readList 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.

PlanTokens maxRequests/min
Free00
Core00
Growth560
Premium25300

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:

CodeHTTPMeaning
UNAUTHENTICATED401No Authorization header
INVALID_TOKEN401Token malformed, revoked, or expired
PLAN_REQUIRED403Caller's plan doesn't include API access
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
URL_FLAGGED422Destination 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
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": 5
  }
}

GET /links

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",
      "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
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

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 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.