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 type | Meaning |
|---|---|
RECIPIENT_DELIVERED | Message delivered to the recipient |
RECIPIENT_FAILED | Delivery failed |
RECIPIENT_PENDING_CONSENT | Awaiting the recipient's consent |
CONSENT_GRANTED | The recipient allowed messages from your app |
CONSENT_DENIED | The recipient declined |
REQUEST_COMPLETED | All recipients in a send request were processed |
POLL_RESPONSE_RECEIVED | A 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.
| Frame | Meaning | What to do |
|---|---|---|
event | A real lifecycle event (payload below) | Handle it, and advance your cursor |
keep_alive | Proof of life on an idle stream. Empty by design | Reset your idle timer; otherwise ignore |
events_lost | The server's broadcast receiver fell behind | Reconcile with GetSendRequestStatus |
Three rules follow, and each of them prevents a real failure:
- 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).
- Advance your cursor only on an
eventframe. No other variant carries anevent_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. - 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:
| Field | Type | Description |
|---|---|---|
event_id | string | ULID — unique event id; use it as your cursor |
send_request_id | string | The send request this event belongs to |
event_type | enum | One of the types above |
recipient | optional | Present for per-recipient events (ppnum, error_code, …) |
summary | optional | Present for REQUEST_COMPLETED (delivered, failed, …) |
occurred_at | string | Event 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.