Appy

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 URLhttps://api.appy.to/v1
Interactive referenceapi.appy.to/docs
OpenAPI 3.1 fileapi.appy.to/v1/openapi.yaml
MCP serverhttps://mcp.appy.to, see MCP server
  • JSON in, JSON out, camelCase field names. Timestamps are RFC 3339 in UTC.
  • Statistics windows take YYYY-MM-DD dates, inclusive, at most two years; the default is the last 30 days.
  • PATCH changes only the fields you send; null clears 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 /v1 at any time; ignore what you do not recognize.

Authentication

Every request carries one credential as Authorization: Bearer <credential>.

CredentialFormatAllowed onNotes
Secret keyappy_sk_ + 40 base62 charactersEverything except /v1/api-keys and /v1/sdk/*, plus the MCP serverServer-side only. 10 active keys per account. Only a SHA-256 hash is stored.
Publishable keyappy_pk_ + 32 characters/v1/sdk/*Created with an app and safe to ship inside it. Deleting the app revokes it.
Dashboard sessionJWT, or the accessToken cookieEverything 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:

bash
curl https://api.appy.to/v1/account \
  -H "Authorization: Bearer $APPY_SECRET_KEY"
response
{
  "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 unauthorized from the next request on.

Plans

FreeProBusinessEnterprise
API keys, REST API, MCP serverNoNoYesYes
Links through the API500 links, custom slugs, deep links, parameter forwardingNo link limit
Statistics through the APIFull history and breakdownsFull history and breakdowns
Apps, /v1/sdk, server events, install lookups and deletionNoNoNoYes

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

CredentialLimitCounted per
Secret key600 requests a minuteKey, shared with the MCP server
Dashboard session300 requests a minuteAccount
Publishable key600 requests a minuteApp 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

json
{
  "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.

StatuscodeWhen
400invalid_requestNot JSON, the wrong shape, or an unknown field.
400validation_failedA value is not acceptable. message names the field.
401unauthorizedMissing, unknown or revoked credential.
403forbiddenValid credential, wrong endpoint.
403plan_requiredThe plan does not include the feature.
403limit_reachedLinks, keys (10) or apps (20) exhausted.
404not_foundMissing, deleted, or owned by another account.
409conflictSlug or subdomain already taken.
429rate_limitedToo many requests. Honor Retry-After.
500internal_errorSomething failed on Appy’s side. Retrying reads is safe.
503service_unavailableA 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:

json
{
  "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.

bash
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"
    }
  }'
response
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 slug to 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_reached means the account has no links left.
  • targets takes iphone, ipad, android and huawei. Devices without a target go to the website (fallbackUrl); with deeplinkUrl, 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.

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:

bash
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
    }
  }'

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

bash
curl "https://api.appy.to/v1/links/spring-sale/stats?start=2026-09-01&end=2026-09-25" \
  -H "Authorization: Bearer $APPY_SECRET_KEY"
response
{
  "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:

json
{
  "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"
}
  • linkDomain goes into the iOS Associated Domains entitlement and the Android intent filter. Every link of the account then works as https://acme.appy.to/<slug> and opens the app when it is installed.
  • publishableKey goes into the SDK configuration.
  • ios.providerToken is optional and turns on Apple campaign analytics.
  • strictAttribution (default false): only count installs Appy can verify; everything else is organic. Set it on POST /v1/apps or PATCH /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:

bash
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:

bash
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"
ParameterMeaning
limit, offsetPage size from 1 to 100 (default 50) and starting row
statusattributed or organic
verifiedtrue, or false for every other install, organic ones included
linkInstalls brought by this link slug; an unknown slug gives an empty page
sourceInstalls whose tap carried this exact utm_source
start, endUTC days of firstSeenAt, both included; either can be left out
qAn 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

bash
curl "https://api.appy.to/v1/apps/$APP_ID/stats?start=2026-09-28&end=2026-09-30" \
  -H "Authorization: Bearer $APPY_SECRET_KEY"
response
{
  "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"
        }
      ]
    },
    "..."
  ]
}
  • installs counts installs first seen in the window: attributed to a link, organic, and byStore. verified is the part of attributed Appy can prove; with strict attribution on, every attributed install is verified.
  • daily has 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.
  • sources groups attributed installs by the tap’s utm_source, utm_medium and utm_campaign, at most 50 rows by installs. A null value means the link carried no such tag. Organic installs are not listed.
  • links lists up to 100 links by installs, each with its verified installs. 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, with organic at 0. An unknown slug returns the same shape with zeros, not 404.

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.

bash
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 id is 1 to 128 printable ASCII characters without spaces. Otherwise the event is rejected with id must be 1-128 printable characters without spaces.
  • id is 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 deduplicationId from 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.

bash
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"
CallDeletesAnswers
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.

EndpointPurpose
GET /v1/accountAccount and plan behind the credential
GET /v1/linksList links (limit, offset, search)
POST /v1/linksCreate 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}/statsTotals, time series and breakdowns
GET /v1/links/{slug}/stats/timeseriesClicks per day
GET /v1/links/{slug}/stats/countriesClicks by country
GET /v1/links/{slug}/stats/osClicks by operating system
GET /v1/statsClicks across all links
GET /v1/stats/linksLinks ranked by clicks
GET /v1/appsList SDK apps
POST /v1/appsRegister 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}/statsInstalls, verified installs, days, UTM sources, events and links; link for one link
POST /v1/apps/{appId}/eventsServer events by installId or userId
GET /v1/apps/{appId}/installsPage 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-keysList active secret keys
POST /v1/api-keysCreate a secret key
DELETE /v1/api-keys/{keyId}Revoke a secret key
POST /v1/sdk/openApp open, deep link and deferred deep link
POST /v1/sdk/eventsIn-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.