# Contact consent

> When your app can opt a user in to email, the limits on addresses from your app, and how your backend confirms consent with the server key.

Whisperr sends a message only to a contact with a recorded opt-in. This page explains who can record that opt-in for email: your backend with the server key, and your app with the publishable key when you allow it for the app. It also covers the limits on addresses from the app, and how push consent follows the device permission.

> Your backend can always opt a user in with the server key. Your app can opt a user in with the publishable key only when you told Whisperr that your signup gets permission to email users.

## Does your signup get permission to email users?

Whisperr asks this during setup. Your answer decides what happens to an email address that your app or website sends with the publishable key:

| Answer       | Setting value | An email address from your app                                                                                                                                              |
| ------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Yes**      | `assumed`     | Is opted in, unless the app sends `optedIn: false`. Use it when every user who signs up agrees to get email.                                                                |
| **Per user** | `per_user`    | Is opted in only when the app sends `optedIn: true` (on the API: `opted_in: true` with `consent_source: "user"`). Use it when each user makes their own choice in your app. |
| **No**       | `off`         | Is stored as **pending**: not opted in. Your backend confirms it with the server key.                                                                                       |
| Not answered | none          | The same as **No**.                                                                                                                                                         |

With every answer, an earlier opt-out, including the unsubscribe link and bounce or complaint suppression, is kept. A later identify from the app never opts the address back in.

The setting is per app. The API name is `client_email_consent`. You can answer it or change it in **Settings → API keys**.

When you change your answer, or turn off **Require a server key**, Whisperr checks the pending addresses from your app again under the new answer. These re-checks do not count toward the hourly limit below. The other limits still apply: one user per address, and earlier opt-outs and suppression are kept. It uses the choice that the app sent with each address. An address stored before Whisperr kept that choice is not checked again: the next identify of that user from the app applies the new answer.

In the app, send the user’s choice on the email channel:

`app.ts`

```ts
// In the app (publishable key).
whisperr.identify(user.id, {
  channels: [{ type: "email", address: user.email, optedIn: user.agreedToEmail }],
});
```

The client SDKs also take the shortcut `email` with `emailOptedIn`, for example `identify(user.id, { email: user.email, emailOptedIn: true })`. It sends the same channel. Leave `emailOptedIn` out when the app does not know the user’s choice.

| SDK                                                           | Version with app-side consent |
| ------------------------------------------------------------- | ----------------------------- |
| `@whisperr/react-native` (React Native and Expo)              | 0.6.0 or later                |
| `@whisperr/web` (also `@whisperr/react` and `@whisperr/next`) | 0.4.0 or later                |
| Flutter `whisperr`                                            | 0.7.0 or later                |
| Swift `Whisperr`                                              | 0.6.0 or later                |

Earlier versions cannot set app-side consent. Whisperr stores an email address they send as pending, whatever your answer. Older Web, Flutter and Swift versions send `opted_in: true` on the `email` shortcut by default, so Whisperr does not treat it as the user’s choice.

`optedIn: false` records an opt-out with any key and any answer.

## Which key can do what

| Key                       | Where it runs     | Email contacts                                                                                                                                                |
| ------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Publishable key (`wpk_…`) | Apps and browsers | Can send an address. Whether it is opted in depends on your answer above. Can record an opt-out (`opted_in: false`). Cannot change a user’s existing address. |
| Server key (`wrk_…`)      | Your backend only | Can mark an address verified and opted in, whatever your answer. Can record an opt-out. Can change a user’s existing address.                                 |

- Whisperr ignores `verified: true` from the publishable key.
- Push tokens are different. See [Push notifications](#push-notifications).
- Whisperr sends push and email only. SMS is not available.

## Limits on addresses from your app

Anyone can read a publishable key from your app. So Whisperr limits what the publishable key can do with email, whatever your answer:

- **One address per user.** The app cannot change a user’s existing email address. Whisperr answers `403` (`contact_change_requires_server_key`). Change it from your backend with the server key.
- **One user per address.** When another user in your app already has the address opted in, the app cannot opt it in for a second user. Whisperr stores it as pending.
- **Rate limit.** By default, the app can opt in up to 200 new email addresses per app in any rolling hour. Whisperr stores addresses over the limit as pending. The identify call still succeeds, and a later identify of the same address tries the opt-in again.
- **Earlier opt-outs stay.** An earlier opt-out, including the unsubscribe link and bounce or complaint suppression, is kept. A later identify from the app never opts the address back in.
- **Bounces and complaints.** A hard bounce opts the address out. A spam complaint opts the user out of email. Whisperr gets both from your email provider’s webhook. See [Connect an email provider](/integrations/email-providers/#add-the-delivery-reports-webhook).
- **Unsubscribe.** Every email has an unsubscribe link. A user who unsubscribes is opted out of email. See [How does unsubscribe work?](/integrations/email-providers/#how-does-unsubscribe-work)

An address stored as pending gets no email until your backend confirms it with the server key.

## Require a server key

**Require a server key**, in **Settings → API keys**, is a stricter mode. When it is on, only your server can opt an email address in. Addresses your app sets get no messages until your server confirms them, whatever your answer above. When it is off, your answer above decides.

A common setup with a backend: the app sends the address with the publishable key, and your backend confirms consent for the same user id with the server key.

## Confirm consent with identify

A server-key `identify` opts an email contact in, whatever your answer to the consent question. Call it from a server SDK ([Node](/sdks/node/), [Python](/sdks/python/), [PHP](/sdks/php/), [.NET](/sdks/dotnet/)) for the same user id and address. Send `optedIn: true` only when the user agreed to email. Send `verified: true` only when your app confirmed the address, for example with a confirmation link or a one-time code. A server-key event with `user.channels` works the same way.

`server.ts`

```ts
import { createWhisperr } from "@whisperr/node";


// Runs on your backend. WHISPERR_SERVER_KEY is the server key (wrk_…).
const whisperr = createWhisperr({ apiKey: process.env.WHISPERR_SERVER_KEY! });


whisperr.identify(user.id, {
  channels: [
    { type: "email", address: user.email, optedIn: user.emailConsent, verified: user.emailVerified },
  ],
});
```

The same request with curl:

```bash
curl https://api.whisperr.net/v1/identify \
  -H "X-API-Key: $WHISPERR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_user_id": "user_8842",
    "channels": [
      { "channel": "email", "address": "ada@example.com", "opted_in": true, "verified": true }
    ]
  }'
```

What the server key does when you leave a field out:

- **`opted_in` omitted:** opts in a new address, and confirms a pending address that the app sent first. The order of the two calls does not matter. An address that already holds a decision keeps it.
- **`verified` omitted:** counts as `true`, unless the address was last declared `false`.

> The Node, Python, PHP and .NET SDKs send `opted_in: true` for a channel when you leave `optedIn` out, and so do their `email` shortcuts. Send a channel from these SDKs only when you hold consent for it, or pass `optedIn: false`.

See [Identify users](/api/identify/) for the full request.

## Record consent proof with a consent assertion

Use `POST /v1/consent/assertions` to record the proof of a consent choice, or an opt-out, for one channel of a user. For example, record which form or settings toggle the user used. It accepts only the server key.

An assertion does **not** make a pending contact sendable by itself. Whisperr also needs the address opted in, by a server-key `identify` or by your app under your consent answer. To confirm consent from your backend, send the identify above. Add an assertion when you want to store the proof.

```bash
curl https://api.whisperr.net/v1/consent/assertions \
  -H "Authorization: Bearer $WHISPERR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_user_id": "user_8842",
    "channel": "email",
    "status": "opted_in",
    "basis": "explicit_user_action",
    "asserted_at": "2026-10-12T09:30:00Z",
    "evidence_ref": "settings_toggle:evt_7f3a"
  }'
```

| Field              | Type             | Notes                                                                                                                                     |
| ------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `customer_user_id` | string, required | Your user id, the same `external_user_id` you send on identify. The user must already exist in Whisperr.                                  |
| `channel`          | string, required | `email` or `push`.                                                                                                                        |
| `status`           | string, required | `opted_in`, `opted_out` or `unknown`.                                                                                                     |
| `basis`            | string, required | `explicit_user_action`: the user did something, such as tick a box. `customer_declared`: your records say so, with no single user action. |
| `asserted_at`      | string, required | When the user made the choice, RFC 3339. Not more than 5 minutes in the future.                                                           |
| `evidence_ref`     | string, required | Your reference to the proof, such as a form submission id. 1 to 512 characters. Do not put personal data in it.                           |

Responses from `POST /v1/consent/assertions`:

| Status | Body               | Meaning                                                                                     |
| ------ | ------------------ | ------------------------------------------------------------------------------------------- |
| `200`  | `{ "ok": true }`   | Recorded.                                                                                   |
| `400`  | `{ "error": "…" }` | A field is missing, unknown or not valid.                                                   |
| `401`  | `{ "error": "…" }` | The key is missing, revoked, or a publishable key.                                          |
| `404`  | `{ "error": "…" }` | Whisperr does not know the user yet. Send `identify` first, then retry after a few seconds. |

The choice with the latest `asserted_at` wins. An exact tie counts as `opted_out`. After a user unsubscribes from a Whisperr message, only a newer `explicit_user_action` opt-in from your backend with the server key, or the user’s own choice through Whisperr, lets Whisperr message them again. Nothing your app sends with the publishable key lifts an unsubscribe.

## Push notifications

Push consent comes from the device. The mobile SDKs send a push token opted in only while the OS reports notification permission. When the user turns notifications off in the OS, the SDK opts the token out.

Apple’s App Review guideline 4.5.4 asks for more when pushes can carry promotions. This is product guidance, not legal advice:

- Show a consent line at the push permission prompt. Say that notifications may include offers and reminders.
- Give users an in-app toggle to turn these notifications off.
- When the user turns the toggle off, report the permission as `denied` to the SDK. The SDK opts the push token out and holds it. When the user turns the toggle on again, report the real OS status.

| SDK                                                 | Toggle off                                         | Toggle on                        |
| --------------------------------------------------- | -------------------------------------------------- | -------------------------------- |
| [Swift](/sdks/swift/#push-permission)               | `pushPermissionChanged(.denied)`                   | `refreshPushPermission()`        |
| [React Native](/sdks/react-native/#push-permission) | `setPushPermission("denied")`                      | `setPushPermission(<OS status>)` |
| [Flutter](/sdks/flutter/#push-permission)           | `setPushPermission(WhisperrPushPermission.denied)` | `setPushPermission(<OS status>)` |

## Common questions

### Why does Whisperr not email a user my app identified?

An email address sent with the publishable key is opted in only when you told Whisperr that your signup gets permission to email users (Yes, or Per user with optedIn: true), Require a server key is off, and your SDK version supports app-side consent. Even then Whisperr keeps it pending when another user in the app already has the address, when the app hit the hourly limit on new addresses, or when the user opted out earlier. Your backend can confirm it with a server-key identify.

### Can an app without a backend email its users?

Yes. Answer Yes or Per user to Does your signup get permission to email users? Then an address that the app sends with identify can be opted in, within the limits on this page.

### Do the server SDKs opt users in by default?

Yes. The Node, Python, PHP and .NET SDKs send opted_in: true for a channel when you leave optedIn out. Send a channel from them only when you hold consent for it, or pass optedIn: false.

### Does a consent assertion make an address sendable?

No. POST /v1/consent/assertions stores proof of a choice. Whisperr also needs the address opted in, by a server-key identify or by your app under your consent answer.

### Can a user who unsubscribed be opted in again?

Only by your backend with the server key, with a newer opt-in with basis explicit_user_action, or by the user through Whisperr. A customer_declared opt-in cannot reverse an unsubscribe from a Whisperr message, and nothing your app sends with the publishable key can.

Source: https://docs.whisperr.net/concepts/consent/
