In-App Messages
In-app campaigns over the API — formats, states, triggers, blocks, the event funnel, and capability negotiation by SDK version.
In-App Messages
An in-app message is the card that appears inside your app, while the person is already there — no notification permission required. It is the channel that still works with the users who blocked push.
Everything the dashboard does, this API does: the management routes call the same core the interface does. There is no dashboard-only feature — you can create, publish, measure and end campaigns entirely from your own code, with the same key you use for push.
A note on language: field names and error text are emitted in Portuguese by the service. They are shown here exactly as they travel on the wire, so what you read matches what your logs will hold. Branch on field names and HTTP status codes — never on the prose.
The model in one sentence
The server segments and signs; the device decides to display.
When the app opens, it fetches a session package holding the messages that are eligible for that device — each one already carrying its complete rules (trigger, caps, priority, window) and a signed proof. The package is valid on the device for up to 24 hours, and the device evaluates the trigger locally, with no round trip at display time.
That explains three behaviours that surprise anyone expecting a server-commands model:
| Consequence | What it means in practice |
|---|---|
| Pausing applies to new packages | someone who already holds a package may still see the message for up to 24 h |
| The funnel only advances on proven receipts | numbers may undercount, never inflate |
| “served” is never “displayed” | served = entered a package; displayed = actually drawn on screen |
Quick start
1. Turn the channel on (it ships off)
This is the number one cause of “I created the campaign and nothing shows up”. The In-App channel is off on every new app — deliberately, so you can prepare everything without anyone receiving it.
curl -X PUT https://api.pushmesh.io/api/v1/in_app_messages/config \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "Content-Type: application/json" \
-d '{ "app_id": "'"$APP_ID"'", "inapp_ativo": true }'
{ "inapp_ativo": true, "inapp_cap_horas": 6 }
2. Create the campaign (it starts as a draft)
curl -X POST https://api.pushmesh.io/api/v1/in_app_messages \
-H "Authorization: Basic $PUSHMESH_KEY" \
-H "Content-Type: application/json" \
-d '{
"app_id": "'"$APP_ID"'",
"nome": "welcome",
"formato": "modal",
"alvo_tipo": "todos",
"alvo": [],
"conteudo": {
"pt": {
"titulo": "Good to see you here",
"corpo": "Turn alerts on and know the moment your order ships.",
"botao": { "texto": "Turn alerts on", "acao": "open_notification_settings" }
}
},
"gatilho": { "tipo": "ao_abrir", "atraso_ms": 2000 },
"regras": { "cap_total": 1, "cooldown_h": 24 }
}'
Returns 201, with estado: "rascunho" (draft). Creating never publishes.
3. Check the reach before publishing
curl "https://api.pushmesh.io/api/v1/in_app_messages/$CID/alcance?app_id=$APP_ID" \
-H "Authorization: Basic $PUSHMESH_KEY"
{
"campanha_id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40",
"alvo_tipo": "todos",
"alcance_estimado": 48120,
"schema_min_efetivo": 1,
"alcancaveis_agora": 47010,
"precisam_app_atualizado": 1110,
"sem_versao_reportada": 402
}
4. Publish
curl -X POST "https://api.pushmesh.io/api/v1/in_app_messages/$CID/publicar?app_id=$APP_ID" \
-H "Authorization: Basic $PUSHMESH_KEY"
Nothing to do on the app side: the React Native SDK already fetches the package and returns the receipts on its own.
States and transitions
Four states. A campaign is born rascunho (draft) and only leaves it through
an explicit call.
| State | Serves messages? | Editable? |
|---|---|---|
rascunho (draft) | no | yes |
ativa (active) | yes | yes |
pausada (paused) | no (new packages only) | yes |
encerrada (ended) | no | no — terminal |
| Action | From | To |
|---|---|---|
POST /{id}/publicar | rascunho, pausada | ativa |
POST /{id}/pausar | ativa | pausada |
POST /{id}/encerrar | rascunho, ativa, pausada | encerrada |
An invalid transition returns 409 naming the current state and the accepted origins — for example: campanha está ‘encerrada’ — esta ação só vale a partir de: rascunho, pausada.
Ending preserves the funnel. Deleting erases it. An ended campaign can no
longer be edited (PATCH returns 409); the official way to reuse the creative
is POST /{id}/duplicar, which returns a copy as a draft with counters reset.
Deleting an active campaign requires explicit confirmation:
curl -X DELETE "https://api.pushmesh.io/api/v1/in_app_messages/$CID?app_id=$APP_ID&confirmar=true" \
-H "Authorization: Basic $PUSHMESH_KEY"
Without confirmar=true the answer is 409 — because deleting erases the funnel
and its trail for good.
Formats
| Format | How it appears |
|---|---|
modal | centred card, background dimmed |
banner_top | strip at the top |
banner_bottom | strip at the bottom |
fullscreen | takes the whole screen |
Content
conteudo is an object keyed by language, and the pt block is
required. Putting titulo at the root of conteudo is an error — it must
sit inside a language block.
Each language block uses one of the two forms. Mixing them is a 422.
Form A — fixed fields
| Field | Type | Limit | What it does |
|---|---|---|---|
titulo | text | 80 chars | card title |
corpo | text | 240 chars | body copy |
imagem | URL | https:// | card artwork |
botao | object | — | { texto (1–24), acao } |
botao_secundario | object | — | only alongside botao |
acao_card | action | — | tapping the card area |
Validity rule: at least one of imagem, titulo, corpo, botao. There
is no such thing as an empty card.
Form B — block stack
blocos is an ordered array. Per-campaign limits:
| Block | Max | Fields |
|---|---|---|
texto | 8 | texto (1–500), cor (#rrggbb), typography |
imagem | 4 | url (https, required), sangra (bleed), raio (0–64), acao_click, margens (0–64 each) |
botao | 2 | texto (1–24), acao (required), cor_fundo, cor_texto, raio (0–32), typography |
espacador | 8 | altura (1–200) |
Cap of 12 blocks total, with at least one visible block.
Typography (on texto and botao):
| Field | Values |
|---|---|
fonte | sistema, serifada, monoespacada |
peso | regular, medio, negrito |
italico, sublinhado | booleans |
tamanho | 10 to 48 |
alinhamento | esquerda, centro, direita |
Button actions
The list is closed — any other value is rejected at creation time:
| Action | What it does |
|---|---|
dismiss | closes the message |
open_url | opens an address |
open_notification_settings | opens the system notification settings |
open_url requires a scheme (https://... or myapp://...) and rejects plain
http:// and addresses containing spaces.
open_notification_settings is the engine of re-activation: it is the button
that walks a user who blocked push back to their settings. When the device
comes back reporting the permission as enabled, the server attributes that
return to re-activation campaigns from the last 7 days — that is the
reativados field in the statistics.
Style and the anti-trap rule
estilo accepts #rrggbb colours (fundo, texto, botao_fundo,
botao_texto), raio (0–32), escurecer (0–1), padding_px (0–64),
imagem_sangra, fundo_imagem (https) and acao_card.
The close block controls the dismiss X: mostrar, icone (x, x_circulo,
seta), tamanho (16–48), cor and fundo.
Hiding the X is only allowed when another exit is guaranteed — either the
modal format (the dimmed backdrop dismisses) or a button / card action set to
dismiss. Otherwise creation is rejected, stating that the message would trap
the user. This is not a design preference: it is the difference between a
campaign and a frozen app.
Triggers — when the message appears
The trigger travels in flat form. A field that does not belong to the type is
rejected, and sending any parameter without tipo is rejected too — only
atraso_ms travels alone.
| Type | Parameters | Fires when |
|---|---|---|
ao_abrir | — | the app opens |
evento | chave, comparador, valor | the app signals that state |
sessao_duracao | segundos (1–86400) | after N seconds in session |
inatividade | horas (1–8760) | after N hours without opening |
atraso_ms (0 to 60000, default 2000) counts from the moment the condition
matched.
Comparators for the evento trigger
| Comparator | Takes a value | Value type |
|---|---|---|
igual (equals) | yes | text or number |
diferente (differs) | yes | text or number |
maior (greater) | yes | numbers only |
menor (lesser) | yes | numbers only |
existe (exists) | no | — |
nao_existe (does not exist) | no | — |
chave: 1 to 64 characters, letters, digits and_ . : -only. Spaces are rejected — a space in a key name is a silent typo, and a silent typo is a message that never appears.- Text
valor: 1 to 120 characters. Numericvalor: absolute value up to 1e12, at most 6 decimal places. - Type matters:
"10"(text) and10(number) are not the same thing in a comparison. maiorandmenorwith text are rejected at creation — they never reach a device.
In the app, the event trigger is fed by the SDK:
PushMesh.definirGatilho('screen', 'checkout');
Frequency rules — three brakes
| Rule | Lives on | Default | What it does |
|---|---|---|---|
cap_total | campaign | 1 | how many times this campaign may appear (0 = unlimited) |
cooldown_h | campaign | 24 | minimum hours between two displays of this campaign |
inapp_cap_horas | app | 6 | minimum hours between any two in-app messages |
The third one is the one people miss: even with cap_total: 0 (“unlimited”),
a device will not show two in-app messages less than 6 hours apart, from any
campaign. Only inapp_cap_horas: 0 releases that brake (accepted range: 0 to
168).
Also in regras: fechar_apos_ms (0 = never auto-closes; or 1 to 600000) and
prioridade (default 100, higher wins).
The display window uses inicia_em and termina_em at the top level of the
body, in RFC3339 — not inside regras. Sending them inside regras is
rejected, so you never believe you scheduled something you did not.
Audience
alvo_tipo | Who is included | alvo |
|---|---|---|
todos | the whole base | [] |
bloqueados | users who denied push permission | [] |
nunca_pediu | users who never saw the permission prompt | [] |
desinscritos | users who unsubscribed | [] |
external_ids | a list of your own identifiers | 1+ items |
player_ids | a list of devices | 1+ items |
The four segments require alvo: []; the two lists require at least one item. A
non-empty list on a segment is an error.
Capability by SDK version — the part nobody enjoys explaining
A new feature requires an app capable of honouring it. A device running an old SDK version does not receive the campaign — it is suppressed and counted, rather than served half-working.
| Feature | Requires |
|---|---|
Fixed fields, ao_abrir | SDK 0.5+ |
| Blocks | SDK 0.5+ |
evento, sessao_duracao, inatividade | SDK 0.6+ |
inicia_em (scheduling) while the time has not arrived | SDK 0.6+ |
The current SDK is @pushmesh/sdk@0.7.2 — anyone installing today covers
everything. The SDK is not the problem: the problem is the slice of your base
still running a build you shipped months ago.
That is why /alcance answers split, rather than with a single number:
| Field | The honest reading |
|---|---|
alcance_estimado | everyone matching the audience |
alcancaveis_agora | who will actually receive it |
precisam_app_atualizado | who will only receive it after updating the app |
sem_versao_reportada | devices that do not even report their version |
alcance_estimado is always exactly alcancaveis_agora plus
precisam_app_atualizado, and sem_versao_reportada is a subset of the
latter — broken out separately so devices that may well be fine are not accused
of being outdated.
A device that does not report its version counts as incapable, not capable: without proof, it does not enter a promise of reach.
schema_min_efetivo is recalculated on every question, because the scheduling
part is a moving target: a campaign set for 9 pm requires SDK 0.6+ today,
and goes back to reaching everyone after 9 pm.
Before blaming delivery, read precisam_app_atualizado.
The funnel
curl "https://api.pushmesh.io/api/v1/in_app_messages/$CID/funil?app_id=$APP_ID" \
-H "Authorization: Basic $PUSHMESH_KEY"
{
"campanha_id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40",
"nome": "welcome",
"estado": "ativa",
"servidos": 12840,
"impressoes": 9012,
"cliques": 1877,
"fechados": 6410,
"alcance_estimado": 48120
}
| Number | What it is |
|---|---|
servidos | entered a device’s session package |
impressoes | actually drawn on screen (proven receipt) |
cliques | someone tapped (proven receipt) |
fechados | someone dismissed it (proven receipt) |
servidos higher than impressoes is expected, not a defect: the package
was delivered and the trigger did not match during that session. Only
servidos increments on the server; the other three depend on proof coming
back from the device — which is why the funnel can undercount, never inflate.
Numbers by time window
To line up with your own reporting cycle:
curl "https://api.pushmesh.io/api/v1/in_app_messages/stats?app_id=$APP_ID&desde=2026-08-01T00:00:00Z&ate=2026-09-01T00:00:00Z" \
-H "Authorization: Basic $PUSHMESH_KEY"
{
"desde": "2026-08-01T00:00:00+00:00",
"ate": "2026-09-01T00:00:00+00:00",
"totais": { "impressoes": 40122, "cliques": 8110, "reativados": 512 },
"campanhas": [
{
"campanha_id": "0198e7c4-77a1-7bd2-b0f1-6b1d3f2a9c40",
"nome": "welcome",
"estado": "ativa",
"alvo_tipo": "todos",
"impressoes": 9012,
"cliques": 1877,
"reativados": 208
}
]
}
desde and ate are both required, in RFC3339, and the window is half-open
[desde, ate). Results are ordered by impressions within the window.
reativados is not a device event: it is server-side attribution. When a device
returns reporting notification permission as enabled, its occasions from the
last 7 days on re-activation campaigns are stamped. It is the answer to “did
this campaign bring anyone back?”.
The two device routes
You normally do not call these — the SDK handles them. They are documented because anyone writing their own client needs the contract.
GET /api/v1/inapp/pending
Delivers the session package. No API key (it runs inside the end user’s app),
with app_id, player_id and schema in the query string, and a ceiling of
120 requests per minute per IP.
Returns 200 with pacote_id, messages[], validade_s (86400),
cap_global_h and etag; it accepts If-None-Match and returns 304 when
nothing changed. An empty list is normal and never a 404 — including for an
unknown player_id, because the route does not confirm device existence.
A package carries at most 20 campaigns, ordered by priority. A low-priority campaign in a base with many active campaigns may simply not travel.
POST /api/v1/inapp/eventos
Batched receipts — the only source of impressions, clicks and dismissals.
Each item carries campanha_id, prova (echoed exactly as it arrived in the
package), evento (impressao, clique or fechou), ocorrencia_n (1 to
500) and em (RFC3339, within a window from 7 days ago to 5 minutes ahead).
Batch of 1 to 50 items.
The response is positional, in the same order as the items:
{ "ok": true, "resultados": ["aceito", "duplicado", "prova_invalida", "acima_do_cap"] }
| Verdict | Meaning |
|---|---|
aceito | recorded, counters incremented |
duplicado | that event for that occurrence already existed |
prova_invalida | the proof matches no recent package for this device |
acima_do_cap | ocorrencia_n exceeded the campaign cap |
Mind the contrast: a malformed item fails the whole request (400/422). Only the proof gets a per-item verdict. Validate shape in your local queue before sending, otherwise one out-of-window timestamp takes 49 healthy items down with it.
Errors
| Status | When |
|---|---|
| 400 | bad shape: broken JSON, malformed identifier, dates outside RFC3339, batch outside 1–50, unknown event |
| 401 | key missing, unknown, revoked or expired — or an app_id that is not the key’s app |
| 403 | app paused, In-App channel off, or an entire batch with invalid proof |
| 404 | campaign does not exist in this app |
| 409 | invalid state transition, editing an ended campaign, deleting an active one without confirming |
| 413 | body over 16 KB (device routes) |
| 422 | invalid campaign, inapp_cap_horas outside 0–168, timestamp outside the window |
| 429 | more than 120 requests per minute per IP (device routes), with Retry-After: 60 |
| 503 | dependency unavailable — always named |
Every error uses the same envelope, with causa and como_corrigir. On
campaign validation errors, causa lists each offending field and
como_corrigir says what to do about each one.
An app_id that is not the key’s app returns 401, never 404 — the API does
not confirm the existence of another app’s resources.
Traps worth five minutes
- The channel ships off. Active campaign + channel off = nothing happens.
Turn it on with
PUT /in_app_messages/config. - Creating does not publish. Campaigns start as drafts.
- Pausing does not recall what was already delivered. The package lives up to 24 h on the device.
app_idis required on every management route, in the query or the body.- A trigger field that does not belong to the type is an error, never ignored.
inicia_em/termina_emlive at the top level, not insideregras.- Do not mix blocks with fixed fields in the same language block.
- New triggers cut reach. Check
precisam_app_atualizado. servidos>impressoesis normal.acima_do_caparrives inside a 200. Checking only the HTTP status will make you think it counted.- The app-wide cap (6 h by default) overrides an “unlimited” campaign.