API
REST API
Create and manage smart links, read their statistics, register SDK apps, and send events and look up installs from your servers.
The REST API and the MCP server are part of the Business and Enterprise plans. Apps, the SDK endpoints, server events and install lookups are part of Enterprise. This guide covers the concepts; the interactive reference has every field.
Base URL and versioning
| Base URL | https://api.appy.to/v1 |
| Interactive reference | api.appy.to/docs |
| OpenAPI 3.1 file | api.appy.to/v1/openapi.yaml |
| MCP server | https://mcp.appy.to, see MCP server |
- JSON in, JSON out, camelCase field names. Timestamps are RFC 3339 in UTC.
- Statistics windows take
YYYY-MM-DDdates, inclusive, at most two years; the default is the last 30 days. PATCHchanges only the fields you send;nullclears an optional field.- Management endpoints reject unknown fields, so typos fail loudly. SDK endpoints ignore them, so older servers accept newer SDKs.
- Breaking changes get a new path prefix (
/v2). New endpoints, optional fields, response fields and error codes can appear in/v1at any time; ignore what you do not recognize.
Authentication
Every request carries one credential as Authorization: Bearer <credential>.
| Credential | Format | Allowed on | Notes |
|---|---|---|---|
| Secret key | appy_sk_ + 40 base62 characters | Everything except /v1/api-keys and /v1/sdk/*, plus the MCP server | Server-side only. 10 active keys per account. Only a SHA-256 hash is stored. |
| Publishable key | appy_pk_ + 32 characters | /v1/sdk/* | Created with an app and safe to ship inside it. Deleting the app revokes it. |
| Dashboard session | JWT, or the accessToken cookie | Everything except /v1/sdk/* | The only credential that can create or revoke secret keys. |
Create a secret key on the dashboard’s API keys page. The full key is shown once; a lost key cannot be recovered, so revoke it and create a new one. Check that a key works:
curl https://api.appy.to/v1/account \
-H "Authorization: Bearer $APPY_SECRET_KEY"{
"id": "0b7c1c86-...",
"plan": "business",
"credential": "secret_key",
"features": {
"api": true,
"mcp": true,
"sdk": false
},
"limits": {
"links": 500
},
"usage": {
"links": 37
}
}A key acts as its account and carries the account’s plan, checked on every request. A publishable key sent to /v1/links gets 403 forbidden rather than 401, so you can tell the wrong kind of key from a bad key.
Handling secret keys
- Create one key per system or assistant and name it after where it lives, such as “CI pipeline” or “Claude on the marketing laptop”. Revoking one then leaves the others working.
- Keys are created only from a signed-in dashboard session, never with another key. Every plan can list and revoke its keys; creating them needs Business or Enterprise, with up to 10 active at a time.
- The full key is shown once. Appy keeps only a SHA-256 hash, so a lost key cannot be recovered: revoke it and create a new one.
- A secret key acts as your whole account. Keep it in an environment variable or a secrets manager on your server, never in a mobile app, a web page or a public repository.
- A revoked key gets
401 unauthorizedfrom the next request on.
Plans
| Free | Pro | Business | Enterprise | |
|---|---|---|---|---|
| API keys, REST API, MCP server | No | No | Yes | Yes |
| Links through the API | 500 links, custom slugs, deep links, parameter forwarding | No link limit | ||
| Statistics through the API | Full history and breakdowns | Full history and breakdowns | ||
Apps, /v1/sdk, server events, install lookups and deletion | No | No | No | Yes |
A credential whose account is below the required plan receives 403 plan_required. Keys survive a downgrade: they stay listed in the dashboard and can be revoked, but API and MCP calls with them are refused until the account is back on Business or Enterprise.
Rate limits
| Credential | Limit | Counted per |
|---|---|---|
| Secret key | 600 requests a minute | Key, shared with the MCP server |
| Dashboard session | 300 requests a minute | Account |
| Publishable key | 600 requests a minute | App and client IP address |
Limits are counted in fixed one-minute windows. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds, when the current window ends). A 429 rate_limited response adds Retry-After; wait that many seconds before retrying.
At 600 requests a minute, one key can read the statistics of every link in a 500-link Business account in under a minute.
Errors
{
"error": {
"code": "validation_failed",
"message": "fallbackUrl must be a valid http(s) URL",
"requestId": "4b6f1c2e-7c1a-4bd6-9f36-2f1f0f7b8c11"
}
}Branch on code, show message to people, and log requestId, which is also sent as the X-Request-ID header. Include it when you contact support.
| Status | code | When |
|---|---|---|
| 400 | invalid_request | Not JSON, the wrong shape, or an unknown field. |
| 400 | validation_failed | A value is not acceptable. message names the field. |
| 401 | unauthorized | Missing, unknown or revoked credential. |
| 403 | forbidden | Valid credential, wrong endpoint. |
| 403 | plan_required | The plan does not include the feature. |
| 403 | limit_reached | Links, keys (10) or apps (20) exhausted. |
| 404 | not_found | Missing, deleted, or owned by another account. |
| 409 | conflict | Slug or subdomain already taken. |
| 429 | rate_limited | Too many requests. Honor Retry-After. |
| 500 | internal_error | Something failed on Appy’s side. Retrying reads is safe. |
| 503 | service_unavailable | A dependency is temporarily down. Retry with exponential backoff. |
Pagination
List endpoints use limit (1 to 100, default 50) and offset, and return the page with its position:
{
"data": [
"..."
],
"pagination": {
"limit": 50,
"offset": 0,
"total": 137,
"hasMore": true
}
}Request the next page with offset increased by limit while hasMore is true. GET /v1/stats/links returns one ranked list without offset (limit 1 to 100, default 10), and the installs of a user return at most 20 in data.
Links
curl https://api.appy.to/v1/links \
-H "Authorization: Bearer $APPY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "spring-sale",
"title": "Spring sale",
"fallbackUrl": "https://acme.com/spring",
"deeplinkUrl": "acme://sale/spring",
"targets": {
"iphone": "https://apps.apple.com/app/id123456789",
"android": "https://play.google.com/store/apps/details?id=com.acme.shop"
}
}'HTTP/1.1 201 Created
Location: /v1/links/spring-sale
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1790330460
{
"id": "3f6c2a1e-8b4d-4c7a-9e21-5d0b7c9a1f42",
"slug": "spring-sale",
"url": "https://appy.to/spring-sale",
"title": "Spring sale",
"fallbackUrl": "https://acme.com/spring",
"deeplinkUrl": "acme://sale/spring",
"parameterForwarding": false,
"targets": {
"iphone": "https://apps.apple.com/app/id123456789",
"android": "https://play.google.com/store/apps/details?id=com.acme.shop"
},
"createdAt": "2026-09-25T10:00:00Z",
"updatedAt": "2026-09-25T10:00:00Z"
}- The link and its targets are written in one transaction: if a target is rejected, no link is created and the slug stays free. Omit
slugto get a random five-character one. - Link writes go through the same service as the dashboard, so the same fields, validation and plan link limit apply.
403 limit_reachedmeans the account has no links left. targetstakesiphone,ipad,androidandhuawei. Devices without a target go to the website (fallbackUrl); withdeeplinkUrl, phones first try to open the app.DELETE /v1/links/{slug}deactivates the link. It stops counting against the plan limit, but the slug stays taken.- With
"parameterForwarding": true(Business), query parameters on the short link, such as UTM tags, coupons or creator codes, are passed to the destination and to the deep link, so one link serves every campaign variant without using more of your link limit.
Updating links
PATCH /v1/links/{slug} changes only the fields you send. null clears an optional field, and inside targets it removes that platform. This request clears the title, adds an iPad target and removes the Android one:
curl -X PATCH https://api.appy.to/v1/links/spring-sale \
-H "Authorization: Bearer $APPY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": null,
"targets": {
"ipad": "https://apps.apple.com/app/id123456789",
"android": null
}
}'Creating links from a webhook
Put your own id in the slug, for example product-4812, so retries are safe. A second attempt with the same slug returns 409 conflict: treat it as already created and read the link with GET /v1/links/{slug}. The same pattern works for a CMS, a product catalog or a CRM.
Statistics
curl "https://api.appy.to/v1/links/spring-sale/stats?start=2026-09-01&end=2026-09-25" \
-H "Authorization: Bearer $APPY_SECRET_KEY"{
"slug": "spring-sale",
"start": "2026-09-01",
"end": "2026-09-25",
"detail": "full",
"totalClicks": 1284,
"uniqueClicks": 1102,
"timeSeries": [
{
"date": "2026-09-01",
"clicks": 38
},
"..."
],
"breakdowns": {
"countries": [
{
"countryCode": "TR",
"countryName": "Turkey",
"clicks": 746,
"percentage": 58.1
},
"..."
],
"operatingSystems": [
{
"os": "iOS",
"clicks": 702,
"percentage": 54.7
},
"..."
],
"devices": [
"..."
],
"browsers": [
"..."
],
"referrers": [
"..."
],
"outcomes": {
"store": 603,
"appOpens": 512,
"fallback": 169
}
}
}/stats returns totals, a daily time series and breakdowns for one link. outcomes counts visitors sent to a store, into the app and to the website (fallback). /stats/timeseries, /stats/countries and /stats/os return one slice each. For the whole account, GET /v1/stats returns the same totals and breakdowns across all links, and GET /v1/stats/links?limit=10 ranks links by clicks in the window.
For a nightly export to a warehouse or BI tool, page through GET /v1/links and read /stats/timeseries for each link. At 600 requests a minute, one key covers a 500-link account comfortably.
Apps
POST /v1/apps registers an iOS and Android app for the SDK (Enterprise) and returns the two values your mobile developers need:
{
"id": "8d3f5a0b-2c4e-4f7a-9b1d-6e8f0a2c4d6e",
"name": "Acme Shop",
"subdomain": "acme",
"linkDomain": "acme.appy.to",
"publishableKey": "appy_pk_4NkP0z0cS8y1v7QeXw2mTa9bLr3HdJ6u",
"ios": {
"teamId": "ABCDE12345",
"bundleId": "com.acme.shop",
"appStoreId": "123456789",
"providerToken": "118445"
},
"android": {
"packageName": "com.acme.shop",
"sha256CertFingerprints": [
"14:6D:E9:...:44:E5"
]
},
"strictAttribution": false,
"createdAt": "2026-09-25T10:00:00Z",
"updatedAt": "2026-09-25T10:00:00Z"
}linkDomaingoes into the iOS Associated Domains entitlement and the Android intent filter. Every link of the account then works ashttps://acme.appy.to/<slug>and opens the app when it is installed.publishableKeygoes into the SDK configuration.ios.providerTokenis optional and turns on Apple campaign analytics.strictAttribution(defaultfalse): only count installs Appy can verify; everything else is organic. Set it onPOST /v1/appsorPATCH /v1/apps/{appId}.- The subdomain cannot be changed, because Apple and Google cache the verification files and installed apps keep the entitlement. To move to another subdomain, register a second app.
DELETE /v1/apps/{appId}revokes the publishable key and keeps the app’s installs and events, which can then no longer be reached through the API. Delete users’ data first.
Installs and attribution
Before you pay a referral bonus or unlock a promotion, ask which link brought the install. Look it up by install id, or by the user id your app set with setUserId:
curl https://api.appy.to/v1/apps/$APP_ID/installs/7f0c2b9e-3a61-4c2e-9d0b-5c1e8f2a9b10 \
-H "Authorization: Bearer $APPY_SECRET_KEY"
curl "https://api.appy.to/v1/apps/$APP_ID/installs?userId=u_123" \
-H "Authorization: Bearer $APPY_SECRET_KEY"Each install has attribution.status (attributed or organic), the link, the tap’s URL, parameters, UTM source, medium and campaign, the tap time, and attribution.verified. The user lookup returns {"data": [...]} with up to 20 installs, most recently seen first. Require verified before paying out money or rewards; see Verified installs.
Browsing installs
Without userId, GET /v1/apps/{appId}/installs returns a page of the app’s installs, newest first, with the same pagination as other lists. Filters combine:
curl "https://api.appy.to/v1/apps/$APP_ID/installs?status=attributed&verified=true&link=summer-sale&limit=20" \
-H "Authorization: Bearer $APPY_SECRET_KEY"| Parameter | Meaning |
|---|---|
limit, offset | Page size from 1 to 100 (default 50) and starting row |
status | attributed or organic |
verified | true, or false for every other install, organic ones included |
link | Installs brought by this link slug; an unknown slug gives an empty page |
source | Installs whose tap carried this exact utm_source |
start, end | UTC days of firstSeenAt, both included; either can be left out |
q | An exact install id or an exact user id |
Each item is the same install record as the lookup. GET /v1/apps/{appId}/installs/{installId} also returns activity: the number of events stored for the install, their revenue per currency, and lastEventAt (null without events).
App statistics
curl "https://api.appy.to/v1/apps/$APP_ID/stats?start=2026-09-28&end=2026-09-30" \
-H "Authorization: Bearer $APPY_SECRET_KEY"{
"appId": "8d3f5a0b-2c4e-4f7a-9b1d-6e8f0a2c4d6e",
"start": "2026-09-28",
"end": "2026-09-30",
"installs": {
"total": 9,
"attributed": 6,
"verified": 3,
"organic": 3,
"byStore": {
"app_store": 4,
"google_play": 2,
"huawei_appgallery": 1,
"unknown": 2
}
},
"daily": [
{
"date": "2026-09-28",
"installs": 0,
"attributed": 0,
"organic": 0,
"events": 0,
"revenue": []
},
{
"date": "2026-09-29",
"installs": 1,
"attributed": 1,
"organic": 0,
"events": 1,
"revenue": [
{
"currency": "EUR",
"amount": "5"
}
]
},
"..."
],
"sources": [
{
"source": null,
"medium": null,
"campaign": null,
"installs": 2,
"events": 5,
"revenue": [
{
"currency": "USD",
"amount": "19.98"
}
]
},
{
"source": "instagram",
"medium": "story",
"campaign": "summer_sale",
"installs": 2,
"events": 3,
"revenue": [
{
"currency": "USD",
"amount": "9.99"
}
]
},
"..."
],
"events": [
{
"name": "purchase",
"count": 3,
"revenue": [
{
"currency": "USD",
"amount": "29.97"
}
]
},
"..."
],
"links": [
{
"slug": "summer-sale",
"installs": 2,
"verified": 1,
"events": 3,
"revenue": [
{
"currency": "USD",
"amount": "9.99"
}
]
},
"..."
]
}installscounts installs first seen in the window:attributedto a link,organic, andbyStore.verifiedis the part ofattributedAppy can prove; with strict attribution on, every attributed install is verified.dailyhas one row per UTC day of the window, oldest first, with zeros on quiet days. Installs count on the day they were first seen, events and revenue on the day they happened.sourcesgroups attributed installs by the tap’sutm_source,utm_mediumandutm_campaign, at most 50 rows by installs. A null value means the link carried no such tag. Organic installs are not listed.linkslists up to 100 links by installs, each with itsverifiedinstalls. Events and revenue follow the install that sent them, so a link or a source is credited with the events of the installs it brought.- Add
link=<slug>and every figure covers only the installs that link brought and their events, withorganicat 0. An unknown slug returns the same shape with zeros, not404.
Server events
Renewals charged by the store, refunds, orders confirmed after payment: send them with the install id or the user id your app reports, and Appy credits them to the link that brought that install. A userId is resolved to that user’s most recently seen install.
curl https://api.appy.to/v1/apps/$APP_ID/events \
-H "Authorization: Bearer $APPY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"events": [{"id": "GPA.3372-4150-9088-12345", "name": "purchase",
"userId": "u_123", "revenue": 9.99, "currency": "USD"}]}'Up to 100 events per request. The answer is 202 with accepted, duplicates and rejected; an invalid event is listed in rejected with its index and a message, and the other events are stored.
- An event
idis 1 to 128 printable ASCII characters without spaces. Otherwise the event is rejected withid must be 1-128 printable characters without spaces. idis the deduplication key for the app, shared by SDK events and server events. An event whose id is already stored counts as a duplicate, and the first event with an id wins.- Inside one request, a repeated id is rejected with
duplicate id in batch. - To count a purchase once when both the app and your backend report it, use the store’s id on both sides: the App Store transaction id or the Google Play order id, sent as
deduplicationIdfrom the SDK.
Deleting installs
When someone closes their account or asks to be forgotten, erase their data. Both calls need a secret key or a dashboard session; publishable keys cannot delete data, and deletion is not offered through the MCP server or the SDKs.
curl -X DELETE "https://api.appy.to/v1/apps/$APP_ID/installs?userId=u_123" \
-H "Authorization: Bearer $APPY_SECRET_KEY"
curl -X DELETE https://api.appy.to/v1/apps/$APP_ID/installs/7f0c2b9e-3a61-4c2e-9d0b-5c1e8f2a9b10 \
-H "Authorization: Bearer $APPY_SECRET_KEY"| Call | Deletes | Answers |
|---|---|---|
DELETE /v1/apps/{appId}/installs?userId= | Every install of the app that reported the user id, every event of those installs, and every other event sent with that user id, including server events stored without an install. | 200 with {"deletedInstalls": 1, "deletedEvents": 3}. Zero counts for a user Appy has not seen or a repeated call. 400 validation_failed without userId. |
DELETE /v1/apps/{appId}/installs/{installId} | The install and every event stored under its install id, from the SDK and from your server. | 204, or 404 not_found when nothing is stored under that install id, which is also the answer to a repeated call. |
- Deleting is permanent and runs in one transaction. App statistics computed afterwards no longer include the deleted installs, events or revenue.
- Deleting stored data does not stop an installed app from sending more. Clear the user id in the app or turn tracking off first, otherwise its next launch is stored as a new, organic install.
- Installs and events are kept until you delete them this way or the account is deleted. Deleting an app keeps them out of reach of the API, so erase a user’s data before you delete the app.
Endpoints
Every endpoint accepts a secret key or a dashboard session, except /v1/api-keys, which needs a signed-in session, and /v1/sdk/*, which takes the publishable key.
| Endpoint | Purpose |
|---|---|
GET /v1/account | Account and plan behind the credential |
GET /v1/links | List links (limit, offset, search) |
POST /v1/links | Create a link with its targets |
GET /v1/links/{slug} | Read a link |
PATCH /v1/links/{slug} | Partial update, including targets |
DELETE /v1/links/{slug} | Deactivate a link |
GET /v1/links/{slug}/stats | Totals, time series and breakdowns |
GET /v1/links/{slug}/stats/timeseries | Clicks per day |
GET /v1/links/{slug}/stats/countries | Clicks by country |
GET /v1/links/{slug}/stats/os | Clicks by operating system |
GET /v1/stats | Clicks across all links |
GET /v1/stats/links | Links ranked by clicks |
GET /v1/apps | List SDK apps |
POST /v1/apps | Register an app |
GET /v1/apps/{appId} | Read an app |
PATCH /v1/apps/{appId} | Change name, platforms or strict attribution |
DELETE /v1/apps/{appId} | Delete an app and revoke its publishable key |
GET /v1/apps/{appId}/stats | Installs, verified installs, days, UTM sources, events and links; link for one link |
POST /v1/apps/{appId}/events | Server events by installId or userId |
GET /v1/apps/{appId}/installs | Page through installs with filters |
GET /v1/apps/{appId}/installs/{installId} | One install, its attribution and its events |
GET /v1/apps/{appId}/installs?userId= | Installs that reported a user id |
DELETE /v1/apps/{appId}/installs/{installId} | Delete an install and its events |
DELETE /v1/apps/{appId}/installs?userId= | Delete a user’s installs and events |
GET /v1/api-keys | List active secret keys |
POST /v1/api-keys | Create a secret key |
DELETE /v1/api-keys/{keyId} | Revoke a secret key |
POST /v1/sdk/open | App open, deep link and deferred deep link |
POST /v1/sdk/events | In-app events and revenue |
Every endpoint, schema and example is in the interactive reference. Generate a client from the OpenAPI file, or import it into Postman or Insomnia.