# Installs and webhooks

An agent that brings a manifest can show "connected to Places, manage it in Wire" on a return visit without storing anyone's API key. When a user connects, your agent gets two ids, `installId` and `agentUserId`. Keep those. Later, your server asks Wire about them with a runtime key that only your agent holds, and Wire can also tell your server when an install changes.

This page covers the ids, the runtime key, the agent API endpoints, install webhooks, how your users get a new version of your manifest, and how trials (ephemeral containers) show up in both.

:::note[Renamed from the app API]
Until SDK 0.10.0 this was the "app API": paths under `/api/v1/apps/{appId}`, the JWT audience `wire-app-api`, the field `appUserId`, and the SDK's `WireAppClient`. Those old names still work for now, and the old paths answer with a `Deprecation` header. Move to the names on this page.
:::

## Keep the ids, not the key

Every connection result includes `installId` and `agentUserId`. Neither grants access to anything, so they are safe to store in your own database next to your user record.

| Id | Format | One per | Notes |
|---|---|---|---|
| `installId` | `ins_…` | agent, user, and container | Stays the same when the same user reconnects your agent to the same container |
| `agentUserId` | `au_…` | agent and user | Pairwise: another agent gets a different id for the same person. It never reveals the user's Wire account. `null` while the user is on a trial with no Wire account, and set when they claim the container |

The device flow's poll (`GET /api/v1/sdk/poll`) returns them as `install_id` and `agent_user_id`, and so does the `connection` object of the browser flow's token response. `GET /api/v1/sdk/status` returns them under `connection`. Responses still carry the older `app_user_id` beside `agent_user_id`, with the same value.

A typical pattern: store `agentUserId` on your user when the connect completes, then list that user's installs from your server whenever you render a settings or manage page. Store `installId` too if your agent deals with one container per user.

## Add a runtime key

The agent API authenticates your server with an Ed25519 key registered for your agent with `purpose: "runtime"`. It is a separate key from the publisher key that registers your manifest, and neither is accepted in place of the other: the key a live server holds can read, export, and uninstall your agent's own installs and nothing else.

An org member who can update your agent's owner organization adds it:

```bash
curl -X POST https://app.usewire.io/api/v1/agents/someday/publisher-keys \
  -H 'content-type: application/json' --cookie "$WIRE_SESSION" \
  -d '{ "publicKey": "<Ed25519 public key, raw 32 bytes, base64url>", "label": "production server", "purpose": "runtime" }'
```

The response names the key's id (`pk_…`). Keep the private half as a server secret. Revoke a key with `DELETE /api/v1/agents/{agentId}/publisher-keys/{keyId}`.

## Sign each request

Every agent API request carries `Authorization: Bearer <JWT>`, a JWT your server signs with the runtime key for that one request.

| Part | Value |
|---|---|
| Header | `alg: "EdDSA"`, `kid`: the runtime key's id |
| `iss` | Your agent id, for example `someday` |
| `aud` | `wire-agent-api` (the older `wire-app-api` is still accepted) |
| `iat`, `exp` | Seconds. `exp` at most 60 seconds after `iat` |
| `jti` | A random id, at least 8 characters. Each is accepted once |
| `body_sha256` | `POST`, `PUT`, `PATCH`, and `DELETE`: base64url (no padding) SHA-256 of the exact request body, which for an empty body is `47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU` and for `{}` is `RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o`. Not sent on `GET` |

A token signed with a publish key, for another audience, with a reused `jti`, naming another agent in `iss`, or with a `body_sha256` that does not match the body is refused with `401`.

## The agent API

The agent API has five endpoints, all under `https://app.usewire.io/api/v1/agents/{agentId}`, and each only ever sees your own agent's installs. A path naming another agent, another agent's `installId`, or an `agentUserId` your agent does not know all answer `404`, never `403`.

| Endpoint | What it does |
|---|---|
| `GET /installs/{installId}` | One install |
| `GET /users/{agentUserId}/installs` | Every install that user has with your agent, newest first, as `{ "installs": [ … ] }` |
| `DELETE /installs/{installId}` | Uninstalls your agent from that install's container, then returns the install as it now reads |
| `POST /installs/{installId}/export` | Asks Wire to export the install's container for the person who connected it. See [Exporting an install's container](#exporting-an-installs-container) |
| `GET /installs/{installId}/exports/{exportId}` | The status of that export |

Responses use the same envelope as the rest of the API: `{ "success": true, "data": … }`, or `{ "success": false, "error": { "code", "message" } }`.

### The install shape

```json
{
  "installId": "ins_4f0c9k2m1q8r7t6v5w3x2y1z",
  "agentUserId": "au_9a8b7c6d5e4f3g2h1j0k9m8n",
  "container": {
    "id": "b7d7c7f0-…",
    "name": "Places",
    "mcpEndpoint": "https://jit.mcp.usewire.io/container/b7d7c7f0-…/mcp",
    "orgSlug": "jit",
    "isEphemeral": false,
    "ephemeralExpiresAt": null
  },
  "claimed": true,
  "connection": {
    "status": "active",
    "connectedAt": "2026-09-27T18:04:28.000Z",
    "lastUsedAt": "2026-09-27T19:12:03.000Z"
  },
  "installedVersion": "0.3.0",
  "latestVersion": "0.3.0",
  "updateAvailable": false,
  "manageUrl": "https://app.usewire.io/containers/b7d7c7f0-…/connections#installed-agents"
}
```

- **`connection.status`** is `active` while any of the install's connections is live, and `revoked` otherwise. A revoked install stays readable and carries `reason`: `user_disconnected`, `agent_disconnected`, `uninstalled`, `container_deleted`, or `expired`. The old `/api/v1/apps` paths still answer `app_disconnected` for the same case. A container in the trash reads as revoked with `container_deleted` until it is restored.
- **`lastUsedAt`** is the last time the install's credential was used, or `null`.
- **`manageUrl`** opens the container's installed agents in the Wire dashboard, where the user can disconnect or uninstall your agent. Link to it from your own manage screen.
- **`installedVersion`**, **`latestVersion`**, and **`updateAvailable`** say which version of your manifest the container runs and whether a newer one is waiting for approval. **`upgradeUrl`** appears only while `updateAvailable` is `true`. See [Updates](#updates).
- **`claimUrl`** appears only while the install is active and its container is an unclaimed trial. See [Trials](#trials).

The install never includes an API key, the container's contents, the user's Wire account id, or their email.

### Uninstalling from your server

`DELETE /installs/{installId}` does exactly what the container owner's **Uninstall** button does. Your agent's connections to that container end, the container is released (Wire's built-in tools and the analysis graphs go back to their defaults, and the owner's settings unlock), and the container's data stays. The container's activity feed records the uninstall as your agent's. The response is the install, now `revoked` with reason `uninstalled`.

Uninstalling is per container, so it also ends other users' connections of your agent to that same container. A `502` means the connections were ended but the container could not finish the uninstall yet; send the same request again.

Disconnecting without uninstalling stays a dashboard action.

### Exporting an install's container

Your agent can ask Wire to export the container of one of its installs, so a person can leave with their data from your own screens. The export is the same one the container's owner gets from the dashboard: an archive of their files, records, links, and settings.

`POST /installs/{installId}/export` with the body `{}`. The request is signed like any other, with `body_sha256` set to the hash of `{}`.

```json
{
  "success": true,
  "data": {
    "exportId": "V1StGXR8_Z5jdHi6B-myT",
    "status": "queued",
    "createdAt": "2026-10-02T15:04:05.000Z",
    "reused": false
  }
}
```

The response is `202`. Wire builds the archive in the background, then emails the person who connected your agent a link to download it. Your agent never gets the archive or the link, and downloading it needs that person's Wire sign-in. The export runs as that person, so it covers what they can see in the container.

Read its progress with `GET /installs/{installId}/exports/{exportId}`:

```json
{
  "success": true,
  "data": {
    "exportId": "V1StGXR8_Z5jdHi6B-myT",
    "status": "completed",
    "createdAt": "2026-10-02T15:04:05.000Z",
    "completedAt": "2026-10-02T15:04:41.000Z",
    "expiresAt": "2026-10-09T15:04:41.000Z"
  }
}
```

`status` is `queued`, `running`, `completed`, `failed`, or `expired`. The download link works until `expiresAt`, 7 days after the export completes. A `completed` export means the email has gone out.

The same limits apply as in the dashboard, per container and whoever asks:

- **One new archive a day.** If the container's last export started less than 24 hours ago, you get that export back with `reused: true` instead of a new one.
- **Five a month.** At five new archives in a calendar month (UTC, failed exports not counted), the request answers `429` with a `Retry-After` header and `{ "success": false, "error": { "code": "EXPORT_LIMIT", "message", "retryAfter" } }`, where `retryAfter` is when the container can be exported again.

The install has to be active: an install whose connections have all ended answers `409` with `AGENT_DISCONNECTED`. A trial container answers `409` with `INSTALL_IS_TRIAL`, because there is no account to send the link to until the person claims it. If the person who connected your agent can no longer see the container, the answer is `403`. An export id that is not one of this install's container's exports answers `404`.

## Install webhooks

Wire can POST a signed event to your server whenever one of your installs changes, so your records stay right between visits. Register the URL and the events on your agent record:

```bash
curl -X PATCH https://app.usewire.io/api/v1/agents/someday \
  -H 'content-type: application/json' --cookie "$WIRE_SESSION" \
  -d '{ "webhooks": { "url": "https://someday.example/webhooks/wire", "events": ["install.created", "install.uninstalled", "install.disconnected", "install.container_deleted", "install.claimed", "install.expiring"] } }'
```

The URL follows the same rules as an action URL: https only, no credentials in it, no private or loopback address, and not a Wire domain. Send `"webhooks": null` to stop them. Changing webhooks needs the same permission as adding a key.

### Events

| Event | Sent when |
|---|---|
| `install.created` | A connect made the install active: the first connect, or a reconnect after it was revoked |
| `install.upgraded` | An active install moved to a newer version of your manifest: someone approved it, through your connect flow or in the Wire dashboard, or your agent is verified and the update was applied on its own |
| `install.disconnected` | The install's last live connection ended: the user disconnected it, or you rotated your agent's credentials |
| `install.uninstalled` | Your agent was uninstalled from the container, by its owner or by your own `DELETE` |
| `install.claimed` | The install's trial container was claimed. `install.agentUserId` is now set |
| `install.expiring` | About a day before a trial container expires, once per install |
| `install.expired` | A trial container expired and is being deleted |
| `install.container_deleted` | The container was permanently deleted |

After `install.expired` and `install.container_deleted` the install is gone, and reading it answers `404`.

### Payload

The body is JSON with four fields. `install` is the same shape the agent API returns, including the version fields, as it was when the event happened; for `install.expired` and `install.container_deleted` its container `name` and `mcpEndpoint` are `null`, and so is `installedVersion`. For now it also carries `appUserId`, the deprecated name for `agentUserId`, with the same value. Read `agentUserId`.

```json
{
  "id": "evt_5b1e0c2a9f8d7e6c5b4a39281706f5e4",
  "type": "install.uninstalled",
  "createdAt": "2026-09-27T20:11:52.000Z",
  "install": { "installId": "ins_…", "connection": { "status": "revoked", "reason": "uninstalled", "…": "…" }, "…": "…" }
}
```

The request also carries `X-Wire-Event-Id` and `X-Wire-Event-Type`, and `User-Agent: Wire-Webhooks/1`.

### Verify the signature

Each request carries `Authorization: Bearer <JWT>`, signed by Wire with the same published keys as action calls: `https://app.usewire.io/.well-known/wire-actions-jwks.json`. Verify it before you act on the body:

1. The header is `alg: "EdDSA"`, `typ: "wire-webhook+jwt"`, and a `kid` from the key set. An action token (`typ: "wire-action+jwt"`) is not a webhook.
2. `iss` is `wire`, `aud` is your agent id, and the token is within its 60-second lifetime.
3. `wire_url` is the URL you registered, and `wire_event` equals the body's `id`.
4. `wire_body_sha256` is the base64url (no padding) SHA-256 of the raw body bytes you received. Hash before you parse.

The SDK's `verifyWireWebhook` (in `@usewire/sdk/app` from 0.9.0) does all of this for you, and from 0.10.0 it reads either field name.

### Retries and duplicates

Delivery is at-least-once, so your handler must treat a repeated event id as already handled. Answer any `2xx` once you have the event; the response body is ignored.

Anything else is retried for about 24 hours: a non-`2xx` status, a redirect (redirects are never followed), a timeout (8 seconds), or a network error. Each round is one attempt and one quick retry, and the waits between rounds grow from 1 minute to 8 hours. After the last round the event is dropped. Answer `410 Gone` to stop retries of an event at once.

An event keeps its id on every retry, and if Wire notices the same change twice (a retried disconnect, say) it sends it once, under the same id.

## Updates

Registering a new version of your manifest changes no container by itself. For an agent that isn't verified, each install keeps the version its user approved until a person approves the new one, either through your connect flow or in the Wire dashboard. For a verified agent, an update that needs no new permissions is applied the next time the person uses your agent, and one that needs more waits for their approval (see [Updates for a verified agent](#updates-for-a-verified-agent)). Every install you read, and every install in a webhook, says which version it runs and whether a newer one is waiting, so your agent can offer the update on its own screens.

| Field | Type | What it says |
|---|---|---|
| `installedVersion` | string or `null` | The version of your manifest the install's container runs. `null` when your agent has no manifest, the install is gone, or Wire can't tell |
| `latestVersion` | string or `null` | The newest version you have registered. `null` when your agent has no manifest |
| `updateAvailable` | boolean | `true` when the install is active and runs an older version than `latestVersion` |
| `upgradeUrl` | string | Present only when `updateAvailable` is `true`. Opens the review screen for this install in the Wire dashboard |

An install that is behind reads like this:

```json
{
  "installId": "ins_4f0c9k2m1q8r7t6v5w3x2y1z",
  "agentUserId": "au_9a8b7c6d5e4f3g2h1j0k9m8n",
  "container": { "id": "b7d7c7f0-…", "name": "Places", "…": "…" },
  "claimed": true,
  "connection": { "status": "active", "connectedAt": "2026-09-27T18:04:28.000Z", "lastUsedAt": "2026-09-28T09:40:17.000Z" },
  "installedVersion": "0.1.0",
  "latestVersion": "0.3.0",
  "updateAvailable": true,
  "manageUrl": "https://app.usewire.io/containers/b7d7c7f0-…/connections#installed-agents",
  "upgradeUrl": "https://app.usewire.io/containers/b7d7c7f0-…/connections?upgrade=someday#installed-agents"
}
```

A disconnected or uninstalled install never reads `updateAvailable: true`. Connecting your agent again installs the version you have registered, once the person approves it on the connect screen.

### Send people to `upgradeUrl`

`upgradeUrl` works like `manageUrl`: it opens the container in the Wire dashboard, signing the person in first if they need to, and goes straight to the review for your agent. There they see what the new version installs and what it changes from the version they have, and they approve it or leave it for later. Nothing about the approval happens on your site. Who can approve is the same as for your connect flow; see [Updating an agent](/guides/connecting-agents/#updating-an-agent).

On a return visit, a line like this is enough, with the last part linking to `upgradeUrl`:

> Connected to Places · Someday 0.1.0 · Update to 0.3.0

Show the update link only while `updateAvailable` is `true`, and read the install again rather than caching the answer: the update can be approved in the Wire dashboard without anyone visiting your agent.

### Updates for a verified agent

A verified agent's update is applied without a review when it asks for nothing its user hasn't already agreed to. It is applied the next time the person uses your agent: when a client they connected through your agent's URL uses the container, usually within about ten minutes, or when they connect another client to it. It is applied as that person, and the container's activity feed records it as automatic. An install nobody is using keeps its version: nothing is pushed to a container that isn't in use. An install reached only with a key your own software holds is updated when the person connects again, or approves the update in the Wire dashboard.

The update waits for approval when the new version does any of the following, compared with the version the container has installed:

| Change in the new manifest | Asks |
|---|---|
| An action's URL is on a host no installed action used | Yes, unless the host is under your verified domain |
| A view's `csp` list gains a domain it did not have in that same list | Yes, unless the domain is under your verified domain |
| Container records can leave for the first time: your first tool with an `after` action that receives the base tool's output, or your first view with any `csp` domain while a tool of yours reads | Yes, always, including under your verified domain |
| A tool's `after` action, or any `csp` domain of a view, can receive container records on a host that received none before | Yes, unless the host is under your verified domain |
| An `after` action is given more of the base tool's output than any action on that host was before (a new `{{tool.…}}` path) | Yes |
| Your enabled tools and `base_tools` together gain a capability: read, SQL read, write, delete, calling your server, your server setting a tool's arguments, or sending records to your server | Yes |
| `base_tools` gains a built-in tool | Yes |
| `builtin_tools` turns a built-in on or off, on any transport, compared with the installed version | Yes |
| A tool that writes, deletes, or sends records to your server becomes callable from a view: you ship your first `ui` entry, or the tool's `ui.visibility` gains `app` | Yes. Mark such a tool `visibility: ["model"]` if a view should not call it |
| A tool's `annotations` stop marking it destructive or open-world, or start marking it read-only | Yes |
| A view's `permissions` gains one | Yes |
| `analysis` turns a graph on | Yes |
| `app.name` changes | Yes |
| A new path, a new port, or a new action on a host already in use | No |
| An action's `previous_urls` gains or loses an address | No. A previous address is not somewhere the new version sends anything |
| Tools added, changed, or removed within the capabilities you already had, including a tool's SQL, description, and schema | No |
| `objects`, `instructions`, `skill`, and a view's `html` | No |
| Anything else removed, turned off, or lowered | No |

Hosts are compared, not URLs. Your verified domain is the registrable domain of your agent's own hostname: an agent served on `mcp.example.com` is trusted on `example.com` and every name under it. On a shared hosting domain, where each customer has its own name, only your own name and the names under it are trusted. An agent with no hostname of its own has no verified domain.

A manifest Wire can't compare with the installed one is treated as asking. While an update waits, the install reads `updateAvailable: true` with an `upgradeUrl`, exactly as for an unverified agent, so the same "Update to 0.3.0" link applies.

Keep permission changes and everything else in separate versions when you can. A version that only fixes a tool reaches a user the next time they use your agent. One that also adds a host waits for each of them.

### Moving an action to a new address

An install calls the `url` of the manifest version it has installed, and keeps that version until the person updates. Wire signs a call only to an address your current manifest declares for that action. So a version that changes an action's `url` and nothing else breaks that tool for every install still on the earlier version, until each person updates.

To move without breaking anyone, treat the address like a key you are rotating: declare both, then retire the old one.

1. **Declare both.** In the version that changes `url`, list the old address in the action's `previous_urls`:

   ```json
   {
     "name": "geocode",
     "url": "https://new.example.com/geocode",
     "previous_urls": ["https://old.example.com/geocode"]
   }
   ```

2. **Keep serving both.** Installs on the earlier version go on calling the old address, and Wire goes on signing for it. Installs that update call the new one.
3. **Watch.** When your old endpoint has stopped receiving calls, nobody is left on the earlier version. `installedVersion` on an install tells you which version it runs.
4. **Retire.** Register a version without the old address. From then on Wire refuses to sign a call to it. Do this before you give up the old host, so an address you no longer control is never one Wire signs for.

The rules:

- An action can keep up to three previous addresses. Each is an https URL under the same rules as `url`, and none can equal `url`.
- Declare the old address in the same version that changes `url`. Wire accepts a previous address only if the action had it, as its `url` or as a previous address, in the version being replaced. A first version can't declare one, and an address you retired can't be declared again. If you need it back, make it the `url` again.
- Addresses are kept per action name. A renamed or removed action is not covered: installs on the earlier version fail it. If you want them to keep working, keep the action declared until you retire it.
- Wire never calls a previous address for an install on the version that declares it. It isn't shown on the connect screen and isn't something a person is asked to approve. Moving to a host people haven't approved is still an update they are asked about, as in the table above.

If you move or remove an action without declaring its old address, the registration still succeeds and its answer carries a warning with the code `ACTION_ADDRESS_DROPPED`, naming the actions. `registerManifest()` returns it in `warnings` from SDK 0.18.0. To go back, register a version that declares the action at its old address again, with the new one in `previous_urls` so installs that already updated keep working, then move again with both declared.

### What your agent sees after an update

Your agent keeps its connection. It doesn't reconnect or get a new key, and from the moment the update is applied its access follows the new version's tools. You get `install.upgraded`, whether the update was approved through your connect flow or in the dashboard or applied on its own, and the install then reads the new `installedVersion` with `updateAvailable: false`. The container's activity feed records the update.

SDK 0.13.0 exposes `installedVersion`, `latestVersion`, `updateAvailable`, and `upgradeUrl` on `WireInstall`.

## Trials

A trial is a connect by someone with no Wire account: they get an ephemeral container that lasts 7 days, with the same tools and endpoint. Its install has an `installId` right away and `agentUserId: null`, and reads `container.isEphemeral: true` with `container.ephemeralExpiresAt` and `claimed: false`.

While the trial is active, the install also carries `claimUrl`, a link where the person creates an account and keeps the container. The link works until the trial expires, so it can go in an email. It stops working if your agent is disconnected from or uninstalled from the container first.

An agent that offers trials usually subscribes to two events:

- **`install.expiring`**, about a day before the container goes, to remind the person, with `install.claimUrl` in the payload.
- **`install.claimed`**, when they keep it. The payload's `install.agentUserId` is now set: store it on your user, as you would after a normal connect.

If they don't claim it, `install.expired` follows, and the container and its data are deleted.