---
title: Notification Preference
kind: resource
slug: notification-preference
summary: "Per-(user, event-type, channel) routing decisions, optionally scoped to one Droplet or Space. Three-tier lookup — per-subject override beats global override beats compiled-in default."
---

# Notification Preference

Per-(user, event-type, channel) routing decisions, optionally scoped to one Droplet or Space. Three-tier lookup — per-subject override beats global override beats compiled-in default.

A **Notification Preference** is a single row that overrides the default routing for one event type on one channel. The full routing decision for a single (user, event, channel) lookup is resolved through three tiers in order:

1. **Per-subject row** (`subjectType` + `subjectId` set) — most specific. "Mute FIELD_CHANGED on the noisy dashboard, on every channel."
2. **Global override** (`subjectType` and `subjectId` both `null`) — "Always send FIELD_CHANGED to email."
3. **Compiled-in default** (no row in the database) — the matrix below, hard-coded in `lib/change-events/defaults.ts`. Defaults are opinionated and live; a brand-new user with no rows behaves identically to a user with the seeded defaults.

**Default routing matrix** (returned on every `list` response so the UI can render without re-deriving):

| Event type | IN_APP | EMAIL | SLACK |
|---|---|---|---|
| `FIELD_CHANGED` | ✓ | ✗ | ✗ |
| `VERSION_PUBLISHED` | ✓ | ✗ | ✗ |
| `SHARED_WITH_YOU` | ✓ | ✓ | ✗ |
| `ACCESS_GRANTED` | ✓ | ✓ | ✓ |
| `ACCESS_REQUESTED` | ✓ | ✓ | ✓ |
| `COMMENT` | ✓ | ✓ | ✗ |
| `REPLY` | ✓ | ✓ | ✗ |
| `MENTION` | ✓ | ✓ | ✗ |
| `STATE_CHANGED` | ✓ | ✗ | ✗ |
| `OWNERSHIP_TRANSFERRED` | ✓ | ✓ | ✗ |
| `MIGRATION_AUDIENCE_CHANGE` | ✓ | ✓ | ✗ |
| `DROPLET_ARCHIVED_BY_SPACE` | ✓ | ✓ | ✗ |
| `DROPLET_PDF_DOWNLOADED` | ✓ | ✓ | ✗ |

`FIELD_CHANGED` defaults to in-app only to keep email digest volume manageable (SC-07 — digest growth ≤ 20% week-over-week).

**The "Access" bucket.** `ACCESS_REQUESTED` (someone asked for access to a Droplet or Space you own) and `ACCESS_GRANTED` (you were given access) share one preference row — the settings page shows a single **Access** row. Reads and writes for `ACCESS_REQUESTED` resolve to the `ACCESS_GRANTED` key, so set preferences on `ACCESS_GRANTED`. Slack defaults on for this bucket because access requests arrive as a Slack DM with Add / Dismiss buttons; it is the only event type with a Slack delivery today.

**Mute-a-subject is a sugar verb.** Calling `muteSubject` writes three rows in one transaction — one per channel (IN_APP, EMAIL, SLACK), all with `enabled=false`, scoped to the given subject. `unmuteSubject` deletes those same three rows. The matrix UI surfaces this as a single toggle per subject; equivalent direct calls via `update` are possible but require three round trips. Note the [Watch](/docs/resources/watch) model has a separate `MUTED` source — the two paths are **independent**: `Watch.MUTED` suppresses fan-out for that user; `NotificationPreference` rows with `enabled=false` suppress delivery per channel. The UI labels both as "mute" for clarity.

## The Notification Preference object

### Attributes

- **`eventType`** `enum` (enum: `FIELD_CHANGED` / `VERSION_PUBLISHED` / `SHARED_WITH_YOU` / `ACCESS_GRANTED` / `ACCESS_REQUESTED` / `COMMENT` / `REPLY` / `MENTION` / `MIGRATION_AUDIENCE_CHANGE` / `DROPLET_ARCHIVED_BY_SPACE` / `DROPLET_PDF_DOWNLOADED` / `STATE_CHANGED` / `OWNERSHIP_TRANSFERRED`) — Which event this preference governs. `ACCESS_REQUESTED` shares `ACCESS_GRANTED`'s row (the "Access" bucket). Pre-042 values (`COMMENT`, `REPLY`, `MENTION`, `MIGRATION_AUDIENCE_CHANGE`, `DROPLET_ARCHIVED_BY_SPACE`, `DROPLET_PDF_DOWNLOADED`) keep their pre-existing behavior; 042 adds the first four (`FIELD_CHANGED`, `VERSION_PUBLISHED`, `SHARED_WITH_YOU`, `ACCESS_GRANTED`).
- **`channel`** `enum` (enum: `IN_APP` / `EMAIL` / `SLACK`) — The delivery channel this preference governs. `IN_APP` — the bell icon + agent panel. `EMAIL` — the daily digest. `SLACK` — channel-bound notifications (off by default for every event type today; reserved for the planned Space-Slack binding).
- **`enabled`** `boolean` — `true` — route this `(eventType, channel)` to this subject (or globally). `false` — suppress delivery. Setting `enabled=false` for a `(eventType, channel)` is the unit of "mute this combination"; `muteSubject` is a convenience that writes one row per channel for a given subject.
- **`subjectType`** `enum · nullable` (enum: `APP` / `SPACE`) — `APP` / `SPACE` for a per-subject override; `null` for a global preference. The lookup order checks per-subject first (most specific), then falls back to global (`subjectType=null`), then to the compiled-in default.
- **`subjectId`** `string · nullable` — When `subjectType` is set, the `App.id` or `Space.id` this preference applies to. `null` when this is a global preference. Polymorphic — no Prisma `@relation`.
- **`userId`** `string · less-common` — The `User.id` whose preferences these are. Cascade-deletes when the user is deleted. Every preference row is per-user — there's no team-level or global default-override mechanism.
- **`id`** `string · less-common` — CUID primary key. The natural key is `(userId, eventType, channel, subjectType, subjectId)` — enforced by `@@unique`. The surrogate `id` is rarely needed outside Prisma internals.
- **`createdAt`** `timestamp · less-common` — ISO 8601 timestamp when the row was first written.
- **`updatedAt`** `timestamp · less-common` — ISO 8601 timestamp of the last `enabled` flip. Same row is updated in-place on subsequent toggles (no insert-and-supersede pattern).

### JSON example

```json
{
  "id": "pref_clxa",
  "userId": "usr_alex",
  "eventType": "FIELD_CHANGED",
  "channel": "IN_APP",
  "enabled": false,
  "subjectType": "APP",
  "subjectId": "app_noisy",
  "createdAt": "2026-06-07T10:00:00.000Z",
  "updatedAt": "2026-06-07T10:00:00.000Z"
}
```

## Methods

### List notification preferences

`GET /api/notification-preferences`

Return the viewer's effective notification preferences: `defaults` (the compiled-in default matrix), `overrides` (explicit rows that diverge from defaults — global and per-subject), and `mutedSubjects` (subjects with all three channels disabled via per-subject overrides). Per the lookup order in `lib/change-events/preferences.ts`: per-subject row wins over global override row wins over default.

**CLI**

```bash
drop notifications prefs
```

**MCP**

```json
{
  "tool": "drop_notification_preferences_list",
  "arguments": {}
}
```

**HTTP**

```bash
curl -X GET 'https://drop.himarley.ai/api/notification-preferences' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "defaults": {
    "FIELD_CHANGED": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "VERSION_PUBLISHED": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "SHARED_WITH_YOU": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "ACCESS_GRANTED": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "COMMENT": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "REPLY": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "MENTION": {
      "IN_APP": true,
      "EMAIL": true,
      "SLACK": false
    },
    "MIGRATION_AUDIENCE_CHANGE": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "DROPLET_ARCHIVED_BY_SPACE": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    },
    "DROPLET_PDF_DOWNLOADED": {
      "IN_APP": true,
      "EMAIL": false,
      "SLACK": false
    }
  },
  "overrides": [
    {
      "id": "pref_1",
      "eventType": "FIELD_CHANGED",
      "channel": "IN_APP",
      "enabled": false,
      "subjectType": "APP",
      "subjectId": "app_noisy",
      "subject": {
        "subjectType": "APP",
        "subjectId": "app_noisy",
        "name": "Noisy Dashboard",
        "slug": "noisy-dashboard"
      },
      "createdAt": "2026-06-07T10:00:00.000Z"
    }
  ],
  "mutedSubjects": []
}
```

### Update a notification preference

`PATCH /api/notification-preferences`

Upsert a single preference cell. Targets one (eventType, channel) — globally when `subject` is omitted, or for a specific Droplet / Space when `subject` is set. Returns the resulting row. Idempotent — setting a row to its current value is a no-op.

**Parameters**

- **`eventType`** _(enum · body · required)_ — Event type to set (FIELD_CHANGED, COMMENT, etc.).
- **`channel`** _(enum · body · required)_ — IN_APP | EMAIL | SLACK.
- **`enabled`** _(boolean · body · required)_ — Route this event to this channel?
- **`subject`** _(object · body)_ — `{ subjectType: APP|SPACE, subjectId }` to scope this preference; omit for global.

**CLI**

```bash
drop notifications prefs set --event-type FIELD_CHANGED --channel EMAIL --enabled
```

**MCP**

```json
{
  "tool": "drop_notification_preferences_update",
  "arguments": {
    "eventType": "FIELD_CHANGED",
    "channel": "EMAIL",
    "enabled": true
  }
}
```

**HTTP**

```bash
curl -X PATCH 'https://drop.himarley.ai/api/notification-preferences' \
  -H "Authorization: Bearer $DROP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"eventType":"FIELD_CHANGED","channel":"EMAIL","enabled":true}'
```

**Response** (200)

```json
{
  "id": "pref_2",
  "eventType": "FIELD_CHANGED",
  "channel": "EMAIL",
  "enabled": true,
  "subjectType": null,
  "subjectId": null,
  "subject": null,
  "createdAt": "2026-06-07T15:30:00.000Z"
}
```

### Mute a subject across all channels

`POST /api/notification-preferences/mute-subject`

Convenience: write three `NotificationPreference` rows in one transaction — one per channel (IN_APP, EMAIL, SLACK) all with `enabled=false`, scoped to the given `(subjectType, subjectId)`. Equivalent to three `update` calls but atomic. Idempotent — already-muted subjects return `rowsWritten: 0`. The same subject can also be muted via a `Watch` row (`source: MUTED`); the two paths are independent (Watch.mute suppresses fan-out for that user; this suppresses delivery via channel preferences). The UI surfaces both as "mute" — see `data-model.md` § What the data model does NOT include for the design rationale.

**Parameters**

- **`subjectType`** _(enum · body · required)_ — APP | SPACE.
- **`subjectId`** _(string · body · required)_ — App.id or Space.id (slug also accepted).

**CLI**

```bash
drop notifications mute --subject-type APP --subject-id app_noisy
```

**MCP**

```json
{
  "tool": "drop_notification_preferences_mute_subject",
  "arguments": {
    "subjectType": "APP",
    "subjectId": "app_noisy"
  }
}
```

**HTTP**

```bash
curl -X POST 'https://drop.himarley.ai/api/notification-preferences/mute-subject' \
  -H "Authorization: Bearer $DROP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subjectType":"APP","subjectId":"app_noisy"}'
```

**Response** (200)

```json
{
  "subjectType": "APP",
  "subjectId": "app_noisy",
  "rowsWritten": 3
}
```

### Un-mute a subject

`DELETE /api/notification-preferences/mute-subject`

Delete every `enabled=false` `NotificationPreference` row scoped to the given `(subjectType, subjectId)` for the viewer. Inverse of `muteSubject`. Idempotent — un-muting a non-muted subject returns `rowsDeleted: 0`. Note this only removes preference-level mutes; if the viewer also has a `Watch` row with `source: MUTED`, call `unwatchApp` / `unwatchSpace` separately to fully restore notifications.

**Parameters**

- **`subjectType`** _(enum · body · required)_ — APP | SPACE.
- **`subjectId`** _(string · body · required)_ — App.id or Space.id (slug also accepted).

**CLI**

```bash
drop notifications unmute --subject-type APP --subject-id app_noisy
```

**MCP**

```json
{
  "tool": "drop_notification_preferences_unmute_subject",
  "arguments": {
    "subjectType": "APP",
    "subjectId": "app_noisy"
  }
}
```

**HTTP**

```bash
curl -X DELETE 'https://drop.himarley.ai/api/notification-preferences/mute-subject' \
  -H "Authorization: Bearer $DROP_TOKEN"
```

**Response** (200)

```json
{
  "subjectType": "APP",
  "subjectId": "app_noisy",
  "rowsDeleted": 3
}
```
