Developers
The visitto.me REST API is the tracking backend for your own tools and integrations. Mint a uniquely-tracked short code per recipient, then read who clicked — by name, time, place, and device.
Base URL https://visitto.me/api/v1
Everything is scoped to your account and accessed over HTTPS with an API key. One parent link holds a shared destination; each recipient gets its own short code and URL, and every click is attributed to that recipient and rolled up to the link and campaign.
For the full integration walkthrough (idempotency, retries, batching), see the bulk integration guide.
Send your API key as a Bearer token on every request:
Authorization: Bearer vm_live_<prefix>_<secret>Create keys in the dashboard under Settings → API keys. The full token is shown once at creation — only a lookup prefix and a hash of the secret are stored. Keys carry scopes:
links:writeCreate and update linksrecipients:writeMint per-recipient tracked codesanalytics:readRead analytics and list recipientscampaigns:writeCreate campaignsEvery response uses the same envelope:
{ "data": ..., "meta": ..., "error": null }On failure, data is null and error is populated:
{ "data": null, "meta": null,
"error": { "code": "quota_exceeded", "message": "...", "details": null } }| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key |
| 403 | Key is valid but missing the required scope |
| 402 | Plan quota exceeded — upgrade to continue |
| 404 | Resource not found (or not yours) |
| 409 | Conflict — e.g. a custom short code is taken |
| 422 | Validation error — check details |
| 429 | Rate limited — back off and retry |
| 500 | Something went wrong on our side |
API requests are metered per key per day, with the daily ceiling set by your plan (over-limit returns 429). Creating links and recipients also counts against your plan's monthly quotas — exceeding them returns 402 with a message. Batch calls accept up to 5,000 recipients each.
/linksscope: links:write (+ recipients:write to inline)Create a short link — optionally with all its per-recipient tracked codes in the same call (the bulk one-shot path). Idempotent on each recipient's externalId.
POST /api/v1/links
{
"destinationUrl": "https://acme.com/landing",
"title": "Trade show 2026",
"recipients": [
{ "name": "Alice", "externalId": "crm_1" },
{ "name": "Bob", "externalId": "crm_2", "destinationUrl": "https://acme.com/bob" }
]
}Returns 201 with { id, shortCode, url, destinationUrl, title, createdAt, recipients }. When recipients are inlined, recipients is { created, skipped, items: [{ id, externalId, name, shortCode, url }] }.
/links?limit=&offset=&search=scope: analytics:readList your links, newest first. limit ≤ 200.
/links/{id}scope: analytics:readFetch a single link.
/links/{id}scope: links:writeUpdate destinationUrl, title, isActive, and/or expiresAt.
/links/{id}scope: links:writeDelete a link and its recipients, clicks, and short codes.
/links/{id}/recipientsscope: recipients:writeBulk-mint individually-tracked codes under a link. Idempotent on externalId — re-posting a recipient whoseexternalId already exists returns the existing row instead of minting a duplicate (safe for re-runs). A recipient's optional destinationUrl makes their code redirect somewhere unique.
POST /api/v1/links/{id}/recipients
{
"recipients": [
{ "name": "Alice", "email": "alice@x.com", "externalId": "crm_1",
"customFields": { "tier": "gold" } },
{ "name": "Bob", "externalId": "crm_2" }
]
}Returns 201 with { created, skipped, recipients: [{ id, externalId, name, email, shortCode, url, clickCount, customFields }] }.
/links/{id}/recipients?limit=&offset=scope: analytics:readList a link's recipients, ordered by clicks. limit ≤ 500.
/links/{id}/analyticsscope: analytics:readLink summary: { id, shortCode, url, destinationUrl, totalClicks, recipientCount, byCountry, topRecipients }.
/recipients/{id}/analyticsscope: analytics:readPer-recipient breakdown: { id, name, email, externalId, shortCode, url, totalClicks, firstClickedAt, lastClickedAt, byCountry, byDevice }.
/campaignsscope: campaigns:writeCreate a campaign to organise links.
{ "name": "Summer 2026", "description": "Optional", "color": "#2E2AE0" }/campaignsscope: analytics:readList campaigns with live linkCount + totalClicks.
/campaigns/{id}/analyticsscope: analytics:readCampaign rollup + per-link breakdown.
Register endpoints in Settings → Webhooks. When a recipient clicks, visitto.me sends a signed POST:
POST <your-url>
X-Visitto-Event: recipient.clicked
X-Visitto-Signature: sha256=<hex of HMAC-SHA256(rawBody, secret)>
{ "event": "recipient.clicked",
"data": { "recipientId", "parentLinkId", "name", "email",
"externalId", "shortCode", "country", "device", "clickedAt" },
"timestamp": "..." }Verify by recomputing HMAC-SHA256(rawBody, secret) and comparing to the header. The externalId lets you route the event straight to your own record.
Create a link with two tracked recipients in one call:
curl -X POST https://visitto.me/api/v1/links \
-H "Authorization: Bearer vm_live_..." \
-H "Content-Type: application/json" \
-d '{
"destinationUrl": "https://acme.com/landing",
"recipients": [
{ "name": "Alice", "externalId": "crm_1" },
{ "name": "Bob", "externalId": "crm_2" }
]
}'Each returned items[].url is a unique short URL to encode as a QR or send. Read who engaged with GET /recipients/{id}/analytics.