# Settings overview

An app has one settings document for everything that can differ between sites: roles, policy, limits, calls, branding, languages, moderation rules and webhook options. Every key has a default, so `{}` is valid and omitted keys keep their defaults. Unknown keys are rejected.

## Before you start

- Admin or owner access to the dashboard, or the app's server secret and app id. See [Tenants and apps](/guides/tenants-and-apps/).
- The roles you need. Every setting that names a role must name a key of `roles`.

## Steps

1. Edit in the dashboard. Open **App**. The form is built from the settings schema, and every field shows its default and description. The [settings reference](/reference/settings/) lists every key by group.

2. Or read the document from your server with [`GET /v1/app/settings`](/api/operations/get-own-settings/). The answer carries an `ETag` header with the current version, quoted:

   ```sh
   curl -i https://app.mondochat.io/v1/app/settings \
     -H "Authorization: Bearer $MONDOCHAT_SERVER_SECRET" -H "Mondochat-App-Id: $MONDOCHAT_APP_ID"
   ```

3. Replace the document with [`PUT /v1/app/settings`](/api/operations/put-own-settings/). It replaces the whole document and needs `If-Match` with the version of your last `GET`. A stale version answers 412 `precondition_failed`. A successful write bumps the version and returns the new `ETag`.

   ```sh
   curl -X PUT https://app.mondochat.io/v1/app/settings \
     -H "Authorization: Bearer $MONDOCHAT_SERVER_SECRET" -H "Mondochat-App-Id: $MONDOCHAT_APP_ID" \
     -H 'If-Match: "1"' -H 'content-type: application/json' -d @settings.json
   ```

   The route behaves like the dashboard's settings endpoint for that app.

4. Start from a typical cam site: fans and models, fans start conversations, both sides can call.

   ```json
   {
     "roles": [
       {"key": "fan", "label": {"en": "Fan"}},
       {"key": "model", "label": {"en": "Model"}}
     ],
     "default_role": "fan",
     "guests": {"role": "fan"},
     "policy": [
       {"from": "fan", "to": "model", "start_conversation": true, "call": true},
       {"from": "model", "to": "fan", "start_conversation": false, "call": true}
     ],
     "limits": {"fan": {}, "model": {}},
     "calls": {"enabled": true},
     "links": {"open_conversation_url_template": "https://www.example.com/{lang}/messages?with={peer_external_id}"}
   }
   ```

   `guests.role` and `limits` must name existing roles, so replace the default `member` there too.

### Roles

`roles` lists at least one role: `{key, label: {lang: text}, seller}`. `seller: true` marks the roles that get seller features such as the installed app. A `role` claim in a client token carries one of these keys, and `default_role` applies when the claim is missing.

### Policy

`policy` has one row per ordered pair of roles, `from` and `to`. A missing pair allows nothing. Each row sets these flags:

| Flag | Meaning |
|---|---|
| `start_conversation` | `from` may start a conversation with `to`. |
| `message` | `from` may message `to`. |
| `call` | `from` may call `to`. |
| `photos` | `from` may send photos, videos and voice notes. |
| `photo_consent_required` | `to` must accept photos per conversation. |
| `block` | `from` may block `to`. |
| `see_presence` | `from` may see `to`'s presence. |
| `require_vouch` | `from` needs a vouch to start a conversation. See [Embed](/guides/embed/). |
| `offers` | `from` may send ad hoc offer cards. |
| `buy` | `from` may buy from `to`. |

When a row forbids an action, the API answers 403 `policy_denied` with `reason` set to the flag name. The chat gets the resolved policy for the viewer and hides what the API would refuse.

### Limits

`limits` holds a limit set per role, and `null` means unlimited. The defaults are 30 messages per minute and 100 voice notes per day. The other limits are `new_conversations_per_day`, `messages_to_unanswered` (messages before the peer first replies) and `photos_per_day` (photos and videos together). A reached limit answers 403 `limit_reached` with `reason` set to the limit name. Per-app request rate is `rate_limits.app_requests_per_second`, 50 by default.

### Per-user overrides with perms

A `perms` claim in the client token, or `perms` on a server upsert, overrides any `policy` field, applied to every `to` role, and any `limits` field for that user. For example `{"call": false, "photos_per_day": 5}`. The claims are stored on the user.

### Cross-field rules

A save is refused with 422 `validation` when:

- A role named by `default_role`, `guests.role`, a policy row's `from` or `to`, `calls.per_minute.payer_role`, a key of `limits` or a key of `onboarding.slides` is not a key of `roles`. `guests.role` is not allowed implicitly. There is one error per setting, and its `loc` is `["body", ...]` followed by the path to that setting.
- A URL template is not https.
- `pwa.enabled` is on without `pwa.name` and `pwa.icon_url`.
- `workspace.away_reply` or `workspace.reply_time` is on while `presence.enabled` is off.

### URL templates and their fallbacks

Placeholders such as `{lang}` are filled by the chat or by the API, and values are URL-encoded. While a template is unset the host gets a `navigate` event with no `url`, or the field that would carry the link stays empty.

| Key | Placeholders | What a set value does, and what happens while it is unset |
|---|---|---|
| `links.open_conversation_url_template` | `{peer_external_id}` `{lang}` | Builds `open_url`, the link back to the conversation, in `message.created`, `conversation.unread` and `call.ringing`, in the recipient's language. While it is unset, `open_url` stays empty (null). |
| `links.profile_url_template` | `{external_id}` `{lang}` | Where the conversation header opens the peer's profile. While it is unset, the host gets a `navigate` event with reason `profile`, no `url` and the peer's `external_id`, and resolves the page itself. |
| `links.services_url_template` | `{external_id}` `{lang}` | Where a buyer opens a seller's services page. While it is unset, the host gets a `navigate` event with reason `services`, no `url` and the seller's `external_id`. |
| `commerce.checkout_url_template` | `{purchase_id}` `{item_id}` `{amount_minor}` `{currency}` `{seller_external_id}` `{buyer_external_id}` `{lang}` `{return_url}` | Used with `commerce.payment_mode` set to `checkout_url`: the purchase answers a redirect to it and the chat sends a `navigate` event with reason `checkout`, or opens it in a popup. While it is unset in that mode, no `navigate` event is sent: the purchase is refused with 403 `policy_denied` and reason `checkout_url_template`. |
| `commerce.topup_url_template` | same as checkout | Where a buyer goes when a charge answers `insufficient` and the callout gave no `topup_url`: a `navigate` event with reason `topup` and this `url`. While it is unset, the host gets a `navigate` event with reason `topup`, no `url`, and `purchase_id`, `amount_minor`, `currency` and the seller's `external_id`, so your site runs its own top-up. |
| `guests.nudge.signup_url` | `{lang}` | The nudge's sign-up button sends a `navigate` event with reason `signup`. While it is unset, that button is hidden, the invite to sign up is refused with 403 `policy_denied` and reason `invite`, and a notify prompt keeps its Notify me button. |
| `guests.nudge.login_url` | `{lang}` | The nudge's log-in button sends a `navigate` event with reason `login`. While it is unset, that button is hidden and no `navigate` event is sent. |

The nudge shows only while `guests.nudge.enabled` is on. `notify` navigations have no URL template: the host verifies the viewer and collects an address itself. See [Embed](/guides/embed/).

## Check it works

- `GET /v1/app/settings` returns your document, and its `ETag` is one higher after a `PUT`.
- A stale `If-Match` answers 412.
- The chat reflects a change: for example, a policy row with `call: true` shows the call button.

## When it fails

| Answer | Cause |
|---|---|
| 412 `precondition_failed` | `If-Match` is not the current version. `GET` again and resend. |
| 422 `validation` | An unknown key, a role that is not in `roles`, a template that is not https, or another cross-field rule. `errors[].loc` points at the setting. |
| 401 `unauthorized` | The secret or the app id is missing, wrong or revoked. |
| 403 `policy_denied` or `limit_reached` | Your settings forbid the user's action. `reason` names the rule. |

## Limits

- The whole document is replaced on every `PUT`, so send every key you want to keep.
- Each API process caches settings for 30 seconds, and a write clears the cache of the process that handled it.
- Every write is audited.
- `limits` and `onboarding.slides` keys must be roles, and `roles` needs at least one entry.

## Related topics

- [Settings reference](/reference/settings/)
- [Get your settings](/api/operations/get-own-settings/)
- [Replace your settings](/api/operations/put-own-settings/)
- [Errors](/errors/)
- [Embed](/guides/embed/)