Apps API endpoints to register and manage the mobile apps used for deep linking, with iOS and Android configuration, verification files, and plan limits.
An app is the piece of WarpLink's data model between an organization and a link: organization → apps → links. Register an app before you create links: a request to create a link in an organization with no app is refused with APP_REQUIRED. A link can still point only to a web page. Links that already have no app_id keep resolving a destination, tracking clicks, and rendering social previews, but they have nothing to open a native app with. Attach an app, and a link gains Universal Links on iOS, App Links on Android, and an entry in the install attribution match cascade. Most organizations register exactly one app that covers both platforms; the API doesn't require that shape, but the dashboard's UI is built around it.
An app holds independent, optional configuration for iOS and Android. You can register just one platform, both, or add the second one later with an update. The only hard requirement, enforced on both create and update, is that at least one platform stays configured: an app with neither an ios_bundle_id nor an android_package_name is refused, because an app with no platform can't do the one thing an app exists to do.
Authentication and Permissions
| Operation | Scope | Role required (dashboard-created keys) |
|---|
| Create an app | apps:write | any role, including member (apps:write) |
| List apps | apps:read | any role, including member (apps:read) |
| Get an app | apps:read | any role, including member (apps:read) |
| Update an app | apps:write | any role, including member (apps:write) |
| Delete an app | apps:write | owner or admin (apps:delete) |
Reading, creating, and updating apps is open to every role, including member, which matches the dashboard's own permission matrix: anyone on the team can register an app and edit how it is wired up. Deleting an app needs the apps:write scope and a role that holds the apps:delete permission, which only owner and admin roles hold, so a key whose creator is a member gets 403 FORBIDDEN on delete. The role check only applies to keys created through the dashboard UI, which carry a created_by; a key created without that context is authorized on scope alone. See Authentication for how the two layers combine on every request.
Plan Limits
Apps count against a per-organization limit that scales with plan:
| Plan | App limit |
|---|
| Free | 1 |
| Starter | 5 |
| Growth | 20 |
| Scale | Unlimited |
| Unlimited | Unlimited |
One "app" here covers every platform you register under it. iOS, Android, React Native, and Flutter configuration for the same product all live on one app row, so a cross-platform app that supports both stores still counts once. The limit is checked before validating the request body: creating past the limit fails with 403 APP_LIMIT_EXCEEDED regardless of whether the body itself would have been valid.
Validation and Behavior
ios_bundle_id must be reverse-domain notation (for example com.example.myapp), up to 255 characters.
ios_team_id must be alphanumeric, uppercase, up to 20 characters.
ios_app_store_id must be numeric, up to 20 characters.
android_package_name must be a valid Java package name (for example com.example.myapp), up to 255 characters.
android_sha256_fingerprints are normalized to uppercase and validated as 32 colon-separated hex byte pairs each.
ios_universal_link_paths and android_app_links_paths are arrays of paths, each starting with / (for example /products/*).
probabilistic_matching is a boolean, true by default. Set it to false for routing-only mode: deep links keep working, and the server no longer matches installs by probabilistic match or raw device signals for that app, so deferred links and attribution use only the Play Install Referrer and the device ID repeat check. It is on update only, applies at once, and any role that can update the app can change it. See Routing-only mode.
org_id cannot be set on update. Sending it, even unchanged, is refused with 400 IMMUTABLE_FIELD before the rest of the body is even validated.
- A bundle ID or package name must be unique within its organization. A collision, on create or on update, is refused with
409 DUPLICATE_APP.
- Updating an app's identifiers or paths regenerates the app's
apple-app-site-association and Android assetlinks.json files automatically, so Universal Links and App Links keep working without a separate publish step. This runs after the response is sent and never blocks or fails the update itself.
- The app's icon is resolved automatically from the store IDs you provide (App Store ID, Play package name) shortly after creation. This also runs after the response and never blocks it;
icon_url and icon_source on the app may still be null immediately after a 201.
- Deleting an app does not delete its links. A link's
app_id is a nullable foreign key that clears to null on app delete (onDelete: "set null"), so the link and its click history survive as a web-only link with no app. The app's affected links are automatically resynced afterward so nothing keeps pointing at deep link configuration that no longer exists.
Create an App
POST/apps
Create an app
Request Body
| Field | Type | Description |
|---|
name* | string | |
ios_bundle_id | string | Reverse-domain notation, e.g. com.example.myapp |
ios_team_id | string | Apple Team ID, e.g. ABCDE12345 |
ios_app_store_id | string | Numeric App Store ID |
ios_app_store_url | string | |
ios_universal_link_paths | string[] | |
android_package_name | string | Java package name, e.g. com.example.myapp |
android_sha256_fingerprints | string[] | |
android_play_store_url | string | |
android_app_links_paths | string[] | |
link_handle | string | The app's link subdomain, as in link_handle.aplnk.to. 3 to 30 characters: lowercase letters, numbers and hyphens, not starting or ending with a hyphen. Reserved names such as www, api, app and docs are refused. Defaults to a name derived from the app name, with a suffix added if that name is taken. Uppercase letters are accepted and stored in lowercase. A value you send that another app already holds returns 409 APP_HANDLE_TAKEN, and one that was just released returns 409 APP_HANDLE_HELD with details.available_at. |
Responses
201App created
| Field | Type | Description |
|---|
id | string | |
org_id | string | |
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
icon_url | string | null | |
icon_source | "auto" | "manual" | |
probabilistic_matching | boolean | False means routing-only mode: the server no longer matches installs by probabilistic match or raw device signals for this app. Deep links keep working, and deferred links and attribution use only deterministic signals (the Android Play Install Referrer click id, and a device that already has an install). Defaults to true. |
link_handle | string | The lowercase subdomain label for this app's links, for example acme in acme.aplnk.to. Every app has one. |
link_domain | string | The full host derived from link_handle, for example acme.aplnk.to. |
legacy_apex_enabled | boolean | True for an app that existed when app subdomains launched. Its links on aplnk.to keep working and aplnk.to keeps listing the app for good. |
default_link_domain | string | The domain a new link gets when a create request names none: the saved default, or link_domain when none is saved. Change it with PUT /v1/apps/{id}/link-domain. It never changes a link that already exists. |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. A link created with no domain then gets 409 DEFAULT_DOMAIN_UNAVAILABLE until you choose a domain or a new default. |
allowed_link_domains | string[] | The hostnames a new link for this app may use: the default first when it is available, then aplnk.to (legacy apps only), link_domain and the org's live custom domains. |
previous_link_handle | string | null | The app's old subdomain label while a rename period is open. null otherwise. |
previous_link_domain | string | null | The full old host while a rename period is open, for example oldname.aplnk.to. It keeps opening the app. |
rename_period_ends_at | string | null | The date shown for the open rename period. It is not a deadline and not a promise to move links that day. If links on the old subdomain later move to the new one, their old URLs redirect. null when no period is open. |
rename_period_extensions | integer | How many times the open rename period was extended by 1 month. At most 2. |
created_at | string | |
updated_at | string | |
400Validation error
| Field | Type | Description |
|---|
error* | object | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden
| Field | Type | Description |
|---|
error* | object | |
409Duplicate bundle ID or package name, or link_handle is already taken (APP_HANDLE_TAKEN)
| Field | Type | Description |
|---|
error* | object | |
At least one of ios_bundle_id or android_package_name must be present. Everything else, including the store URLs and universal/app link paths, is optional and can be added later with an update.
List Apps
GET/apps
List apps
Parameters
| Name | Type | In | Description |
|---|
limit | integer | query | |
offset | integer | query | |
search | string | query | |
Responses
200List of apps
| Field | Type | Description |
|---|
data | object[] | |
pagination | object | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden
| Field | Type | Description |
|---|
error* | object | |
Supports an optional search parameter (up to 200 characters) to filter by name, alongside the standard limit and offset pagination shared across list endpoints.
Get an App
GET/apps/{id}
Get an app
Parameters
| Name | Type | In | Description |
|---|
id* | string | path | |
Responses
200App details
| Field | Type | Description |
|---|
id | string | |
org_id | string | |
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
icon_url | string | null | |
icon_source | "auto" | "manual" | |
probabilistic_matching | boolean | False means routing-only mode: the server no longer matches installs by probabilistic match or raw device signals for this app. Deep links keep working, and deferred links and attribution use only deterministic signals (the Android Play Install Referrer click id, and a device that already has an install). Defaults to true. |
link_handle | string | The lowercase subdomain label for this app's links, for example acme in acme.aplnk.to. Every app has one. |
link_domain | string | The full host derived from link_handle, for example acme.aplnk.to. |
legacy_apex_enabled | boolean | True for an app that existed when app subdomains launched. Its links on aplnk.to keep working and aplnk.to keeps listing the app for good. |
default_link_domain | string | The domain a new link gets when a create request names none: the saved default, or link_domain when none is saved. Change it with PUT /v1/apps/{id}/link-domain. It never changes a link that already exists. |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. A link created with no domain then gets 409 DEFAULT_DOMAIN_UNAVAILABLE until you choose a domain or a new default. |
allowed_link_domains | string[] | The hostnames a new link for this app may use: the default first when it is available, then aplnk.to (legacy apps only), link_domain and the org's live custom domains. |
previous_link_handle | string | null | The app's old subdomain label while a rename period is open. null otherwise. |
previous_link_domain | string | null | The full old host while a rename period is open, for example oldname.aplnk.to. It keeps opening the app. |
rename_period_ends_at | string | null | The date shown for the open rename period. It is not a deadline and not a promise to move links that day. If links on the old subdomain later move to the new one, their old URLs redirect. null when no period is open. |
rename_period_extensions | integer | How many times the open rename period was extended by 1 month. At most 2. |
created_at | string | |
updated_at | string | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden
| Field | Type | Description |
|---|
error* | object | |
404Not found
| Field | Type | Description |
|---|
error* | object | |
Returns the full app configuration, including whichever platform fields are set. Fields for a platform you haven't configured come back null rather than omitted, so a client can always read ios_bundle_id and android_package_name without checking for their presence first.
Update an App
PATCH/apps/{id}
Update an app
Parameters
| Name | Type | In | Description |
|---|
id* | string | path | |
Request Body
| Field | Type | Description |
|---|
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
probabilistic_matching | boolean | Set false to turn on routing-only mode for this app, true to turn probabilistic matching back on. The change applies to the match endpoint at once. Links already shared keep working either way. |
link_handle | string | The subdomain label for this app: 3 to 30 characters, lowercase letters, numbers and hyphens, not starting or ending with a hyphen. Reserved names such as www, api, app, docs and test are refused. Uppercase letters are accepted and stored in lowercase. It needs an API key that has a creator, and expected_link_handle. Sending the current label confirms it. An unused subdomain (no link and no redirect uses it) changes on any plan at once, with no confirmation and no rename period, and the old name is held for 7 days: only this app can take it back before then. Once links or redirects use it, one more rename is allowed on every plan, needs confirm: true and keeps the old subdomain opening the app (rename_period_ends_at is not a deadline); if its links move to the new subdomain, their old URLs redirect. Rejected with 409 RENAME_PERIOD_OPEN while an earlier rename period is open. Checked for uniqueness against every app subdomain. |
expected_link_handle | string | The link_handle the app has now, as you last read it. Required whenever link_handle is sent. A different current handle returns 409 APP_LINK_STATE_CHANGED and writes nothing. |
confirm | boolean | Set to true to rename while links still use the old host. Without it the request returns 409 CONFIRMATION_REQUIRED with details.links_on_old_host. Not needed for an unused subdomain. |
Responses
200Updated app
| Field | Type | Description |
|---|
id | string | |
org_id | string | |
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
icon_url | string | null | |
icon_source | "auto" | "manual" | |
probabilistic_matching | boolean | False means routing-only mode: the server no longer matches installs by probabilistic match or raw device signals for this app. Deep links keep working, and deferred links and attribution use only deterministic signals (the Android Play Install Referrer click id, and a device that already has an install). Defaults to true. |
link_handle | string | The lowercase subdomain label for this app's links, for example acme in acme.aplnk.to. Every app has one. |
link_domain | string | The full host derived from link_handle, for example acme.aplnk.to. |
legacy_apex_enabled | boolean | True for an app that existed when app subdomains launched. Its links on aplnk.to keep working and aplnk.to keeps listing the app for good. |
default_link_domain | string | The domain a new link gets when a create request names none: the saved default, or link_domain when none is saved. Change it with PUT /v1/apps/{id}/link-domain. It never changes a link that already exists. |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. A link created with no domain then gets 409 DEFAULT_DOMAIN_UNAVAILABLE until you choose a domain or a new default. |
allowed_link_domains | string[] | The hostnames a new link for this app may use: the default first when it is available, then aplnk.to (legacy apps only), link_domain and the org's live custom domains. |
previous_link_handle | string | null | The app's old subdomain label while a rename period is open. null otherwise. |
previous_link_domain | string | null | The full old host while a rename period is open, for example oldname.aplnk.to. It keeps opening the app. |
rename_period_ends_at | string | null | The date shown for the open rename period. It is not a deadline and not a promise to move links that day. If links on the old subdomain later move to the new one, their old URLs redirect. null when no period is open. |
rename_period_extensions | integer | How many times the open rename period was extended by 1 month. At most 2. |
created_at | string | |
updated_at | string | |
400Validation error
| Field | Type | Description |
|---|
error* | object | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden. ATTRIBUTED_KEY_REQUIRED when the API key has no creator and link_handle is sent. FORBIDDEN when the key lacks apps:write, or its creator is no longer in the organization.
| Field | Type | Description |
|---|
error* | object | |
404Not found
| Field | Type | Description |
|---|
error* | object | |
409DUPLICATE_APP for a bundle ID or package name collision. APP_HANDLE_TAKEN when link_handle is already taken. APP_HANDLE_HELD when link_handle was just released and is held for 7 days (details.available_at). RENAME_PERIOD_OPEN while an earlier rename period is open (details.previous_link_handle and details.rename_period_ends_at). CONFIRMATION_REQUIRED when links use the old host and confirm is not true; details.links_on_old_host is the link count. APP_LINK_STATE_CHANGED when expected_link_handle is not the current handle, or when a link started to use the subdomain before an unused-subdomain change was written.
| Field | Type | Description |
|---|
error* | object | |
A PATCH, not a full replace: fields you omit keep their current value, and fields you send as an explicit value overwrite it. The one exception is the platform-configured rule. If your update would leave the app with neither ios_bundle_id nor android_package_name, whether by explicitly nulling the last remaining one or by leaving it unset while the other was never set, the update is refused with 400 VALIDATION_ERROR before anything is written.
Delete an App
DELETE/apps/{id}
Delete an app
Parameters
| Name | Type | In | Description |
|---|
id* | string | path | |
Responses
200Deleted app
| Field | Type | Description |
|---|
id | string | |
org_id | string | |
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
icon_url | string | null | |
icon_source | "auto" | "manual" | |
probabilistic_matching | boolean | False means routing-only mode: the server no longer matches installs by probabilistic match or raw device signals for this app. Deep links keep working, and deferred links and attribution use only deterministic signals (the Android Play Install Referrer click id, and a device that already has an install). Defaults to true. |
link_handle | string | The lowercase subdomain label for this app's links, for example acme in acme.aplnk.to. Every app has one. |
link_domain | string | The full host derived from link_handle, for example acme.aplnk.to. |
legacy_apex_enabled | boolean | True for an app that existed when app subdomains launched. Its links on aplnk.to keep working and aplnk.to keeps listing the app for good. |
default_link_domain | string | The domain a new link gets when a create request names none: the saved default, or link_domain when none is saved. Change it with PUT /v1/apps/{id}/link-domain. It never changes a link that already exists. |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. A link created with no domain then gets 409 DEFAULT_DOMAIN_UNAVAILABLE until you choose a domain or a new default. |
allowed_link_domains | string[] | The hostnames a new link for this app may use: the default first when it is available, then aplnk.to (legacy apps only), link_domain and the org's live custom domains. |
previous_link_handle | string | null | The app's old subdomain label while a rename period is open. null otherwise. |
previous_link_domain | string | null | The full old host while a rename period is open, for example oldname.aplnk.to. It keeps opening the app. |
rename_period_ends_at | string | null | The date shown for the open rename period. It is not a deadline and not a promise to move links that day. If links on the old subdomain later move to the new one, their old URLs redirect. null when no period is open. |
rename_period_extensions | integer | How many times the open rename period was extended by 1 month. At most 2. |
created_at | string | |
updated_at | string | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden. FORBIDDEN when the key lacks apps:write, or its creator is a member or no longer in the organization. Deleting an app needs an owner or admin creator.
| Field | Type | Description |
|---|
error* | object | |
404Not found
| Field | Type | Description |
|---|
error* | object | |
Deletes the app and returns the deleted row. This is permanent. Do this only after confirming which links point at the app, since every link that does becomes a web-only link the moment the delete completes.
Link Subdomain
Every app has its own link subdomain, {link_handle}.aplnk.to, on every plan. An app that existed before subdomains keeps aplnk.to for good: links already on it keep working and keep opening your app, and aplnk.to stays one of the app's allowed link domains. Nothing moves on a date, and no link changes address unless you change it. An app created after the change uses its own subdomain (or a custom domain) for new links. Web-only links (links with no app, whether existing or created later in an org that has an app) default to the shared aplnk.to domain (a custom domain is also allowed). Registering an app does not add deep linking to them.
An app response carries these fields:
| Field | Type | Notes |
|---|
link_handle | string | The subdomain label. Writable on create, and on update as a rename |
link_domain | string | The full host, read only |
legacy_apex_enabled | boolean | True for an app that existed before subdomains. It never changes. Read only |
default_link_domain | string | The hostname a new link gets when the request names no domain. Change it with the link domain endpoint below |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. Read only |
allowed_link_domains | string[] | The hostnames a new link may use, the default first when it is available. Read only |
previous_link_handle | string or null | The old subdomain label while a rename period is open. Read only |
previous_link_domain | string or null | The full old host while a rename period is open. Read only |
rename_period_ends_at | date-time or null | The date shown for the rename period. The old host keeps working. Read only |
rename_period_extensions | integer, 0 to 2 | How many times the rename period was extended. Read only |
On create, leave link_handle out and we derive it from the app name, with the bundle ID or package name as the fallback. The handle is checked against every other app's subdomain.
Changing link_handle on update follows these rules:
- Send
expected_link_handle with every link_handle: the handle you last read. If the app's handle is another, the request gets 409 APP_LINK_STATE_CHANGED and nothing is written. Sending the current handle changes nothing except confirming the name once.
- An app whose subdomain no link and no redirect uses changes it in a plain edit on every plan, as often as you like. There is no
confirm and no rename period. The old name is held for 7 days: only your app can take it back before then. If a link starts using the subdomain first, the request gets 409 APP_LINK_STATE_CHANGED.
- Once a link or redirect uses the subdomain, one more rename is allowed. It needs
confirm: true and works on every plan, Free included. Links you already made stay on the old subdomain.
- Through an API key, the key needs a creator. A key with no creator gets
403 ATTRIBUTED_KEY_REQUIRED. A key whose creator has left the organization gets 403 FORBIDDEN.
- A rename that starts a period is refused with
409 RENAME_PERIOD_OPEN while an earlier rename period is open, including a rename back to the old name. details carries previous_link_handle and rename_period_ends_at.
- When links use the old host, send
confirm: true. Without it the request gets 409 CONFIRMATION_REQUIRED with links_on_old_host in details. After such a rename the old host keeps opening the app. If its links move to the new host, they keep their slug and their old URLs redirect.
- The slug
test is reserved on app subdomains: https://{link_domain}/test is the setup test page, so no link can use it there.
link_handle: null is a 400 VALIDATION_ERROR. Every app keeps a subdomain.
Through MCP, the update_app tool takes the same link_handle, expected_link_handle and confirm fields and follows the same rules. A call that needs the confirmation returns the link count and asks you to retry with confirm: true. Call it once without confirm. Tell the user that the old subdomain keeps opening the app, that if its links move to the new subdomain their old URLs redirect, and that app builds need the new subdomain. Set confirm only after they agree. The extend_subdomain_rename tool extends a rename period. The list_link_moves tool lists the links that still use the previous subdomain and set_link_move_choice sets the choice for one of them, like the endpoints below.
Choose the Default Link Domain
A link that names no domain gets the app's default_link_domain. An app that existed before subdomains starts with the domain of its most recent link, or aplnk.to when that domain cannot serve new links, and any member of the organization can change it to aplnk.to (legacy apps only), the app's own link_domain, or a live custom domain, and change it back at any time. Existing links keep their addresses and no redirect is created. Send the link_handle and default_link_domain you last read, and confirm: true. A change made meanwhile returns 409 APP_LINK_STATE_CHANGED. The app's allowed_link_domains lists the valid choices.
When the default is a custom domain that is no longer live, default_link_domain_available is false and POST /v1/links with no domain returns 409 DEFAULT_DOMAIN_UNAVAILABLE with details.allowed_link_domains. Name an allowed domain, or choose a new default. An explicit domain is never replaced.
Through MCP, the set_app_link_domain tool does the same and needs the apps:write scope.
PUT/apps/{id}/link-domain
Set an app's default link domain
Chooses the default domain for the app's new links: aplnk.to (legacy apps only), the app's own subdomain, or a live custom domain. Reversible. Existing links keep their addresses, and no redirect is created. Needs an API key that has a creator. Send the link_handle and default_link_domain you last read, so a change made meanwhile is never overwritten. A request already at the requested default returns the current app without a change.
Parameters
| Name | Type | In | Description |
|---|
id* | string | path | |
Request Body
| Field | Type | Description |
|---|
domain* | string | The hostname new links use by default. One of allowed_link_domains on the app. |
expected_link_handle* | string | The app's link_handle as you last read it. |
expected_default_link_domain* | string | The app's default_link_domain as you last read it. |
confirm | boolean | Must be true for a change. Without it a change is refused with 400 VALIDATION_ERROR. |
Responses
200The app with its new default
| Field | Type | Description |
|---|
id | string | |
org_id | string | |
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
icon_url | string | null | |
icon_source | "auto" | "manual" | |
probabilistic_matching | boolean | False means routing-only mode: the server no longer matches installs by probabilistic match or raw device signals for this app. Deep links keep working, and deferred links and attribution use only deterministic signals (the Android Play Install Referrer click id, and a device that already has an install). Defaults to true. |
link_handle | string | The lowercase subdomain label for this app's links, for example acme in acme.aplnk.to. Every app has one. |
link_domain | string | The full host derived from link_handle, for example acme.aplnk.to. |
legacy_apex_enabled | boolean | True for an app that existed when app subdomains launched. Its links on aplnk.to keep working and aplnk.to keeps listing the app for good. |
default_link_domain | string | The domain a new link gets when a create request names none: the saved default, or link_domain when none is saved. Change it with PUT /v1/apps/{id}/link-domain. It never changes a link that already exists. |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. A link created with no domain then gets 409 DEFAULT_DOMAIN_UNAVAILABLE until you choose a domain or a new default. |
allowed_link_domains | string[] | The hostnames a new link for this app may use: the default first when it is available, then aplnk.to (legacy apps only), link_domain and the org's live custom domains. |
previous_link_handle | string | null | The app's old subdomain label while a rename period is open. null otherwise. |
previous_link_domain | string | null | The full old host while a rename period is open, for example oldname.aplnk.to. It keeps opening the app. |
rename_period_ends_at | string | null | The date shown for the open rename period. It is not a deadline and not a promise to move links that day. If links on the old subdomain later move to the new one, their old URLs redirect. null when no period is open. |
rename_period_extensions | integer | How many times the open rename period was extended by 1 month. At most 2. |
created_at | string | |
updated_at | string | |
400VALIDATION_ERROR for an invalid app ID or body, a domain the app cannot use (fields.domain), or a change without confirm (fields.confirm).
| Field | Type | Description |
|---|
error* | object | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden. ATTRIBUTED_KEY_REQUIRED for a key with no creator.
| Field | Type | Description |
|---|
error* | object | |
404Not found
| Field | Type | Description |
|---|
error* | object | |
409APP_LINK_STATE_CHANGED when the link_handle or default_link_domain you sent is no longer current. Read the app and review the settings before you retry.
| Field | Type | Description |
|---|
error* | object | |
Extend a Rename
After a rename the old subdomain keeps working. The rename period has an end date. Each call adds 1 month to it, up to 2 times, and only while the period is open.
POST/apps/{id}/extend-subdomain-rename
Extend an app's rename period
Adds 1 month to the rename period's end date. The previous subdomain keeps working. Up to 2 times, and only before the period ends. Takes no body.
Parameters
| Name | Type | In | Description |
|---|
id* | string | path | |
Responses
200The app with its new rename period end
| Field | Type | Description |
|---|
id | string | |
org_id | string | |
name | string | |
ios_bundle_id | string | null | |
ios_team_id | string | null | |
ios_app_store_id | string | null | |
ios_app_store_url | string | null | |
ios_universal_link_paths | array | null | |
android_package_name | string | null | |
android_sha256_fingerprints | array | null | |
android_play_store_url | string | null | |
android_app_links_paths | array | null | |
icon_url | string | null | |
icon_source | "auto" | "manual" | |
probabilistic_matching | boolean | False means routing-only mode: the server no longer matches installs by probabilistic match or raw device signals for this app. Deep links keep working, and deferred links and attribution use only deterministic signals (the Android Play Install Referrer click id, and a device that already has an install). Defaults to true. |
link_handle | string | The lowercase subdomain label for this app's links, for example acme in acme.aplnk.to. Every app has one. |
link_domain | string | The full host derived from link_handle, for example acme.aplnk.to. |
legacy_apex_enabled | boolean | True for an app that existed when app subdomains launched. Its links on aplnk.to keep working and aplnk.to keeps listing the app for good. |
default_link_domain | string | The domain a new link gets when a create request names none: the saved default, or link_domain when none is saved. Change it with PUT /v1/apps/{id}/link-domain. It never changes a link that already exists. |
default_link_domain_available | boolean | False when the saved default is a custom domain that is no longer live. A link created with no domain then gets 409 DEFAULT_DOMAIN_UNAVAILABLE until you choose a domain or a new default. |
allowed_link_domains | string[] | The hostnames a new link for this app may use: the default first when it is available, then aplnk.to (legacy apps only), link_domain and the org's live custom domains. |
previous_link_handle | string | null | The app's old subdomain label while a rename period is open. null otherwise. |
previous_link_domain | string | null | The full old host while a rename period is open, for example oldname.aplnk.to. It keeps opening the app. |
rename_period_ends_at | string | null | The date shown for the open rename period. It is not a deadline and not a promise to move links that day. If links on the old subdomain later move to the new one, their old URLs redirect. null when no period is open. |
rename_period_extensions | integer | How many times the open rename period was extended by 1 month. At most 2. |
created_at | string | |
updated_at | string | |
400Invalid app ID
| Field | Type | Description |
|---|
error* | object | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden
| Field | Type | Description |
|---|
error* | object | |
404Not found
| Field | Type | Description |
|---|
error* | object | |
409RENAME_PERIOD_NOT_OPEN when the app has no open rename period, or it already ended. RENAME_EXTENSION_LIMIT when both extensions were used (details.limit is 2).
| Field | Type | Description |
|---|
error* | object | |
Link Moves
After a rename, the links still on the old subdomain can move to the new one, and each old URL then redirects with a permanent 301. The old address keeps working, and rename_period_ends_at is the date shown for the rename period. Links on aplnk.to never move.
During a rename period, a new link on the app's subdomain cannot take a slug that one of the app's old-host links still has. POST /v1/links returns 409 SLUG_TAKEN with the message "You already have yourapp.aplnk.to/abc, so this address is held for it. Pick another slug." and details.held_by with the old domain and slug. The dashboard and the MCP create_link tool refuse the same slug. A link whose slug was already taken on the target is a conflict. It stays where it is unless you choose a new slug for it, and you can also mark it to keep. Set the choice with PUT /v1/links/{id}/move-choice (see Links).
GET/apps/{id}/link-moves
List an app's link moves
Shows the links that still use an app's previous subdomain, and which cannot keep their slug on the app's current subdomain because it is taken there. Conflicts come first. If a link later moves, its old URL answers a permanent redirect. An app with no previous subdomain in use returns an empty moves list. Needs the links:read scope.
Parameters
| Name | Type | In | Description |
|---|
id* | string | path | |
limit | integer | query | Links per move. |
offset | integer | query | Links to skip in each move. |
Responses
200The app's pending moves
| Field | Type | Description |
|---|
moves | object[] | |
400Invalid app ID or page
| Field | Type | Description |
|---|
error* | object | |
401Unauthorized
| Field | Type | Description |
|---|
error* | object | |
403Forbidden
| Field | Type | Description |
|---|
error* | object | |
404Not found
| Field | Type | Description |
|---|
error* | object | |
status is scheduled (a move is planned, with moves_at set when we have a date) or moving (the move is running).
Worked Example
Register an app with both platforms, add an Android app link path later, then remove it once it's decommissioned:
curl -X POST https://api.warplink.app/v1/apps \
-H "Authorization: Bearer wl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "WarpLink Demo",
"ios_bundle_id": "app.warplink.demo",
"ios_team_id": "ABCDE12345",
"android_package_name": "app.warplink.demo",
"android_sha256_fingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
}'
{
"id": "9d4f1a20-...",
"org_id": "3a9d1e20-...",
"name": "WarpLink Demo",
"ios_bundle_id": "app.warplink.demo",
"ios_team_id": "ABCDE12345",
"ios_app_store_id": null,
"ios_app_store_url": null,
"ios_universal_link_paths": null,
"android_package_name": "app.warplink.demo",
"android_sha256_fingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"],
"android_play_store_url": null,
"android_app_links_paths": null,
"icon_url": null,
"icon_source": "auto",
"probabilistic_matching": true,
"link_handle": "warplink-demo",
"link_domain": "warplink-demo.aplnk.to",
"legacy_apex_enabled": false,
"default_link_domain": "warplink-demo.aplnk.to",
"default_link_domain_available": true,
"allowed_link_domains": ["warplink-demo.aplnk.to"],
"previous_link_handle": null,
"previous_link_domain": null,
"rename_period_ends_at": null,
"rename_period_extensions": 0,
"created_at": "2026-09-16T12:00:00.000Z",
"updated_at": "2026-09-16T12:00:00.000Z"
}
curl -X PATCH https://api.warplink.app/v1/apps/9d4f1a20-... \
-H "Authorization: Bearer wl_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "android_app_links_paths": ["/products/*", "/offers/*"] }'
curl -X DELETE https://api.warplink.app/v1/apps/9d4f1a20-... \
-H "Authorization: Bearer wl_live_YOUR_API_KEY"
Common Errors
| Status | Code | Cause |
|---|
| 400 | VALIDATION_ERROR | A field fails its format check, or neither platform is configured after the change |
| 400 | INVALID_ID | The id path parameter isn't a valid UUID |
| 400 | IMMUTABLE_FIELD | The update body includes org_id |
| 403 | FORBIDDEN | The key lacks apps:read/apps:write, or its creator's role lacks the matching permission |
| 403 | APP_LIMIT_EXCEEDED | The organization's plan app limit is already reached |
| 403 | ATTRIBUTED_KEY_REQUIRED | A rename through an API key with no creator |
| 404 | NOT_FOUND | No app with that ID exists in this organization |
| 409 | DUPLICATE_APP | The ios_bundle_id or android_package_name already belongs to another app in this org |
| 409 | APP_HANDLE_TAKEN | The link_handle already belongs to another app |
| 409 | APP_HANDLE_HELD | The link_handle was just released and is held for 7 days. details.available_at is the instant it becomes available |
| 409 | RENAME_PERIOD_OPEN | A rename period is already open for this app |
| 409 | RENAME_PERIOD_NOT_OPEN | A rename extension was requested with no open period |
| 409 | RENAME_EXTENSION_LIMIT | The app used both rename extensions |
| 409 | CONFIRMATION_REQUIRED | Links use the old host and confirm is not true |
FAQ
Can an app support both iOS and Android?
Yes, and it's the common case. Set ios_bundle_id and/or android_package_name on the same app row; a React Native or Flutter app typically registers both.
What happens to links when their app is deleted?
They aren't deleted. Their app_id clears to null, and the link keeps working as a web-only link, with its click history intact. It just loses deep linking until it's pointed at a different app.
Do I need to publish apple-app-site-association or assetlinks.json myself?
No. Both files regenerate automatically whenever an app's identifiers, fingerprints, or link paths change, and they're served from your domain without a separate deploy step.
Why is icon_url still null right after creating an app?
Icon resolution runs asynchronously after the create response is sent. It typically completes within moments if the App Store ID or Play package name resolves to a public listing; poll the app with GET /v1/apps/{id} if you need to confirm it landed.
How do I turn off probabilistic matching for an app?
Send PATCH /v1/apps/{id} with { "probabilistic_matching": false }, or use the Install matching switch in the app's settings. Links keep redirecting and opening your app. Installs are then matched only by deterministic signals. See Routing-only mode.
Can a member role update app configuration?
Yes. Every role can read, create, and update apps, including the link address and the default link domain. Deleting an app requires the apps:delete permission, which only owner and admin roles hold.