---
title: Troubleshooting
order: '90'
summary: Common ScaiClip errors, symptoms, and what to check first.
path: troubleshooting
status: published
---

Fast-path fixes for the errors we see most often. Skim by symptom.

## Client can't connect to the WebSocket

**Symptom.** Your client library returns an HTTP 401 / 403 during upgrade, or the connection closes immediately with code `4401` / `4403`.

- **401 or `4401`** — auth failed. Bearer token missing, expired, or malformed. Also fires when you passed an `sgk_` API key but the tenant has revoked it.
- **`4403`** — auth resolved but the tenant context is missing (rare; means the caller has no tenant assigned) or the SCSG endpoint got a non-service principal.
- Double-check `?token=…` vs `Authorization: Bearer …`. Both work; only pass one.
- `SCAIGRID_AUTH_DISABLED=true` on the server disables all auth. If you're running local dev and can't connect, that's likely the missing flag.

## `device_id query param required`

**Symptom.** WS close code `4403` with that reason on the SCWP endpoint.

The `?device_id=…` query parameter is required on every `scaiclip/1` connection. It must be either a 32-char hex string (16 raw bytes) OR a ScaiKey nanoid (`dev_XYZ…`).

## `E_BAD_SIG` on `clip.publish`

- Signature was made over the wrong subset of envelope keys. **Signature input is canonical CBOR of keys 1-14 only.** Keys 3 (seq) and 7 (grid_ts) are grid-assigned; do not include them.
- Sender `sig_pub` doesn't match what ScaiKey has on file. Common cause: you rotated device keys client-side but didn't call `POST /me/devices/{id}/keys` on ScaiKey.
- Endian mismatch in the CBOR encoder. Canonical CBOR means definite-length maps sorted by canonical CBOR key ordering. If your library reorders map keys, signatures will fail.

## `E_WRAPS_INCOMPLETE` on `ONE_SHOT` publish

**Symptom.** You published a `ONE_SHOT` clip and got `E_WRAPS_INCOMPLETE { missing: [dev_xyz, …] }`.

The grid requires one-shot wraps for **every current member device** at publish time. If a new device onboarded between when your client fetched `channel.devices` and when you published, that device is missing a wrap.

Fix: re-query `channel.devices`, generate a fresh wrap for each missing device, retry `clip.publish`.

## `E_ALREADY_CLAIMED`

Someone else claimed the one-shot before you. `detail.claimed_by` names the winning subject; `detail.at` is the ms timestamp.

Not an error condition — this is how one-shot works. Your UI should show the user "already claimed by X" rather than retry.

## `E_RATE_LIMITED`

You're publishing / reserving / opening sessions faster than the token bucket allows. `detail.retry_after_ms` says when to try again.

Default rates:

- `clip.publish` — 30/s per device with 60-token burst.
- `scratch.reserve` — 2/s per user with 10-token burst.
- SCSG `session.open` — 5/s per `(tenant, service_id)` with 20-token burst.
- Public link mint — 30/min per tenant with 60-burst.

If you're bumping the limit on legitimate load, ask for an uplift.

## `E_QUOTA_EXCEEDED` on scratch

**Symptom.** `scratch.reserve` fails with `detail = {scope: "user" | "tenant", used, limit, additional}`.

- Delete old scratch objects (`scratch.delete`) — `list` first to see what you have, and check the `oldest_reclaimable` hint in the error detail.
- Ask the tenant admin to raise the quota (setting: `scaiclip_scratch_quota_user_bytes` / `_tenant_bytes`).
- Consider `ref_origin` mode for the specific clip — bypasses scratch storage entirely.

## `E_ORIGIN_OFFLINE`

You tried to `media.fetch` a `ref_origin` clip while the origin device isn't connected. There's no queuing — `ref_origin` is deliberately ephemeral.

Surface a UX like "sender is offline; ask them to reopen ScaiClip". If the content matters over the origin's presence, the sender should have published with `ref_scratch` instead.

## `E_FORBIDDEN` — SCSG session refuses to open

Possible causes, in order of frequency:

1. Tenant hasn't opted your service in. Check `mod_scaiclip_tenant_policy.allow_scsg_<service_id>`.
2. You requested `CLAIM` capability but tenant hasn't opted in for one-shot consumption. That gate is on `allow_agent_claim`.
3. Your `client_id` doesn't map to a registered `service_id` per the SCSG registry. Prefix your `client_id` with `cli_<service_id>_`.

Error `detail` includes `service_id` and `tenant_id` for triage.

## `E_NO_ONLINE_DEVICE`

You're trying to open an SCSG session but the user has no device connected. Grid needs at least one online T1 to consent-prompt.

Client-side UX: prompt the user to open their ScaiClip client first, then retry.

## Public download link returns 410

The link is dead. Possibilities:

- Expired (`expires_at` in the past).
- Revoked (owner or via `revoke_token`).
- Download limit reached.
- Underlying scratch object was reclaimed (rare — happens if scratch idle-TTL fires before public expiry).

The 410 is returned in constant time; the specific reason isn't disclosed to the anonymous fetcher. Owners can look up the token record via the API to see what happened.

## Retention engine isn't sweeping

**Symptom.** Sensitive clips outlive their TTL by many seconds, or expired scratch objects stick around.

- Confirm the retention engine started. Log line `scaiclip_retention_started` should appear in module init.
- Check `is_running` — all four loops (sensitive, normal, scratch, SCSG) must be alive. A crash in one loop stops that specific sweep but not the others.
- If the process just restarted, the first sweep runs `_sensitive_cadence_s` seconds after start (default 15 s), not immediately.

## Presence heartbeats aren't reaching ScaiKey

- Confirm `PresenceHeartbeatPusher` started (log line `scaiclip_presence_started`).
- Check the shared ScaiKey client is configured — `SCAICLIP_SCAIKEY_URL` or `SCAIKEY_BASE_URL` must be set.
- Test the heartbeat batch endpoint directly with a service token. If the platform returns 401/403, the ScaiKey OAuth scopes on the ScaiGrid application don't include `devices:heartbeat`.

## Audit rows aren't landing

- Audit is best-effort — it never breaks the calling path. Failures log as `scaiclip_audit_emit_failed`.
- Common cause: `audit_log` table missing. Run `alembic upgrade head` on the ScaiGrid schema, not just the module schema.
- Filter with `action LIKE 'scaiclip.%'` in `/v1/audit` queries.

## Where to look next

- Every action lands in `audit_log` with matching `resource_type` / `resource_id`. Filter by `status = 'DENIED'` for rejections.
- Structured log lines with prefix `scaiclip_` cover retention sweeps, presence pushes, webhook receipts, and SCSG lifecycle. Grep the ScaiGrid pod logs.
- Metrics on `RetentionEngine.expired_sensitive_total` / `_normal_total` / `reclaimed_scratch_total` / `expired_scsg_total` counters expose sweep health.
- The [error codes](./reference/errors.md) page maps each `E_*` to what it means and what to do.

If none of the above matches your symptom, open an issue with:

- Wire dump of the failing frame (both directions).
- Server-side `scaiclip_` log lines around the failure.
- Approximate wall-clock at time of failure so we can correlate with audit rows.