Ppoppo Docs

Event Streaming

ExternalMessageService.StreamSendRequestEvents is a server-streaming RPC that delivers real-time events for your send requests. It replaces webhooks: your server opens the connection, so there is no public endpoint to expose, and gRPC TLS provides authentication and integrity (no HMAC signatures to verify).

The stream yields SendRequestEventFrame — an envelope, not a bare event. See Stream frames before writing your read loop.

Event types

Event typeMeaning
RECIPIENT_DELIVEREDMessage delivered to the recipient
RECIPIENT_FAILEDDelivery failed
RECIPIENT_PENDING_CONSENTAwaiting the recipient's consent
CONSENT_GRANTEDThe recipient allowed messages from your app
CONSENT_DENIEDThe recipient declined
REQUEST_COMPLETEDAll recipients in a send request were processed
POLL_RESPONSE_RECEIVEDA recipient answered a poll

Event payload

Stream frames

Every item on the stream is a SendRequestEventFrame, a oneof with three variants. Only one of them is content.

FrameMeaningWhat to do
eventA real lifecycle event (payload below)Handle it, and advance your cursor
keep_aliveProof of life on an idle stream. Empty by designReset your idle timer; otherwise ignore
events_lostThe server's broadcast receiver fell behindReconcile with GetSendRequestStatus

Three rules follow, and each of them prevents a real failure:

  1. Reset your idle timer on every frame, not just on events. A delivery feed is quiet most of the time, so "nothing arrived" and "the connection is dead" look identical without the keepalive. Set your bound to a comfortable multiple of the keepalive interval (20 s at time of writing).
  2. Advance your cursor only on an event frame. No other variant carries an event_id. Persisting anything else — an empty string included — leaves you with a cursor the server cannot match, and it will resume you at the live edge instead of where you stopped.
  3. Treat a frame kind you do not recognise as skippable. New variants may be added; skip them and continue. Do not stop the stream, and do not count them as events.

events_lost.broadcast_dropped counts lag on a service-wide channel shared by all apps — the per-app filter runs after it. It is an upper bound on your loss, never a measurement of it. Read any non-zero value as "reconcile", never as "reconcile this many".

Event payload

Each event frame carries a SendRequestEvent:

FieldTypeDescription
event_idstringULID — unique event id; use it as your cursor
send_request_idstringThe send request this event belongs to
event_typeenumOne of the types above
recipientoptionalPresent for per-recipient events (ppnum, error_code, …)
summaryoptionalPresent for REQUEST_COMPLETED (delivered, failed, …)
occurred_atstringEvent timestamp (RFC 3339)

Reconnection

Pass after_event_id to resume from where you left off. Event IDs are ULIDs (chronologically sortable). Filter to a single send request with send_request_id, or omit it to receive all events for your app.

The cursor is a best-effort hint, not a replay guarantee. The server's event channel holds no durable history, so an event that aged out before you reconnected cannot be re-sent — and a cursor the server cannot find makes it resume from the live edge after a bounded search. Reconcile with GetSendRequestStatus for anything you must not miss, and treat events_lost as the signal to do so.

let mut cursor = load_saved_cursor().await;  // persisted last_event_id
loop {
    let mut client = connect_client(&token).await;
    if let Err(e) = stream_events(&mut client, cursor.clone()).await {
        eprintln!("stream error: {e}; reconnecting…");
        // back off before retrying
    }
    cursor = load_saved_cursor().await;       // updated as events arrive
}

Persist last_event_id durably, and write it only from an event frame — it's how the stream resumes. See Security.

First-time recipients must consent before they receive messages. Handle the RECIPIENT_PENDING_CONSENT / CONSENT_GRANTED / CONSENT_DENIED events — the bulk-messaging guide walks through the consent flow.