# Embed sessions

Source: https://ceyo.ai/docs/signal/embed-sessions

### Embed sessions

Issue short-lived browser credentials for an embedded user, then rotate them without changing the user’s effective access.

> **Provisioning options**
>
> By default, the embedded identity and its project or location grants must already exist. For a first-login flow, send `provision` to create or update the identity and ensure the scope grant or a batch of location grants atomically before issuing the session.

> **Server-to-server authentication**
>
> Call both endpoints from your backend with `SIGNAL_API_KEY` as the bearer credential. The key requires the `embed_sessions:create` capability. Inline provisioning additionally requires a workspace-scoped key with `identities:manage`. Send only the returned embed session token to the browser. Never expose the API key in JavaScript, HTML, logs, URLs, or mobile application code.

### Create embed session

`POST /embed/sessions`

Creates a short-lived embed session using existing access or optional atomic first-login provisioning.

#### Request body

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `external_user_id` | string | Required | Stable, case-sensitive identifier for the signed-in user in your system. It must already exist unless provision is supplied. |
| `project_id` | project UUID \| project external ID | Required | Ceyo project UUID or configured partner external ID. The project must belong to the workspace selected by the API key. |
| `location_id` | location UUID \| location external ID | Optional | Restricts the session to one location in the project. The identity must have access unless provision ensures the grant. Omit for project scope. |
| `location_scope` | granted | Optional | Exposes only the identity’s active direct location grants in this project. Mutually exclusive with location\_id. |
| `ttl_seconds` | integer | Optional; Default: 3600 | Session lifetime in seconds. Minimum: 60. Maximum: 86400. Prefer the shortest lifetime suitable for your integration. |
| `expires_in` | integer | Optional | Compatibility alias for ttl\_seconds. Do not send both unless their values are identical. |
| `provision` | ProvisionInput | Optional | Optionally creates or updates the identity and ensures one project/location grant, or a granted-scope batch of location grants, before issuing the session. |

#### ProvisionInput

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `role` | viewer \| editor |  | Required for project or single-location provisioning. Do not combine with location\_grants. |
| `identity` | ProvisionIdentityInput |  | Optional identity fields to set. Omitted fields remain unchanged on an existing identity. |
| `location_grants` | ProvisionLocationGrantInput\[\] |  | One to 100 direct location grants. Allowed only with location\_scope granted and mutually exclusive with role. |

#### ProvisionIdentityInput

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `email` | string \| null |  | Optional normalized contact email. |
| `name` | string \| null |  | Optional display name. |
| `avatar_url` | HTTPS URL \| null |  | Optional absolute HTTPS avatar URL. |
| `metadata` | object |  | Optional partner metadata object, up to 16 KB. |

#### ProvisionLocationGrantInput

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `location_id` | location UUID \| location external ID |  | Required location in the selected project. |
| `role` | viewer \| editor |  | Required role for this direct location grant. Roles may differ between locations. |

> **Access validation**
>
> With neither scope selector, the identity must have active project access. With `location_id`, the identity must have access to that location. With `location_scope: "granted"`, only active direct location grants in the project are visible; a project grant does not expand the result. Grants are checked live, and each location keeps its own role. Sessions support only `viewer` and `editor`; hosted `admin` grants are capped to `editor`.

#### EmbedSessionResponse

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `session.token` | string |  | Opaque, short-lived credential for the embedded browser component. Treat it as a secret. |
| `session.token_type` | Bearer |  | Authentication scheme used by the embed session token. |
| `session.expires_at` | datetime |  | Token expiration time in ISO 8601 UTC format. |
| `session.expires_in` | integer |  | Session lifetime in seconds. |
| `session.project_id` | uuid |  | Compatibility alias for session.scope.project\_id. |
| `session.location_id` | uuid \| null |  | Compatibility alias for session.scope.location\_id. |
| `session.location_scope` | full \| location \| granted |  | Session scope mode. granted resolves the identity’s active direct location grants live. |
| `session.external_user_id` | string |  | Compatibility alias for session.identity.external\_user\_id. |
| `session.identity.external_user_id` | string |  | Partner identifier of the embedded user represented by the session. |
| `session.scope.type` | granted |  | Present for granted-location sessions. |
| `session.scope.project_id` | uuid |  | Canonical Ceyo UUID of the project available to the session. |
| `session.scope.location_id` | uuid \| null |  | Canonical Ceyo UUID of the restricted location, or null for project scope. |
| `session.scope.role` | viewer \| editor \| null |  | Effective role for project or single-location scope. Granted sessions use each location’s own role. |

```
type EmbedSessionResponse = {
  session: {
    token: string;
    token_type: "Bearer";
    expires_at: string;
    expires_in: number;
    project_id: string;
    location_id: string | null;
    location_scope: "full" | "location" | "granted";
    external_user_id: string;
    identity: {
      external_user_id: string;
    };
    scope: {
      type?: "granted";
      project_id: string;
      location_id: string | null;
      role: "viewer" | "editor" | null;
    };
  };
};
```

#### Request and response

```curl
curl --request POST \
  --url 'https://api.signal.ceyo.ai/v1/embed/sessions' \
  --header "Authorization: Bearer ${SIGNAL_API_KEY}" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: embed-create-user-42-20260731T123300Z' \
  --data '{
  "external_user_id": "user-42",
  "project_id": "partner-project-acme",
  "ttl_seconds": 3600
}'
```

```json
HTTP/1.1 201 Created

{
  "session": {
    "token": "ceyo_embed_eyJhbGciOi...",
    "token_type": "Bearer",
    "expires_at": "2026-07-31T13:33:00Z",
    "expires_in": 3600,
    "project_id": "e6c96c98-d777-40e0-94ec-48931f57782f",
    "location_id": null,
    "location_scope": "full",
    "external_user_id": "user-42",
    "identity": {
      "external_user_id": "user-42"
    },
    "scope": {
      "project_id": "e6c96c98-d777-40e0-94ec-48931f57782f",
      "location_id": null,
      "role": "viewer"
    }
  }
}
```

#### Location-scoped request

```
curl --request POST \
  --url 'https://api.signal.ceyo.ai/v1/embed/sessions' \
  --header "Authorization: Bearer ${SIGNAL_API_KEY}" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: embed-create-franchisee-804-20260731T125000Z' \
  --data '{
  "external_user_id": "franchisee-804",
  "project_id": "partner-project-acme",
  "location_id": "partner-location-amsterdam",
  "ttl_seconds": 1800
}'
```

#### Pre-granted location portfolio

```
curl --request POST \
  --url 'https://api.signal.ceyo.ai/v1/embed/sessions' \
  --header "Authorization: Bearer ${SIGNAL_API_KEY}" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: embed-create-region-42-20260805T120000Z' \
  --data '{
  "external_user_id": "regional-manager-42",
  "project_id": "partner-project-acme",
  "location_scope": "granted",
  "ttl_seconds": 3600
}'
```

This session's scope type is `granted`. It exposes Overview and Locations only; project-wide prompts, citations, competitors, settings, and users are unavailable. Single-location auto-open behavior still applies.

#### First-login provisioning

```
curl --request POST \
  --url 'https://api.signal.ceyo.ai/v1/embed/sessions' \
  --header "Authorization: Bearer ${SIGNAL_API_KEY}" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: embed-provision-new-user-42' \
  --data '{
  "external_user_id": "new-user-42",
  "project_id": "partner-project-acme",
  "ttl_seconds": 3600,
  "provision": {
    "role": "viewer",
    "identity": {
      "email": "new-user-42@example.com",
      "name": "New User"
    }
  }
}'
```

#### Atomic location batch provisioning

```
curl --request POST \
  --url 'https://api.signal.ceyo.ai/v1/embed/sessions' \
  --header "Authorization: Bearer ${SIGNAL_API_KEY}" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: embed-provision-region-42' \
  --data '{
  "external_user_id": "regional-manager-42",
  "project_id": "partner-project-acme",
  "location_scope": "granted",
  "provision": {
    "identity": {
      "email": "manager-42@example.com",
      "name": "Regional Manager"
    },
    "location_grants": [
      {
        "location_id": "store-amsterdam",
        "role": "editor"
      },
      {
        "location_id": "store-utrecht",
        "role": "viewer"
      }
    ]
  }
}'
```

Supply one to 100 grants. The batch atomically ensures the listed direct grants; it does not add unlisted locations or make a project grant part of this scope.

> **Idempotency and retries**
>
> A unique `Idempotency-Key` header is required for each intended session. Repeating the same request with the same key returns the original response and does not issue another token. Reusing that key with different body fields returns `409`. Use a new key when you intentionally need a separate session.

#### Errors

#### Error response envelope

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `error` | Error |  | Structured error payload. |

#### Error

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `code` | string |  | Stable snake\_case code suitable for programmatic handling. |
| `message` | string |  | Human-readable explanation of the failure. |
| `details` | object \| array \| null |  | Structured validation or request context when available. |
| `request_id` | string |  | Identifier to provide when requesting support. |

```json
{
  "error": {
    "code": "invalid_request",
    "message": "The request could not be processed.",
    "details": {
      "field": "ttl_seconds"
    },
    "request_id": "req_01JEXAMPLE7J8Q2Y4K6M9"
  }
}
```

#### Status codes

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `400` | invalid\_request |  | The JSON body is malformed, required fields are absent, or a request object or TTL is invalid. |
| `401` | invalid\_api\_key |  | The platform API key is absent, invalid, expired, or revoked. |
| `403` | forbidden \| access\_denied |  | The API key lacks the required capability, or the identity is disabled or has no active access grant. |
| `404` | identity\_not\_found \| project\_not\_found \| location\_not\_found |  | The requested identity, project, or location was not found. |
| `409` | idempotency\_conflict |  | The idempotency key was reused with a different request. |
| `422` | validation\_failed |  | The inline identity, grant role, or access grant failed validation. |
| `429` | rate\_limit\_exceeded |  | Too many requests were made; follow the Retry-After header. |

### Refresh embed session

`POST /embed/sessions/refresh`

Atomically exchanges a current embed session token for a replacement with the same identity and scope.

#### Request body

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `session_token` | string | Required | Current embed session token. It may be active or expired by no more than five minutes. The token is exchanged and cannot be used again after a successful refresh. |
| `ttl_seconds` | integer | Optional; Default: 3600 | Lifetime of the replacement token in seconds. Minimum: 60. Maximum: 86400. |
| `expires_in` | integer | Optional | Compatibility alias for ttl\_seconds. Do not send both unless their values are identical. |

> **Scope is preserved**
>
> Refresh accepts no identity, project, location, location scope, or role fields. The replacement inherits the current scope after the API revalidates access. A granted replacement continues to resolve direct location grants live. If required access was revoked, refresh fails; create a new session only after reconciling access.

> **Rotation and replay protection**
>
> A unique `Idempotency-Key` header is required. A successful exchange invalidates the submitted session token. Subsequent exchanges of that token fail unless they repeat the completed request with the same `Idempotency-Key`, in which case the original replacement response is returned. An active token may be refreshed at any time and an expired token has a five-minute refresh window.

#### EmbedSessionResponse

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `session.token` | string |  | Opaque, short-lived credential for the embedded browser component. Treat it as a secret. |
| `session.token_type` | Bearer |  | Authentication scheme used by the embed session token. |
| `session.expires_at` | datetime |  | Token expiration time in ISO 8601 UTC format. |
| `session.expires_in` | integer |  | Session lifetime in seconds. |
| `session.project_id` | uuid |  | Compatibility alias for session.scope.project\_id. |
| `session.location_id` | uuid \| null |  | Compatibility alias for session.scope.location\_id. |
| `session.location_scope` | full \| location \| granted |  | Session scope mode. granted resolves the identity’s active direct location grants live. |
| `session.external_user_id` | string |  | Compatibility alias for session.identity.external\_user\_id. |
| `session.identity.external_user_id` | string |  | Partner identifier of the embedded user represented by the session. |
| `session.scope.type` | granted |  | Present for granted-location sessions. |
| `session.scope.project_id` | uuid |  | Canonical Ceyo UUID of the project available to the session. |
| `session.scope.location_id` | uuid \| null |  | Canonical Ceyo UUID of the restricted location, or null for project scope. |
| `session.scope.role` | viewer \| editor \| null |  | Effective role for project or single-location scope. Granted sessions use each location’s own role. |

```
type EmbedSessionResponse = {
  session: {
    token: string;
    token_type: "Bearer";
    expires_at: string;
    expires_in: number;
    project_id: string;
    location_id: string | null;
    location_scope: "full" | "location" | "granted";
    external_user_id: string;
    identity: {
      external_user_id: string;
    };
    scope: {
      type?: "granted";
      project_id: string;
      location_id: string | null;
      role: "viewer" | "editor" | null;
    };
  };
};
```

#### Request and response

```curl
curl --request POST \
  --url 'https://api.signal.ceyo.ai/v1/embed/sessions/refresh' \
  --header "Authorization: Bearer ${SIGNAL_API_KEY}" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: embed-refresh-session-7f42a-1' \
  --data '{
  "session_token": "ceyo_embed_eyJhbGciOi...current",
  "ttl_seconds": 3600
}'
```

```json
HTTP/1.1 200 OK

{
  "session": {
    "token": "ceyo_embed_eyJhbGciOi...replacement",
    "token_type": "Bearer",
    "expires_at": "2026-07-31T14:20:00Z",
    "expires_in": 3600,
    "project_id": "e6c96c98-d777-40e0-94ec-48931f57782f",
    "location_id": "a1308d14-149c-4dd7-a4c5-295ac9090f58",
    "location_scope": "location",
    "external_user_id": "franchisee-804",
    "identity": {
      "external_user_id": "franchisee-804"
    },
    "scope": {
      "project_id": "e6c96c98-d777-40e0-94ec-48931f57782f",
      "location_id": "a1308d14-149c-4dd7-a4c5-295ac9090f58",
      "role": "editor"
    }
  }
}
```

#### Backend refresh endpoint

```
// Authenticate your application user before this handler.
// Your platform API key remains on the server.
app.post('/api/signal-session/refresh', async (req, res) => {
  const response = await fetch(
    'https://api.signal.ceyo.ai/v1/embed/sessions/refresh',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SIGNAL_API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': req.get('Idempotency-Key') || crypto.randomUUID(),
      },
      body: JSON.stringify({
        session_token: req.body.session_token,
        ttl_seconds: 3600,
      }),
    },
  );

  const body = await response.json();
  if (!response.ok) return res.status(response.status).json(body);
  res.json({
    token: body.session.token,
    expires_at: body.session.expires_at,
  });
});
```

#### Browser token lifecycle

Keep the active token and its `expiresAt` value in memory. Before expiration, send the current token to your authenticated backend, atomically replace the in-memory record, and call `embed.updateToken()`. The expiry callback should exchange your in-memory current token and return the replacement token. Returning `null` leaves the application locked.

```
let currentSession = {
  token: initialToken,
  expiresAt: initialExpiresAt,
};

async function refreshSession(
  currentToken: string,
  updateHandle: boolean,
): Promise<string | null> {
  const response = await fetch("/api/signal-session/refresh", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({ session_token: currentToken }),
  });
  if (!response.ok) return null;

  const session = await response.json();
  currentSession = {
    token: session.token,
    expiresAt: session.expires_at,
  };
  if (updateHandle) embed.updateToken(currentSession.token);
  return currentSession.token;
}

const embed = Ceyo.mount("#signal-visibility", {
  sessionToken: currentSession.token,
  onTokenExpired() {
    return refreshSession(currentSession.token, false);
  },
});

// Schedule this before currentSession.expiresAt.
await refreshSession(currentSession.token, true);
```

#### Errors

#### Error response envelope

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `error` | Error |  | Structured error payload. |

#### Error

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `code` | string |  | Stable snake\_case code suitable for programmatic handling. |
| `message` | string |  | Human-readable explanation of the failure. |
| `details` | object \| array \| null |  | Structured validation or request context when available. |
| `request_id` | string |  | Identifier to provide when requesting support. |

```json
{
  "error": {
    "code": "invalid_request",
    "message": "The request could not be processed.",
    "details": {
      "field": "ttl_seconds"
    },
    "request_id": "req_01JEXAMPLE7J8Q2Y4K6M9"
  }
}
```

#### Status codes

| Name | Type | Details | Description |
| --- | --- | --- | --- |
| `400` | invalid\_request |  | The JSON body is malformed, required fields are absent, or a request object or TTL is invalid. |
| `401` | invalid\_api\_key \| invalid\_session\_token |  | The API key or session token is invalid, revoked, expired, or outside the refresh window. |
| `403` | session\_workspace\_mismatch \| access\_denied |  | The session belongs to another workspace or its identity no longer has access. |
| `409` | idempotency\_conflict |  | The idempotency key was reused with a different request. |
| `429` | rate\_limit\_exceeded |  | Too many requests were made; follow the Retry-After header. |

### Security checklist

**Authenticate your own user**

Mint or refresh a session only after validating the user’s session in your application. Do not accept an arbitrary `external_user_id` from an unauthenticated browser.

**Keep the platform key server-side**

The platform key can operate across its workspace. The browser receives only the short-lived, narrowly scoped embed token.

**Protect session tokens**

Send tokens only over HTTPS, keep them out of URLs and analytics, and avoid persistent browser storage. Hold them in memory when possible.

**Use short lifetimes**

The default is 3600 seconds and the maximum is 86400 seconds. Refresh shortly before expiration or during the five-minute post-expiry exchange window.

**Retry deliberately**

Use one idempotency key per intended create or refresh operation. Never retry a refresh with a new key after its outcome is unknown; first retry with the original key.
