# Notify

`POST` `https://api.pushwoosh.com/messaging/v2/notify`

Creates and schedules a single message.

## Request structure

The request body is a `NotifyRequest` with exactly one of two kinds:

- [`segment`](#notifysegment): target an audience segment by segment code, or — without creating a segment first — a [seglang](/developer/api-reference/segmentation-filters-api/segmentation-language/) expression or a structured filter expression.
- [`transactional`](#notifytransactional): send to an explicit list of hwids, user IDs, push tokens, or test devices.

```json title="Shape"
{
  "segment": { ... },      // OR
  "transactional": { ... },
  "transaction_id": "unique-uuid"
}
```

| Field | Type | Description |
|---|---|---|
| `transaction_id` | string | Optional. Idempotency key for the request — works with both `segment` and `transactional`. A repeat call with the same `transaction_id` within 5 minutes returns the original `message_code` instead of sending a duplicate message. Use a UUID or another value unique per logical send. |

<Aside type="tip">
Pass an `X-Request-ID` header to correlate the created message with your request for end-to-end tracing — see [Request ID](/developer/api-reference/api-identifiers/#request-id).
</Aside>

<Aside type="caution" title="Only the key is checked, not the payload">
Deduplication matches on `transaction_id` alone. If a retry reuses the same `transaction_id` but sends a different `segment` / `transactional` body, the original `message_code` is still returned and the new body is silently discarded — it is not compared against the first request. Never reuse a `transaction_id` across logically different messages.
</Aside>

<Aside type="tip">
This is the same deduplication mechanism as `transactionId` on the legacy [Messages API](/developer/api-reference/messages-api/), sharing the same 5-minute storage window, so a retried request is deduplicated across `/createMessage` and `Notify` alike. It is unrelated to the `transaction_id` attribute on [`PW_Conversion`](/product/audience-data-and-segmentation/events/conversion-events/#attributes-for-the-postevent-call) revenue events, which Pushwoosh stores as-is and does not use for deduplication.
</Aside>

## NotifySegment

Targets users who match an audience segment or filter expression. Set exactly one of `code`, `expression`, or `filter_expression`. For `expression` and `filter_expression`, no segment needs to be created beforehand — the expression is evaluated inline for this send only.

<Aside type="note" title="Choosing between code and inline expressions">
Only a saved segment referenced by `code` can use [high-speed delivery](/product/resources/high-speed-delivery/) precompilation. `expression` and `filter_expression` are evaluated at send time and are never precompiled.
</Aside>

| Field | Type | Description |
|---|---|---|
| `schedule` | [`Schedule`](#schedule) | When and how to send. Required. |
| `application` | string | [Application code](/developer/api-reference/api-identifiers/#application-code). |
| `platforms` | array of [`Platform`](#platform-enum) | Platforms the message targets. |
| `code` | string | [Segment code](/developer/api-reference/api-identifiers/#segment--filter-code) of a segment saved in advance. Mutually exclusive with `expression` and `filter_expression`. |
| `expression` | string | [Seglang](/developer/api-reference/segmentation-filters-api/segmentation-language/) expression, evaluated for this send only — no saved segment required. Mutually exclusive with `code` and `filter_expression`. |
| `filter_expression` | `FilterExpression` | The same logic as `expression`, as a structured object instead of a seglang string. Mutually exclusive with `code` and `expression`. The `FilterExpression` schema isn't publicly documented — request it from Pushwoosh support if you need the structured form. |
| `payload` | [`Payload`](/developer/api-reference/messaging-api-v2/payload-reference/) | Push / SMS / Telegram / Kakao / LINE / WhatsApp / Viber / Facebook Messenger payload. Mutually exclusive with `email_payload`. |
| `email_payload` | [`EmailPayload`](/developer/api-reference/messaging-api-v2/email-payload-reference/) | Email payload. |
| `campaign` | string | [Campaign code](/developer/api-reference/api-identifiers/#campaign-code) to attribute this message to. |
| `campaign_name` | string | Internal name for this message, shown as the row title in Message History and in its export. Unrelated to `campaign` above. It doesn't group messages or affect delivery. Max 255 characters, trimmed. Omit it or leave it empty to keep the row titled from the payload's own content instead. |
| `frequency_capping` | [`FrequencyCapping`](#frequencycapping) | Per-user frequency limits. |
| `send_rate` | [`SendRate`](#sendrate) | Throttling for the send. |
| `message_type` | [`MessageType`](#messagetype-enum) | `MESSAGE_TYPE_MARKETING` (default) or `MESSAGE_TYPE_TRANSACTIONAL`. Controls control-group filtering. |
| `dynamic_content_placeholders` | map&lt;string, string&gt; | Replaces placeholders in content. |
| `meta_data` | object | Free-form metadata forwarded to downstream analytics. |
| `use_latest_user_device` | bool | When `true`, delivers the message to each user's most recently active device (the one with the latest Last Application Open) instead of every device matched by the segment. Scoped to `platforms`: only devices on those platforms are considered, and if none has Last Application Open data, the first matching device is used instead of dropping the send. Defaults to `false` (send to every device). |
| `control_group` | string | Code or name of a send's own hold-out group to apply to this message. It doesn't narrow which [control groups](/product/audience-data-and-segmentation/control-groups/) apply: every enabled control group of the application holds users out of marketing sends whether this field is set or not. Mutually exclusive with `holdout_percentage`. |
| `holdout_percentage` | uint32 | Draws this share of the segment, `1`–`20`, into a control group scoped to this send, on top of the application's enabled control groups, to measure a single campaign's impact without spending down the shared ones. `0` (default) leaves it off. Mutually exclusive with `control_group`. |

<Aside type="caution" title="Important">
Pushwoosh names the holdout `__holdout_<campaign code>` (or `__holdout_<message code>` for a send with no `campaign`) and reuses that same group, at its original percentage, for every later send sharing the code. The draw freezes the first time it's created. This group never appears in **Settings > Control groups**, and resizing, reshuffling, disabling, or deleting it returns `FailedPrecondition`, because changing it mid-measurement would invalidate the comparison.
</Aside>

Devices drawn into the holdout are excluded from delivery the same way as by the application's [control groups](/product/audience-data-and-segmentation/control-groups/#how-the-control-group-affects-message-delivery). They aren't sent to, and the tracking log records the drop as `Device is in control group`. A `message_type` of `MESSAGE_TYPE_TRANSACTIONAL` skips this exclusion entirely, same as it does for the application's control groups.

Setting `holdout_percentage` together with `control_group`, or a `holdout_percentage` above `20`, returns `400`:

```json title="Invalid holdout_percentage"
{
  "code": 3,
  "message": "invalid argument",
  "details": [
    {
      "@type": "type.googleapis.com/google.rpc.BadRequest",
      "fieldViolations": [
        {
          "field": "holdout_percentage",
          "description": "holdout_percentage and control_group are mutually exclusive"
        }
      ]
    },
    {
      "@type": "type.googleapis.com/google.rpc.ErrorInfo",
      "reason": "InvalidHoldoutPercentage",
      "domain": "api.pushwoosh.com",
      "metadata": {}
    }
  ]
}
```

### Example: Send to a segment

```bash
curl -X POST https://api.pushwoosh.com/messaging/v2/notify \
  -H "Authorization: Token YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "segment": {
      "application": "XXXXX-XXXXX",
      "platforms": ["IOS", "ANDROID"],
      "code": "active_users",
      "payload": {
        "content": {
          "localized_content": {
            "en": {
              "ios":     { "body": "Hello!" },
              "android": { "body": "Hello!" }
            }
          }
        }
      },
      "schedule": { "at": "2026-05-01T12:00:00Z" },
      "message_type": "MESSAGE_TYPE_MARKETING"
    }
  }'
```

## NotifyTransactional

Sends to an explicit list of recipients.

| Field | Type | Description |
|---|---|---|
| `schedule` | [`Schedule`](#schedule) | Required. |
| `application` | string | [Application code](/developer/api-reference/api-identifiers/#application-code). |
| `platforms` | array of [`Platform`](#platform-enum) | Platforms the message targets. |
| `test_devices` | bool | If `true`, send to the app's test devices only. |
| `hwids` | `{ "list": [string, ...] }` | Send to these [hwids](/developer/pushwoosh-knowledge-hub/device-identifiers/#hwid) only. |
| `users` | `{ "list": [string, ...] }` | Send to these [user IDs](/developer/pushwoosh-knowledge-hub/users-userids/) only. |
| `push_tokens` | `{ "list": [string, ...] }` | Send to these [push tokens](/developer/pushwoosh-knowledge-hub/device-identifiers/#push-token) only. |
| `payload` | [`Payload`](/developer/api-reference/messaging-api-v2/payload-reference/) | Push / SMS / Telegram / Kakao / LINE / WhatsApp / Viber / Facebook Messenger payload. |
| `email_payload` | [`EmailPayload`](/developer/api-reference/messaging-api-v2/email-payload-reference/) | Email payload. |
| `return_unknown_identifiers` | bool | When `true`, the response's `unknown_identifiers` lists identifiers that were not found. |
| `use_latest_user_device` | bool | Only applies when you target `users`. Same behavior as in `NotifySegment` above — delivers one message per user instead of one per device, scoped to `platforms`. Defaults to `false`. |
| `campaign`, `campaign_name`, `frequency_capping`, `send_rate`, `message_type`, `dynamic_content_placeholders`, `meta_data` | | See `NotifySegment` above. |

`test_devices`, `hwids`, `users`, and `push_tokens` are mutually exclusive. Exactly one must be set.

### Example: Transactional by user IDs

```bash
curl -X POST https://api.pushwoosh.com/messaging/v2/notify \
  -H "Authorization: Token YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "transactional": {
      "application": "XXXXX-XXXXX",
      "platforms": ["IOS", "ANDROID"],
      "users": { "list": ["user-123", "user-456"] },
      "payload": {
        "content": {
          "localized_content": {
            "en": { "ios": { "body": "Your order has shipped." } }
          }
        }
      },
      "schedule": { "at": "2026-05-01T12:00:00Z" },
      "message_type": "MESSAGE_TYPE_TRANSACTIONAL",
      "return_unknown_identifiers": true,
      "use_latest_user_device": true
    }
  }'
```

## Response

```json
{
  "result": {
    "message_code": "XXXXX-XXXXX-XXXXX",
    "unknown_identifiers": []
  }
}
```

| Field | Type | Description |
|---|---|---|
| `message_code` | string | Unique [message code](/developer/api-reference/api-identifiers/#message-code). Use it with [`/getMessageDetails`](/developer/api-reference/messages-api/#getmessagedetails) and message statistics endpoints. |
| `unknown_identifiers` | array of string | Identifiers not found on the account. Populated only when `return_unknown_identifiers: true` was set on the `transactional` kind. |

## Shared types

### Schedule

```json
{
  "at": "2026-05-01T12:00:00Z",
  "follow_user_timezone": true,
  "past_timezones_behaviour": "PAST_TIMEZONES_BEHAVIOUR_SEND_IMMEDIATELY"
}
```

| Field | Type | Description |
|---|---|---|
| `at` | timestamp | Absolute send time (RFC 3339). If in the past, Pushwoosh substitutes the time the request was received. With `follow_user_timezone` unset or `false`, this means the message goes out immediately to everyone. See the caution below for `follow_user_timezone: true`. Maximum 30 days in the future. |
| `after` | duration | Alternative to `at`. Send after this offset from "now" (e.g. `"3600s"`). Maximum 30 days. |
| `follow_user_timezone` | bool | When `true`, each device receives the message at `at` in its local timezone. |
| `past_timezones_behaviour` | enum | `PAST_TIMEZONES_BEHAVIOUR_SEND_IMMEDIATELY` (default), `PAST_TIMEZONES_BEHAVIOUR_DO_NOT_SEND`, or `PAST_TIMEZONES_BEHAVIOUR_NEXT_DAY`. Only meaningful when `follow_user_timezone` is `true` or `best_time_to_send` is set. |
| `best_time_to_send` | timestamp | Optional. Segment sends only. Each device gets the message at its own best hour within 24 hours of this time. Devices without a best hour get it at the date and time of `at` / `after` in their local timezone. See [Best time to send](#best-time-to-send). |

<Aside type="caution" title="A past send time isn't instant for everyone with follow_user_timezone">
With `follow_user_timezone: true`, the substituted "now" is still split into per-device local-time slots, not delivered to every device at once. Timezones behind UTC haven't reached that moment locally yet, so they receive the message up to 12 hours later. Timezones ahead of UTC have already passed that moment locally, so they follow `past_timezones_behaviour` instead: sent now, skipped (`DO_NOT_SEND`), or pushed a day forward (`NEXT_DAY`) at that same local time. The same split applies to the [`best_time_to_send`](#best-time-to-send) fallback.
</Aside>

#### Best time to send

`best_time_to_send` is the API counterpart of the [Best time](/product/messaging-channels/push-notifications/send-push-notifications/one-time-push/#best-time-paid-subscription-required) option in the Control Panel. `at` / `after` sets the fallback for devices without enough data for a best hour. Pushwoosh takes the UTC date and time of `at` / `after` and delivers the fallback at that date and time in each device's local timezone, as with `follow_user_timezone: true`. This applies implicitly: leave `follow_user_timezone` unset, since `true` is rejected with `BestTimeToSendWithUserTimezone`. In the example below, devices without a best hour receive the message at 18:00 local time on May 1. Where that local time has already passed when the send starts, `past_timezones_behaviour` applies.

```json
{
  "at": "2026-05-01T18:00:00Z",
  "best_time_to_send": "2026-05-01T00:00:00Z"
}
```

Pushwoosh rejects the request with `400` unless all of these hold:

- The request uses the `segment` kind. `transactional` sends don't support `best_time_to_send`.
- `follow_user_timezone` is unset or `false`.
- The send time from `at` / `after` is in the future. A past `at`, `after: "0s"`, or an omitted `after` count as "send now" and are rejected.
- `best_time_to_send` is no earlier than the next full hour and no more than 30 days ahead. It is rounded down to the full hour.
- The send time from `at` / `after` is no later than `best_time_to_send` plus 24 hours.

The error's `reason` (in the `ErrorInfo` detail) names the failed check:

| `reason` | Cause |
|---|---|
| `BestTimeToSendWithUserTimezone` | `follow_user_timezone` is `true`. |
| `BestTimeToSendTooEarly` | `best_time_to_send` is earlier than the next full hour. |
| `BestTimeToSendTooFarInFuture` | `best_time_to_send` is more than 30 days ahead. |
| `SendDateAfterBestTimeToSend` | `at` / `after` is later than `best_time_to_send` plus 24 hours. |
| `BestTimeToSendNotAllowed` | `best_time_to_send` is set on a `transactional` send. |
| `BestTimeToSendWithSendNow` | `at` / `after` resolves to "now" (past `at` or zero `after`). |

### FrequencyCapping

Per-user frequency limits for marketing sends. To disable capping, omit `frequency_capping` entirely, or send `days: 0` together with `count: 0`.

```json
{ "days": 7, "count": 3, "exclude": false, "avoid": true }
```

- `days` (int, 1–30, or `0` to disable capping): look-back window. Must be sent together with `count` — if one is `0`, the other must also be `0`; sending one as `0` while the other is nonzero returns `400`.
- `count` (int, 1 or higher, or `0` to disable capping): maximum messages allowed within `days`. Same pairing rule as `days` above.
- `exclude` (bool): hard-exclude users who have already hit the cap.
- `avoid` (bool): soft-avoid users who have already hit the cap (they still count toward analytics).

<Aside type="caution" title="Important">
Sending `days` and `count` with mismatched zero-ness (e.g. `{"days": 0, "count": 5}`) returns `400`. This validation is a breaking change to `Notify` — clients that previously relied on a lone `0` being silently ignored will now get an error instead.
</Aside>

### SendRate

```json
{ "value": 500, "bucket": "1s", "avoid": false }
```

Throttles the send. `value` is messages per `bucket`; typical `bucket` is `"1s"`.

### Platform enum

`IOS`, `ANDROID`, `OSX`, `WINDOWS`, `AMAZON`, `SAFARI`, `CHROME`, `FIREFOX`, `IE`, `EMAIL`, `HUAWEI_ANDROID`, `SMS`, `WEB`, `KAKAO`, `TELEGRAM`, `LINE`, `WHATS_APP`, `VIBER`, `FB_MESSENGER`.

### MessageType enum

- `MESSAGE_TYPE_UNSPECIFIED`: equivalent to `MESSAGE_TYPE_MARKETING`.
- `MESSAGE_TYPE_MARKETING`: subject to control-group filtering and frequency capping.
- `MESSAGE_TYPE_TRANSACTIONAL`: skips control-group filtering and frequency capping. Use for order confirmations, OTPs, and similar critical flows.

## Related

<CardGrid>
  <LinkCard title="Notify batch" href="/developer/api-reference/messaging-api-v2/notify-batch/" />
  <LinkCard title="Cancel" href="/developer/api-reference/messaging-api-v2/cancel/" />
  <LinkCard title="Payload reference" href="/developer/api-reference/messaging-api-v2/payload-reference/" />
  <LinkCard title="Email payload reference" href="/developer/api-reference/messaging-api-v2/email-payload-reference/" />
  <LinkCard title="Migration from v1" href="/developer/api-reference/messaging-api-v2/migration-from-v1/" />
</CardGrid>