> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pushnotif.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Flows

> One notification rule - a pattern over a user's events, a message, and the limits it respects.

A flow reads like a sentence: *when a user adds their first connection and then
adds no second one for 48 hours, send "Your network is waiting".* You can build
flows on the **Flows** page, approve them from a [Composer](https://docs.pushnotif.ai/strategy/composer.md)
proposal, or have your own AI agent write them [over MCP](https://docs.pushnotif.ai/agents/mcp.md). They
all end up in the same format, checked by the same rules.

## Lifecycle

| Status | Means |
| - | - |
| `proposed` | Drafted, not running |
| `active` | Matching events and sending |
| `paused` | Stopped for now; can be activated again |
| `retired` | Stopped for good |

**Nothing sends until a person activates a flow.** Only owners and admins can.

## Steps

A flow's steps are a pattern over **one user's** events. Each user moves
through the steps on their own. When they reach the end, the flow sends.

> [accordion] await - wait for an event
>
> The user does something. Optional fields:
>
> * `where`: conditions the event must meet
> * `withinSeconds`: a deadline. If the event hasn't happened by then, the
>   user leaves the flow.
> * `afterSeconds`: the event only counts once this much time has passed
> * `bind`: save values from the event for later steps or for the message
>
> The first step is always an `await`: a flow starts when something happens.

> [accordion] absent - wait for an event not to happen
>
> The user does **not** do something for `forSeconds`. This is how you write
> "hasn't opened the app in 3 days". The clock starts when the user reaches
> this step.

> [accordion] wait - let time pass
>
> Pause for `forSeconds`. This is how you write "2 hours after".

### Example

"A user added their first connection, then added none for 48 hours":

```json theme={null}
{
  "key": "nudge-second-connection",
  "name": "Nudge toward a second connection",
  "steps": [
    {
      "id": "first",
      "type": "await",
      "event": "connection_added",
      "bind": { "name": "contact_name" }
    },
    {
      "id": "quiet",
      "type": "absent",
      "event": "connection_added",
      "forSeconds": 172800
    }
  ],
  "maxRunAgeSeconds": 259200,
  "constraints": {
    "quietHours": { "start": 21, "end": 9 },
    "maxPerWeek": 2
  },
  "messages": [
    {
      "title": "Keep it going",
      "body": "You connected with {{ bound.name }}. Who's next?",
      "deepLink": "myapp://connections/new"
    }
  ]
}
```

`maxRunAgeSeconds` is the longest a user can sit part-way through the flow
before they're dropped. Set it well above the total of the steps' windows.

### Branches

Steps can also branch. Give a step a `next` list of the step ids that may
follow it. The first one satisfied wins. Each branch ends on a `send` step with
its own message. Without `next` on any step, steps simply run in order.

## Conditions

A `where` list holds conditions on the event's properties. All of them must be
true.

```json theme={null}
{ "property": "score", "op": "gte", "value": 80 }
```

| `op` | Matches when the property... |
| - | - |
| `eq`, `neq` | equals, or doesn't equal, the value |
| `gt`, `gte`, `lt`, `lte` | is a number greater or less than the value |
| `contains` | contains the value |
| `exists`, `not_exists` | is present, or missing (no value needed) |

`property` can be a dotted path into nested properties, like `metadata.tier`.
To compare against a value saved by an earlier step, use `valueRef` instead of
`value`, for example "the same `order_id` as the cart event".

> [!WARNING]
>
> Types are never converted. `"80"` (a string) is not `80` (a number), so
> `{ "op": "gte", "value": 80 }` never matches a property sent as `"92"`. A
> missing property never matches either, except with `not_exists`.

## Messages

```json theme={null}
{
  "title": "Lesson done",
  "subtitle": null,
  "body": "You scored {{ event.score }}. Ready for the next one?",
  "deepLink": "myapp://lesson/{{ event.lesson_id }}",
  "data": { "screen": "lesson", "lesson_id": "{{ event.lesson_id }}" }
}
```

Title and subtitle are at most 120 characters, and the body at most 400.
A variable is written as `{{ path }}`, and can read:

| Variable | Value |
| - | - |
| `event.<property>` | the event that completed the pattern |
| `bound.<name>` | a value an earlier step saved with `bind` |
| `user.id`, `user.timezone` | the user |
| `lookup.<key>.<column>` | a column from one of the flow's [lookups](https://docs.pushnotif.ai/strategy/lookups.md) |

**A send whose variable is missing is skipped, not sent with a blank.** "Nice
work, !" is worse than no push. The skip is logged with its reason.

### Custom data

`data` holds keys and values for your app's own code, like which screen to
refresh. It is delivered with the push but never shown to the user. Leave it
out, or set it to `null`, when the message has none.
[Read it in your app](https://docs.pushnotif.ai/sdk/push-handling.md#custom-data).

* Values are text and can use variables, the same as the copy. A value whose
  variable is missing skips the send, like any other field.
* At most 20 keys per message. A value is at most 256 characters.
* A key is letters, digits and underscores, starts with a letter or
  underscore, and is at most 64 characters.
* `aps`, `pnai` and `deep_link` are used by the push itself, so they can't be
  keys.

A push can be at most 4 KB, which Apple enforces. Variables are filled in when
the flow decides to send, so a long value can push a message over that limit
even though the flow saved fine. Such a send is cancelled before it goes out,
with the reason `message_too_large`.

### When it's filled in

The message is filled in when the flow decides to send. A send delayed by
quiet hours still goes out with the copy and data from the moment it was
decided, even if you edit the flow in between.

### Copy tests

Give a send up to 5 message variants. Each send picks one at random and
records which, and the flow page compares how each one does. Keep a variant's
`key` when you reword it, so its history stays attached.

## Limits

| Constraint | Effect |
| - | - |
| `quietHours` `{ start, end }` | In the user's local time. A send that lands inside is **delayed** until the window ends. |
| `cooldownSeconds` | Least time between two sends from **this flow** to the same user. A send inside it is **cancelled**. |
| `maxPerDay`, `maxPerWeek` | Most sends to one user across **all flows**. A send over the cap is **cancelled**. |

Every cancelled send is recorded with its reason, so you can see what a flow
would have done. See [Debug a user](https://docs.pushnotif.ai/dashboard/debugging-a-user.md).

## When flows compete

If one event completes several flows for the same user, the flow with the
highest `priority` sends and the others are recorded as suppressed. Priority
defaults to 0.

If a user is already part-way through a flow and its first step matches again,
`onOverlap` decides: `ignore` (the default) keeps the run they're in, and
`restart` starts them over.

## Right before sending

A send scheduled for later is checked again when its time comes: is the flow
still active, is the user still under their caps and outside their quiet
hours? A send that no longer qualifies is dropped, not sent, and the reason is
recorded.

Each flow shows at most one notification on a user's lock screen: a newer send
from the same flow replaces an older one still showing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.