## Sign in to an app from the desktop

`POST /v1/cowork/app-login`

Gives the Cowork/Code desktop app a sign-in link to a deployed app, so the built-in browser opens the app already signed in as the key owner, without a sign-in form.

The platform decides access itself: the app must belong to the Bitrix24 account the key is bound to, and the app's access policy must admit the key owner. The gateway exchanges the token in the address for its own cookie on the app subdomain and redirects to the address without the token. The app receives the user's Bitrix24 ID, as with a regular sign-in. The token opens only this app: it opens neither the platform dashboard nor other apps.

A sign-in link is issued for an app on a dedicated server and for a [Galaxy app](/docs/infra/galaxy): both go through the same access check. The shared galaxy host is not an app, and a request with its address gets `404 APP_NOT_AVAILABLE`.

Only a Cowork/Code desktop key with the `vibe:cowork` scope can call this endpoint. Agent seat keys and third-party agent keys carry the same scope but receive `403 COWORK_DESKTOP_KEY_REQUIRED`.

**Scope:** `vibe:cowork`. The key must also be a Cowork/Code desktop key.

## Request fields (body)

| Field | Type | Required | Description |
|------|-----|-------|----------|
| `appUrl` | string | yes | Address of the app to open, with any path, query parameters and fragment. The scheme, host and port must match the app address exactly. User info in the address and the `__gw_token` parameter are not allowed |

## Examples

### curl

```bash
curl -X POST https://vibecode.bitrix24.com/v1/cowork/app-login \
  -H "X-Api-Key: YOUR_COWORK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"appUrl": "https://app-3f9c2a1b7e.vibecode.bitrix24.com/orders?status=new"}'
```

### JavaScript — get the sign-in address and check it

```javascript
// An app address: https, an app-* subdomain of the app domain, no port, user info, or gateway token
function isAppAddress(appUrl) {
  try {
    const u = new URL(appUrl)
    return u.protocol === 'https:' && !u.port && !u.username && !u.password
      && !u.searchParams.has('__gw_token')
      && /^app-[a-z0-9-]+\.vibecode\.bitrix24\.com$/.test(u.hostname)
  } catch {
    return false
  }
}

async function getAppLoginUrl(appUrl) {
  // Not an app address: do not call the endpoint and do not open the address
  if (!isAppAddress(appUrl)) return null
  const res = await fetch('https://vibecode.bitrix24.com/v1/cowork/app-login', {
    method: 'POST',
    headers: {
      'X-Api-Key': 'YOUR_COWORK_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ appUrl }),
  })
  if (!res.ok) {
    const { error } = await res.json().catch(() => ({}))
    // The platform did not recognize the address as an app: do not open it at all
    if (error?.code === 'INVALID_APP_URL') return null
    return appUrl // any other refusal: the address was checked above, open it with the regular sign-in
  }

  const { url } = await res.json() // { url, expiresIn } — no success/data wrapper
  // The response differs from the request by exactly one token parameter
  const stripped = url.replace(/[?&]__gw_token=[^&#]*/, '')
  return stripped === appUrl ? url : null // a difference remains: open nothing
}
```

## Response fields

| Field | Type | Description |
|------|-----|----------|
| `url` | string | The original `appUrl` with one added `__gw_token` parameter. The parameter is the last one in the query string, before the fragment. Do not parse the token and do not write it to logs |
| `expiresIn` | number | Token lifetime in seconds. The platform takes it from the signed token itself |

## Response example

```json
{
  "url": "https://app-3f9c2a1b7e.vibecode.bitrix24.com/orders?status=new&__gw_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXAiOiJndyJ9.c2lnbmF0dXJl",
  "expiresIn": 600
}
```

## Error response example

404 — the app is not available:

```json
{
  "success": false,
  "error": {
    "code": "APP_NOT_AVAILABLE",
    "message": "The app is not available."
  }
}
```

## Errors

| HTTP | Code | Description |
|------|-----|----------|
| 400 | `INVALID_APP_URL` | The `appUrl` field is missing, is not a string, or is not the exact address of an app: a different scheme or port, an extra subdomain level, user info, control characters, or an existing `__gw_token` parameter. Retrying with the same body will not help |
| 401 | `MISSING_API_KEY` | The `X-Api-Key` header is missing |
| 401 | `INVALID_API_KEY` | The key is not recognized — no such key exists on the platform |
| 401 | `KEY_INACTIVE` | The key has been revoked, for example when the device was signed out with [`DELETE /v1/cowork/key`](/docs/cowork/key) |
| 401 | `KEY_EXPIRED` | The key has expired |
| 402 | `ACCOUNT_FROZEN` | The account balance is exhausted — top up to continue |
| 403 | `INSUFFICIENT_SCOPE` | The key lacks the `vibe:cowork` scope |
| 403 | `COWORK_DESKTOP_KEY_REQUIRED` | The key does not belong to the Cowork/Code desktop class. This response applies to both agent seat keys and third-party agent keys |
| 403 | `PORTAL_REQUIRED` | The key is not bound to a person on a portal |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | The key was issued in read-only mode |
| 404 | `APP_NOT_AVAILABLE` | One answer for every access outcome: the app does not exist, belongs to another account, its access policy does not admit the key owner, or the platform could not decide |
| 409 | `B24_USER_UNKNOWN` | The key owner's user ID in Bitrix24 could not be determined, so there is no identity to vouch for |
| 413 | `PAYLOAD_TOO_LARGE` | The body is larger than 8 KB |
| 415 | `FST_ERR_CTP_INVALID_MEDIA_TYPE` | The request body uses a content type this route does not parse. Send the body with the `Content-Type: application/json` header |
| 429 | `RATE_LIMITED` | The rate limit for the account and person combination has been exceeded: several keys of one person share the same limit. The platform-wide limit is 30 requests per minute. The effective limit for your key is returned in the `x-ratelimit-limit` header. It is lower than the platform-wide limit because that limit is divided across replicas |
| 503 | `user_self_deletion_pending` | The key owner is pending account deletion on this Bitrix24 account. The request is refused before a token is issued; retry after the delay in `Retry-After` |

Full list of common API errors: [Errors](/docs/errors).

## Known specifics

**A successful response (200) is the object itself, with no `success` wrapper.** Errors come in the `{ success: false, error: { code, message } }` envelope. Determine success by the HTTP status (`res.ok`).

**The response address matches the request byte for byte, except for the token parameter.** The platform does not rebuild the address: parameter encoding, empty values and the fragment stay as they are in `appUrl`. Compare the response with the request after removing the `__gw_token` parameter, and do not open the address if any difference remains. After exchanging the token for a cookie, the gateway redirects to the address without the token and rebuilds the query string, so the parameter order and the way empty values are written may differ from `appUrl` by the time the app receives it.

**The token stays valid until it expires, not for a single use.** Following the same address again within `expiresIn` signs the user in to the app again. After the first visit, the gateway sets a cookie and redirects to the address without the token, so later visits to the same app use the cookie, with no new call.

**Revoking the key stops new sign-ins from being issued.** A cookie the gateway has already set lives out its lifetime. The gateway rechecks the app's access policy itself, so revoking access to the app also applies to such a cookie.

**How to react to refusals.** After `400 INVALID_APP_URL` do not open the address at all: the platform did not recognize it as an app address, so show the user an error. Any other refusal means the app should be opened at the original address with the regular sign-in, but only if the address passed your own check before the request: `https`, an `app-*` subdomain of the app domain, no port, user info, or `__gw_token` parameter. A `401` or `403` arrives before the address is parsed and does not confirm that it is an app address. `401`: ask the user to sign in to the desktop app again. `400` and `403`: a client error, so retrying is pointless. `429`: retry with a growing delay.

## See also

- [Cowork/Code](/docs/cowork)
- [Sign this device out](/docs/cowork/key)
- [Errors](/docs/errors)
