# Signed resource URL

`POST /v1/infra/servers/:id/access-tokens/:tokenId/resource-url`

Issues a `GET`/`HEAD` URL for one resource of a private application: CSS, an image or a font. It works without a browser Gateway session and does not open other application paths. Anyone possessing the URL can read the selected resource until expiry or parent token revocation. Sign only content you intend to distribute to widget recipients.

Requires the key that manages the server and an existing `api-bearer` token created by that key through [Mint a token](./create.md), including the required audience approval. No new token record is created; the parent's lifetime and approved audience are not extended. Development-team, external-collaborator and Cowork keys cannot call this operation. Check section [availability](/docs/infra/access-tokens#availability) before calling.

Known link-preview fetchers receive a local card for a public application or a neutral page for a private application; the Gateway does not proxy their request through the tunnel even when it carries a resource URL. A resource URL does not override this policy.

## Parameters

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | string | yes | Server ID from [List servers](/docs/infra/servers/list) |
| `tokenId` | path | string | yes | `data.id` of an existing [minted `api-bearer` token](./create.md) |

## Request body

| Field | Type | Required | Description |
|---|---|---|---|
| `path` | string | yes | Absolute ASCII path of 2–1024 characters, for example `/widget/style.css`. The root `/` is not allowed. Allowed characters: letters, digits, `/`, `.`, `_`, `~`, `-`. No query, fragment, `%`, `//` or `.`/`..` segments. `/robots.txt`, `/_gw`, `/tunnel` and every path inside `/_gw/` or `/tunnel/` are reserved |
| `ttlSeconds` | integer | no | 1–2592000 seconds, default 86400. Actual expiry is capped by the parent |

Extra body fields are rejected with `400 INVALID_RESOURCE_REQUEST`.

## Examples

Run on your application server. Do not send the key or user session to the browser.

### curl — personal key

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/access-tokens/TOKEN_ID/resource-url" \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path":"/widget/style.css","ttlSeconds":86400}'
```

### curl — OAuth application

```bash
curl -X POST "https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/access-tokens/TOKEN_ID/resource-url" \
  -H "X-Api-Key: YOUR_APP_KEY" \
  -H "Authorization: Bearer USER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"path":"/widget/style.css","ttlSeconds":86400}'
```

### JavaScript — personal key

```javascript
const res = await fetch('https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/access-tokens/TOKEN_ID/resource-url', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ path: '/widget/style.css', ttlSeconds: 86400 }),
})
const result = await res.json()
if (!res.ok || result.success !== true) {
  throw new Error(result.error?.code ?? `HTTP_${res.status}`)
}
const { data } = result
// Use data.url in the widget and replace it before data.expiresAt.
```

### JavaScript — OAuth application

```javascript
const res = await fetch('https://vibecode.bitrix24.com/v1/infra/servers/SERVER_ID/access-tokens/TOKEN_ID/resource-url', {
  method: 'POST',
  headers: {
    'X-Api-Key': 'YOUR_APP_KEY',
    Authorization: 'Bearer USER_SESSION_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ path: '/widget/style.css', ttlSeconds: 86400 }),
})
const result = await res.json()
if (!res.ok || result.success !== true) {
  throw new Error(result.error?.code ?? `HTTP_${res.status}`)
}
const { data } = result
// Use data.url in the widget and replace it before data.expiresAt.
```

## Response fields

| Field | Type | Description |
|---|---|---|
| `success` | boolean | `true` |
| `data` | object | Signed URL and its expiry |
| `data.url` | string | Resource URL carrying `__resource`; preserve the entire URL |
| `data.expiresAt` | string (ISO 8601) | URL expiry, capped by the parent |

## Response example

Captured from a local HTTP call; the deployment hostname and credential are masked.

```json
{
  "success": true,
  "data": {
    "url": "https://app-example.vibecode.bitrix24.com/widget/style.css?__resource=SIGNED_RESOURCE_JWT",
    "expiresAt": "2026-10-10T14:11:21.000Z"
  }
}
```

## Error response example

400 — `ttlSeconds` is zero:

```json
{
  "success": false,
  "error": {
    "code": "INVALID_RESOURCE_REQUEST",
    "message": "Expected path and optional integer ttlSeconds; no extra fields."
  }
}
```

## Errors

| HTTP | Code | Action |
|---|---|---|
| 400 | `INVALID_RESOURCE_REQUEST` | Fix the body, field types or TTL |
| 400 | `INVALID_RESOURCE_PATH` | Use a canonical ASCII resource path |
| 400 | `WRONG_TOKEN_MODE` | Use an api-bearer parent |
| 400 | `SERVER_NO_SUBDOMAIN` | Use a server with a subdomain |
| 401 | `MISSING_API_KEY` | No API key was supplied through `X-Api-Key` or a supported `Authorization: Bearer` scheme |
| 401 | `INVALID_API_KEY` | The authentication key is not recognized |
| 401 | `KEY_INACTIVE` | The requesting API key is no longer active |
| 401 | `KEY_EXPIRED` | The requesting API key has expired; this is distinct from resource URL expiry |
| 401 | `NO_PORTAL` | A non-management key has no portal binding |
| 401 | `COWORK_CHAT_KEY_INACTIVE` | The parent device key behind a Cowork chat key is no longer usable |
| 401 | `WRONG_AUTH_SCHEME` | An OAuth application key was sent as a Bearer session: put the key in `X-Api-Key` and the session in `Authorization: Bearer` |
| 401 | `INVALID_SESSION` | The supplied OAuth session is invalid or expired |
| 401 | `TOKEN_EXPIRED` | The OAuth user token expired and its refresh failed; re-authorization is required |
| 401 | `TOKEN_INVALID` | When the partner AI plane is enabled, its supplied token is unknown or expired |
| 401 | `KEY_REVOKED` | The partner credential behind the AI plane token is revoked |
| 402 | `ACCOUNT_FROZEN` | The key owner’s account is frozen. With wallet-debt scoping disabled, the shared API-key guard refuses this call before its handler. Follow error.topupUrl to resolve the balance; this status alone does not authorize repeating a prior write. |
| 403 | `KEY_BLOCKED` | The current or a recorded previous token issuer is blocked |
| 403 | `TOKEN_OWNER_MISMATCH` | Use the key that owns both parent and server |
| 403 | `WRITE_BLOCKED_READONLY_KEY` | A read-only key cannot perform this write. |
| 403 | `INFRA_FORBIDDEN_FOR_COWORK_KEY` | Use the key that manages the server outside Cowork |
| 403 | `PORTAL_COLLABORATOR_KEY_OUT_OF_SCOPE` | A development-team key cannot sign resource URLs |
| 403 | `EXTERNAL_COLLABORATOR_KEY_OUT_OF_SCOPE` | An external-collaborator key cannot sign resource URLs |
| 403 | `IP_NOT_ALLOWED` | The caller IP is outside the key allowlist. |
| 403 | `OWNER_BLOCKED` | The key owner is blocked. |
| 403 | `OWNER_DELETED` | The key owner was deleted. |
| 403 | `PORTAL_SUSPENDED` | The bound portal is suspended. |
| 403 | `PORTAL_DELETED` | The bound portal was deleted. |
| 403 | `PORTAL_BLOCKED` | The bound portal is blocked. |
| 403 | `MANAGEMENT_KEY_NO_ENTITY_ACCESS` | The management key lacks the portal binding or entity authority required by this call. |
| 403 | `AGENT_MAINTENANCE_KEY_OUT_OF_SCOPE` | The maintenance key route list does not include this operation. |
| 403 | `APP_API_KEY_OUT_OF_SCOPE` | The application key route list does not include this operation. |
| 403 | `WAITLIST_PENDING` | The key owner has not been activated. |
| 403 | `SESSION_APP_MISMATCH` | The OAuth session belongs to another application. |
| 403 | `PLANE_FORBIDDEN` | The partner key does not allow this AI plane. |
| 403 | `SYSTEM_FORBIDDEN` | The partner key does not allow this AI system. |
| 403 | `SCOPE_FORBIDDEN` | The partner AI plane forbids this route. |
| 403 | `CONNECT_COMPANY_DERIVED_TOKEN_FORBIDDEN` | A company connection key cannot manage derived tokens. |
| 403 | `COWORK_READONLY` | The Cowork seat is read-only. |
| 403 | `GALAXY_DISABLED` | Galaxy operations are disabled for this portal. |
| 404 | `NOT_FOUND` | The parent token was not found or the server's portal has been deactivated |
| 404 | `SERVER_NOT_FOUND` | Check the server ID |
| 410 | `ALREADY_REVOKED` | Mint a new approved parent token |
| 410 | `TOKEN_EXPIRED` | Mint a new approved parent with a valid expiry |
| 429 | `QUOTA_EXCEEDED` | API billing is enabled with a nonzero overage price: the daily free quota is exhausted and the PREPAY balance is not positive. Top up the balance. `error.quota` and `error.used` report counts; `X-RateLimit-Quota`, `X-RateLimit-Used` and `X-RateLimit-Remaining` report the quota, usage and zero remainder. This refusal does not promise `Retry-After` or `X-RateLimit-Scope` |
| 429 | `CALLER_RATE_LIMITED` | The shared key or user request limit. Wait for the number of seconds specified in `Retry-After`; `X-RateLimit-Scope` identifies the refusing layer |
| 503 | `FEATURE_DISABLED` | The access-token section is unavailable |
| 503 | `ACCOUNT_PENDING_ERASURE` | The key owner is pending erasure or being erased. Header `Retry-After: 3600`. |
| 503 | `SERVICE_DISABLED` | The partner AI plane or its quota-readiness service is disabled. No `Retry-After` is promised for this refusal. |
| 503 | `user_self_deletion_pending` | The API key owner scheduled self-deletion. The token plugin refuses the call before its handler. Header `Retry-After: 604800`. |

When reading a resource, an altered, expired or revoked URL, another method, query or path returns `401 BH_RESOURCE_URL_INVALID`. Resource signatures are not accepted as Bearer or cookie sessions. If the application tunnel is disconnected, the Gateway returns neutral JSON `503 BH_RESOURCE_UNAVAILABLE`: the URL does not wake the server or expose the owner's billing state. Common errors: [Errors](/docs/errors).

The Gateway checks the parent token on every resource read. A network failure, timeout or malformed authority response returns neutral `503 BH_RESOURCE_UNAVAILABLE`; confirmed revocation returns `401 BH_RESOURCE_URL_INVALID`. The request is refused without falling back to a browser session or public access.

## Known specifics

### Main-page widget with PORTAL access

1. Create one parent `api-bearer` token with the required lifetime and audience approval.
2. Sign the stylesheet and each image/font path separately through this operation.
3. Use the signed stylesheet URL as `WIDGET_PARAMS.style` when registering through `landing.repowidget.register`. Use the corresponding signed URLs in CSS `url(...)`, HTML images and previews.
4. Renew the URLs and update widget registration before `expiresAt`. Relative image paths do not inherit the stylesheet URL's signature and remain private.
5. [Revoke the parent](./delete.md) to stop every derived URL early.

Ordinary application URLs remain protected under `PORTAL`. The Gateway does not rewrite HTML/CSS or automatically sign dependencies. Resource requests do not create cookies or forward user identity or `X-Vibe-Authorization`; the application must serve the resource without a personal session. Responses carry `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.

## See also

- [Mint a token](./create.md)
- [Revoke a token](./delete.md)
- [What the app receives](/docs/infra/app-runtime)
