# Set a user's external activity

`POST /user.activity.set`

## Description

Paint a badge (and optional glow) on a user's seat for work happening
outside Roam — a phone call, a browser meeting, a CRM session. Pass
`dnd: true` to also put their assigned office in Do Not Disturb.

The integration owns the lifecycle: `set` when the session starts,
`clear` when it ends. Re-posting the same `externalId` is the heartbeat
for long-running sessions — it refreshes `expiresAt` and, unless you
send `startedAt`, keeps the original start time. Roam stamps expiry
itself (default 10 minutes, maximum 60) so a dropped "ended" webhook
cannot leave a permanent glow.

`externalId` is unique per (integration, user). Two apps can hold
activities on the same person at once; you can only update or clear
your own rows.

See [External activity](/docs/guides/user-activity) for display, DND,
TTL, stacking, and where the indicator appears on the map.

**Access:** Organization and Personal. Organization tokens may target
any user in the workspace. Personal tokens (OAuth or PAT) may target
only the token owner.

**Required scope:** `user:write.activity`. Personal Access Tokens skip
this check; personal-mode OAuth installs must still request the scope.

---

**OpenAPI Spec:** [chat-v1.json](https://developer.ro.am/chat-v1.json)

## Authentication

```
Authorization: Bearer YOUR_API_KEY
```

## Request Body

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `userId` | string | Yes | Target user. Bare or tagged UUID. Personal tokens may only pass their own user.  |
| `externalId` | string | Yes | Caller-chosen session id, unique per integration and user. Re-using it upserts the existing row (heartbeat). At most 128 Unicode code points.  |
| `display` | UserActivityDisplay | Yes |  |
| `ttlSeconds` | integer | No | Seconds from now until expiry. Mutually exclusive with `expiresAt`. Values above 3600 are **clamped** to 60 minutes, not rejected. Default when both are omitted: 600 (10 minutes).  |
| `expiresAt` | string | No | Absolute expiry (RFC3339, must be in the future). Mutually exclusive with `ttlSeconds`. Instants more than 60 minutes ahead are clamped to that maximum.  |
| `startedAt` | string | No | Optional session start (RFC3339). Omit on heartbeats to preserve the original. A future value is clamped to the server's now (clock skew; also so one integration cannot pin the newest-first projection |
| `dnd` | boolean | No | If true, this activity contributes Do Not Disturb on the user's **own assigned office** until it is cleared or expires. Defaults to false — a badge does not lock an office unless you opt in. Stacks wi |

**UserActivityDisplay**:

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `emoji` | string | Yes | Badge shown on the user's seat. Required. At most 16 Unicode code points, so ZWJ sequences (family emoji, flags) stay valid.  |
| `title` | string | Yes | Required hover-tooltip title. At most 140 Unicode code points. |
| `subtitle` | string | No | Optional supporting line after the title (for example the source app and a customer name). At most 140 Unicode code points. Omitted when empty.  |
| `color` | "blue" | "gold" | "gray" | "green" | "indigo" | "lime" | "orange" | "pink" | "purple" | "red" | "teal" | "yellow" | No | Curated glow palette name. Omit (or send empty) for a quiet badge-only activity. Unknown names are rejected at set time. Clients resolve the name to light/dark hex — hex is not part of the API. First- |

### Example Request

```json
{
  "userId": "0cc74785-e31e-4403-aa5e-0cc7c1897e66",
  "externalId": "justcall:call:CA123",
  "display": {
    "emoji": "📞",
    "title": "On a customer call",
    "subtitle": "JustCall · Acme Corp",
    "color": "green"
  },
  "ttlSeconds": 1800,
  "dnd": true
}
```

## Responses

### 200 - Activity saved. Body is the live item (same shape `.list` returns
per entry), including the server-stamped `startedAt` / `expiresAt`.


| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `userId` | string | Yes | Bare UUID of the target user. |
| `externalId` | string | Yes | Caller-chosen id for this session, unique per integration and user (for example `justcall:call:CA123`). At most 128 Unicode code points.  |
| `display` | UserActivityDisplay | Yes |  |
| `dnd` | boolean | Yes | Whether this activity currently contributes Do Not Disturb on the user's assigned office.  |
| `startedAt` | string | Yes | When this session started (RFC3339). A heartbeat that omits `startedAt` keeps the original value.  |
| `expiresAt` | string | Yes | Server-stamped expiry (RFC3339). The indicator vanishes from the map and from `.list` once this instant has passed.  |

**UserActivityDisplay**:

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `emoji` | string | Yes | Badge shown on the user's seat. Required. At most 16 Unicode code points, so ZWJ sequences (family emoji, flags) stay valid.  |
| `title` | string | Yes | Required hover-tooltip title. At most 140 Unicode code points. |
| `subtitle` | string | No | Optional supporting line after the title (for example the source app and a customer name). At most 140 Unicode code points. Omitted when empty.  |
| `color` | "blue" | "gold" | "gray" | "green" | "indigo" | "lime" | "orange" | "pink" | "purple" | "red" | "teal" | "yellow" | No | Curated glow palette name. Omit (or send empty) for a quiet badge-only activity. Unknown names are rejected at set time. Clients resolve the name to light/dark hex — hex is not part of the API. First- |


#### Example Response

```json
{
  "userId": "0cc74785-e31e-4403-aa5e-0cc7c1897e66",
  "externalId": "justcall:call:CA123",
  "display": {
    "emoji": "📞",
    "title": "On a customer call",
    "subtitle": "JustCall · Acme Corp",
    "color": "green"
  },
  "dnd": true,
  "startedAt": "2026-05-18T16:04:17.717Z",
  "expiresAt": "2026-05-18T16:34:17.717Z"
}
```

### 400 - Bad request. Common causes:
- Missing `userId`, `externalId`, `display.emoji`, or `display.title`
- `externalId` longer than 128 code points, or display fields over their caps
- `display.color` not in the curated palette
- `ttlSeconds` and `expiresAt` both sent (`invalid_arguments`)
- `ttlSeconds` not positive, or `expiresAt` in the past


### 401 - Presented invalid authentication credentials.

### 403 - Forbidden. Common causes:
- Missing `user:write.activity` (OAuth / API key)
- Personal token targeting a user other than the owner (`access_denied`)


### 404 - User not found, or not in this workspace (`user_not_found`).

### 405 - An unsupported method was requested.

### 500 - An internal error occurred.

---

*Machine-readable API documentation.*
*Full documentation: https://developer.ro.am/docs/api/user-activity-set*
