PushMesh
Sign in Request access
Open section navigation

Index

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:

ConsequenceWhat it means in practice
Pausing applies to new packagessomeone who already holds a package may still see the message for up to 24 h
The funnel only advances on proven receiptsnumbers 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.

StateServes messages?Editable?
rascunho (draft)noyes
ativa (active)yesyes
pausada (paused)no (new packages only)yes
encerrada (ended)nono — terminal
ActionFromTo
POST /{id}/publicarrascunho, pausadaativa
POST /{id}/pausarativapausada
POST /{id}/encerrarrascunho, ativa, pausadaencerrada

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

FormatHow it appears
modalcentred card, background dimmed
banner_topstrip at the top
banner_bottomstrip at the bottom
fullscreentakes 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

FieldTypeLimitWhat it does
titulotext80 charscard title
corpotext240 charsbody copy
imagemURLhttps://card artwork
botaoobject{ texto (1–24), acao }
botao_secundarioobjectonly alongside botao
acao_cardactiontapping 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:

BlockMaxFields
texto8texto (1–500), cor (#rrggbb), typography
imagem4url (https, required), sangra (bleed), raio (0–64), acao_click, margens (0–64 each)
botao2texto (1–24), acao (required), cor_fundo, cor_texto, raio (0–32), typography
espacador8altura (1–200)

Cap of 12 blocks total, with at least one visible block.

Typography (on texto and botao):

FieldValues
fontesistema, serifada, monoespacada
pesoregular, medio, negrito
italico, sublinhadobooleans
tamanho10 to 48
alinhamentoesquerda, centro, direita

Button actions

The list is closed — any other value is rejected at creation time:

ActionWhat it does
dismisscloses the message
open_urlopens an address
open_notification_settingsopens 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.

TypeParametersFires when
ao_abrirthe app opens
eventochave, comparador, valorthe app signals that state
sessao_duracaosegundos (1–86400)after N seconds in session
inatividadehoras (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

ComparatorTakes a valueValue type
igual (equals)yestext or number
diferente (differs)yestext or number
maior (greater)yesnumbers only
menor (lesser)yesnumbers 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. Numeric valor: absolute value up to 1e12, at most 6 decimal places.
  • Type matters: "10" (text) and 10 (number) are not the same thing in a comparison.
  • maior and menor with 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

RuleLives onDefaultWhat it does
cap_totalcampaign1how many times this campaign may appear (0 = unlimited)
cooldown_hcampaign24minimum hours between two displays of this campaign
inapp_cap_horasapp6minimum 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_tipoWho is includedalvo
todosthe whole base[]
bloqueadosusers who denied push permission[]
nunca_pediuusers who never saw the permission prompt[]
desinscritosusers who unsubscribed[]
external_idsa list of your own identifiers1+ items
player_idsa list of devices1+ 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.

FeatureRequires
Fixed fields, ao_abrirSDK 0.5+
BlocksSDK 0.5+
evento, sessao_duracao, inatividadeSDK 0.6+
inicia_em (scheduling) while the time has not arrivedSDK 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:

FieldThe honest reading
alcance_estimadoeveryone matching the audience
alcancaveis_agorawho will actually receive it
precisam_app_atualizadowho will only receive it after updating the app
sem_versao_reportadadevices 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
}
NumberWhat it is
servidosentered a device’s session package
impressoesactually drawn on screen (proven receipt)
cliquessomeone tapped (proven receipt)
fechadossomeone 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"] }
VerdictMeaning
aceitorecorded, counters incremented
duplicadothat event for that occurrence already existed
prova_invalidathe proof matches no recent package for this device
acima_do_capocorrencia_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

StatusWhen
400bad shape: broken JSON, malformed identifier, dates outside RFC3339, batch outside 1–50, unknown event
401key missing, unknown, revoked or expired — or an app_id that is not the key’s app
403app paused, In-App channel off, or an entire batch with invalid proof
404campaign does not exist in this app
409invalid state transition, editing an ended campaign, deleting an active one without confirming
413body over 16 KB (device routes)
422invalid campaign, inapp_cap_horas outside 0–168, timestamp outside the window
429more than 120 requests per minute per IP (device routes), with Retry-After: 60
503dependency 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_id is 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_em live at the top level, not inside regras.
  • Do not mix blocks with fixed fields in the same language block.
  • New triggers cut reach. Check precisam_app_atualizado.
  • servidos > impressoes is normal.
  • acima_do_cap arrives 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.