Rules reference
The rules are one JSON document per product. You edit it in the portal, as a form
or as JSON; the SDK downloads it in InitializeAsync and
evaluates it on the device every time your app calls
TrackAsync.
A complete example
{
"version": 7,
"triggers": {
"session_end": {
"privateAsk": {
"minOccurrences": 5,
"minDaysSinceInstall": 3,
"cooldownDays": 60,
"skipIfSubmittedWithinDays": 180
}
},
"workout_completed": {
"storePrompt": {
"minOccurrences": 3,
"minDaysSinceInstall": 7,
"cooldownDays": 120,
"maxPerYear": 2,
"when": {
"appVersion": { "gte": "2.3.0" },
"plan": { "in": ["solo", "studio"] }
}
}
}
}
}
A user who has ended five sessions, three or more days after the SDK first ran, sees the private
ask — unless they sent a note in the last 180 days. A user who has completed three workouts, a week
or more in, on app version 2.3.0 or later and on one of the two named plans, is offered the store
prompt — at most twice in any 365 days, and never within 60 days of a note or 7 days of an
ask. Here appVersion and plan are attributes the app sets itself in
Attributes; conditions read only those, not the
AppVersion option.
The document
| Field | Meaning |
|---|---|
version | Set by the server on every save. You may send the version you last read; if the rules changed since, the save is refused with rules.conflict. Zero means no check. It is also the ETag devices revalidate with. |
storeIds | Copied from the product's settings on every save (androidPackage, iosAppId). Anything you send here is ignored; set them on the product page. |
triggers | An object: trigger name → what that trigger may cause. A name is lowercase letters, digits and underscores, 1 to 40 characters, and matches what your code passes to TrackAsync. |
privateAsk | Optional, per trigger. When to show the private ask. |
storePrompt | Optional, per trigger. When to ask the platform for its store prompt. A trigger must have at least one of the two. |
A product with no rules yet serves {"version":0,"triggers":{}}: nothing asks, nothing
fires.
Thresholds
Both blocks take these fields. "Occurrences" are counted on the device, one per
TrackAsync call for that trigger, and never reset. "Install" is
the first time the SDK ran on that device.
| Field | Default | Allowed | Meaning |
|---|---|---|---|
minOccurrences | 1 | 1–1000 | The trigger has happened at least this many times. |
minDaysSinceInstall | 0 | 0–365 | At least this many days since install. |
cooldownDays | 30 | 1–365 | At least this many days since this block last acted for this trigger (the ask was shown, or the prompt was asked for). |
when | none | up to 10 | Conditions on your attributes; see below. All must hold. |
One field only for privateAsk:
| Field | Default | Allowed | Meaning |
|---|---|---|---|
skipIfSubmittedWithinDays | 365 | 0–730 | Do not ask anyone who sent a note, from any trigger, within this many days. |
One field only for storePrompt:
| Field | Default | Allowed | Meaning |
|---|---|---|---|
maxPerYear | platform | 1–3 | The most store prompts in any rolling 365 days, counting every prompt the app asked for from any trigger. Left out, it is 2 on Android and 3 elsewhere. The app's MaxStorePromptsPerYear option can lower it further. |
Conditions
when maps an attribute key to one condition. The attributes
are the ones your app sets in Attributes. Each condition has
exactly one operator:
| Operator | Holds when the attribute… |
|---|---|
eq | is exactly this text. |
in | is exactly one of these texts (a non-empty list). |
gte | is greater than or equal to this value. |
lte | is less than or equal to this value. |
- An attribute the app did not set fails its condition.
eqandincompare text exactly, case included. A number or boolean attribute is compared as its invariant text; a boolean readsTrueorFalse.gteandltecompare either two dotted versions (two dots or more, such as2.3.0) or two decimals (10,0.75). A version against a decimal fails rather than guessing, so write2.3.0, not2.3, to compare versions. A value that is neither is refused when you save.- Attribute keys are letters, digits,
_and-, 1 to 40 characters.
How a decision is made
Each TrackAsync call counts one occurrence, then:
- If the loop has already shown an ask or fired a store prompt, nothing else happens for the rest of that loop's life — one action per session.
- If the trigger has a
privateAskand all its thresholds and conditions pass, and no note was sent withinskipIfSubmittedWithinDays, the ask is requested. The ask always comes first. - Otherwise, if the trigger has a
storePromptand all its thresholds and conditions pass, the engine then checks its fixed quiet periods and the cap, and asks the platform.
The engine's fixed limits apply whatever the rules say:
- At most 3 store prompts in any rolling 365 days, on every platform.
- No store prompt within 60 days of a note the user sent.
- No store prompt within 7 days of an ask the user was shown, whether they answered it or not.
Limits
A save is refused, with every problem listed, when the document breaks one of these:
- At most 50 triggers, each with a valid name and at least one block.
- At most 10 conditions per block, each with a valid key and exactly one operator.
- Each threshold within its allowed range (above).
The saved document must also be at most 65,536 characters long (rules.too_large). The
SDK runs the same validation on a downloaded document and keeps its cached rules if the new one
fails.
Reasons
When nothing happens, the Decision's
Reason is one of these stable strings, safe to log:
| Reason | Meaning |
|---|---|
no_rules | No rules yet, or none downloaded. |
unknown_trigger | The rules do not name this trigger. |
invalid_trigger | The name is not lowercase letters, digits and underscores, up to 40. |
below_min_occurrences | minOccurrences not reached. |
too_soon_after_install | minDaysSinceInstall not reached. |
cooldown | Inside cooldownDays. |
condition_failed | A when condition did not hold. |
recently_submitted | The user sent a note too recently. |
recent_ask | The user was shown an ask too recently. |
yearly_cap | The store-prompt cap for the last 365 days is reached. |
already_acted_this_session | This loop already asked or prompted. |
prompter_unavailable | This platform has no store prompt (the web, desktop). |
prompter_declined | The platform could not be asked. |
error | Something failed inside the loop; it never throws into your app. |