Protocol Buffer definitions for the Localytics push notification service.
This repository contains a single source-of-truth schema, push.proto, that defines a bidirectional streaming gRPC API for sending push notifications to iOS, Android, and Web clients. The long-form client guide — with full runnable examples in Go, Python, Node.js, Java, and grpcurl, plus the full table of gRPC status codes — lives in usage.md. This README is the entry point: it explains what is in the proto, how the protocol works, and how to wire the generated stubs into a codebase.
- What
push.protodefines - Message reference
- The streaming protocol
- Authentication and endpoint
- Limits worth knowing up front
- Integrating into your codebase
- Versioning and compatibility
- Repo layout
The file is plain proto3. It declares:
- The package
push(so generated symbols live underpush.*,push_pb2, etc.). - A Go import path:
option go_package = "github.com/localytics/push-notification-protos/push". Generated Go code will live under this import path. - A single external import,
google/protobuf/struct.proto, which ships withprotocand the language plugins. It is only used to type the free-formextrafields on the platform params messages. - One gRPC service,
PushService, with one RPC,StreamPush, which is bidirectional streaming. - A set of request and response messages used to drive that stream.
There are no enums and no nested messages; every type is top-level. Field numbers are stable — only additive changes should be expected.
The proto groups messages into a few small layers. Every field listed here comes from push.proto directly.
The user-visible part of the push.
body(string, required) — the notification text.title(string, optional) — short title.subtitle(string, optional) — iOS 10+ only; the server rejectssubtitleiftitleis not also set.
Each is optional. Set the ones you need; omitted platforms are simply not delivered to.
IOSParams — iOS-specific options:
sound(string) — sound file name, or"default".badge(int32) — unread badge number.category(string) — interactive push category.content_available(bool) — defaults totrue.mutable_content(bool) — defaults tofalse.extra(google.protobuf.Struct) — arbitrary JSON-shaped payload merged into the APNs message.
AndroidParams — Android-specific options:
priority(string) — message priority, typically"normal"or"high".extra(google.protobuf.Struct) — arbitrary JSON-shaped payload.
WebParams — Web push options:
badge(string) — URL to a badge image.dir(string) —"ltr","rtl", or"auto".icon(string) — URL to an icon image.require_interaction(bool)renotify(bool)silent(bool)tag(string)extra(google.protobuf.Struct)
The extra fields are typed as google.protobuf.Struct, which represents an arbitrary JSON object (nested objects and arrays allowed, not just flat string maps). Every language has a builder for it: structpb.NewStruct(...) in Go, struct_pb2.Struct().update({...}) in Python, Struct.newBuilder().putFields(...) in Java, and a plain JS object in Node.js.
A flat container with fields label1 through label10, all string and all optional. Used for campaign performance tracking; the meaning of each slot is yours to decide.
Sent once, as the first frame, to identify the stream.
app_id(string, required) — the application UUID.request_id(string, optional, ≤ 255 chars) — auto-generated by the server if omitted.campaign_key(string, optional, ≤ 255 chars, must match^[\w\-.]+$).labels(Labels, optional).all_devices(bool, optional) — defaults tofalse.test(bool, optional) — test-mode flag; messages are validated but not delivered.
Sent zero or more times after StreamInit. Each PushRequest targets one or more customer_ids with the same alert and platform params.
customer_ids(repeated string, required, non-empty) — up to 30,000 per request.alert(Alert, required) — rejected if missing.ios(IOSParams, optional).android(AndroidParams, optional).web(WebParams, optional).
A oneof wrapper so the stream can carry either kind of frame:
message PushStreamMessage {
oneof payload {
StreamInit init = 1;
PushRequest push = 2;
}
}The first frame must populate init; every subsequent frame must populate push. This is enforced by the server, not by the proto.
Streamed back zero or more times as failures are discovered. The stream stays open; it does not terminate the call.
customer_ids(repeated string) — the subset of IDs that failed (from aPushRequestyou sent).reason(string) — human-readable failure description.
Sent exactly once, always last, just before the server closes the stream.
request_id(string) — matches the stream's request ID (the one you sent inStreamInit, or the one the server generated).total_messages(int32) — number ofPushRequestmessages processed.total_customer_ids(int32) — total IDs across all messages.status(string) —"accepted"or"error".error(string, optional) — set only whenstatus == "error".campaign_id(int64) — server-assigned campaign ID.
Mirrors PushStreamMessage on the server side:
message PushStreamResponse {
oneof response {
PushFailure failure = 1;
PushResponse summary = 2;
}
}service PushService {
rpc StreamPush(stream PushStreamMessage) returns (stream PushStreamResponse);
}One method, both sides streaming. There are no other RPCs.
The proto defines the shape of the messages; the order in which they're allowed is part of the contract. Every client must do this:
client → StreamInit (must be first; exactly one)
client → PushRequest (zero or more; in any order)
client → PushRequest
client → ... CloseSend / half-close ... (signal you're done sending)
server → PushFailure (zero or more, as failures happen)
server → PushFailure
server → PushResponse summary (exactly one; always last)
server → EOF (server closes the stream)
Concretely:
- Open the bidirectional stream by calling
StreamPushon the generated client. - Send a single
PushStreamMessagewith theinitarm populated. The server rejects any other frame in this slot. - Send as many
PushStreamMessages with thepusharm populated as you need, batchingcustomer_idsper request to stay efficient. - Half-close the send side (
CloseSend()in Go,stream.end()in Node,done_writing()in Python aio,requestObserver.onCompleted()in Java). Until you do this, the server will not emit its summary. - Read every response until EOF. Treat
PushFailureframes as partial-success information — they do not end the stream. ThePushResponsesummary is always the last message before EOF.
A common bug is to stop reading after the first PushFailure. Don't — the summary still has to come through.
The service is reachable on the sandbox at:
trans-api-grpc.sandbox53.localytics.com:50051
Authentication is HTTP Basic, carried in gRPC metadata on the call (not in the proto itself):
authorization: Basic <base64(api_key:api_secret)>
The server authenticates the call, then verifies that StreamInit.app_id belongs to the authenticated organization. See usage.md for the full mapping from auth failures to gRPC status codes.
These limits are enforced by the server, not encoded in the proto, but they shape how you should batch:
- 10,000
PushRequestmessages per stream. - 30,000
customer_idsperPushRequest. - 10 minutes of wall-clock per stream.
- 4 MiB maximum message size.
request_idandcampaign_key≤ 255 chars;campaign_keymust match^[\w\-.]+$.
If you have more recipients than fit in one stream, open multiple streams in parallel — each gets its own request_id and limits independently.
The mechanics are the same in every language: vendor the proto, generate stubs, then write a small client that drives the protocol described above.
You have three reasonable options:
- Git submodule.
git submodule add https://.../push-notification-protos third_party/push-notification-protosand point your build atthird_party/push-notification-protos/push.proto. Easiest way to track upstream and stay pinned to a specific commit. - Pin a tag and copy. Copy
push.proto(andLICENSE) into your repo under e.g.proto/push.proto. Record the upstream commit hash somewhere in your repo so you can re-sync intentionally. bufmodules. If you use Buf, you can publish this proto as a module and depend on it from yourbuf.yaml.
push.proto only imports google/protobuf/struct.proto. That import ships with protoc itself (under the include/ directory of the protobuf release) and with each language's protobuf runtime, so you do not need to vendor it separately.
Use protoc (or buf generate). Example invocations are below; replace proto/push.proto with wherever you placed the file.
Go. The generated package import path follows the option go_package in the proto, i.e. github.com/localytics/push-notification-protos/push.
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
protoc \
--go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/push.proto
go get google.golang.org/grpc google.golang.org/protobufThis produces push.pb.go (message types) and push_grpc.pb.go (the PushServiceClient interface and NewPushServiceClient constructor).
Python. Use grpcio-tools so the gRPC stubs are generated alongside the message types.
pip install grpcio grpcio-tools protobuf
python -m grpc_tools.protoc \
-I proto \
--python_out=./gen \
--grpc_python_out=./gen \
proto/push.protoThis produces push_pb2.py (messages) and push_pb2_grpc.py (the PushServiceStub).
Node.js / TypeScript. Two common approaches:
- Dynamic loading, no codegen step:
npm install @grpc/grpc-js @grpc/proto-loader, thenprotoLoader.loadSync('push.proto', ...)at runtime. Simplest to get started; no TypeScript types out of the box. - Static codegen:
npm install -D grpc-tools grpc_tools_node_protoc_ts, rungrpc_tools_node_protocagainstpush.proto. You get.js+.d.tsand full type checking.
Java. Either invoke protoc directly:
protoc --java_out=src/main/java --grpc-java_out=src/main/java proto/push.proto…or, more commonly, use the com.google.protobuf Gradle/Maven plugin pointed at your proto directory. Add these runtime dependencies:
implementation 'io.grpc:grpc-netty-shaded:1.68.0'
implementation 'io.grpc:grpc-protobuf:1.68.0'
implementation 'io.grpc:grpc-stub:1.68.0'
implementation 'com.google.protobuf:protobuf-java:4.29.0'The generated classes live under the push package (from the proto's package push; declaration).
Other languages. push.proto is plain proto3 with one well-known import, so it works unchanged with protoc plugins for C#, Ruby, Kotlin, Swift, and Rust (tonic). Generate stubs the same way and follow the protocol described above.
The same five steps in every language:
- Open a channel/connection to the endpoint. Reuse one channel across the application — gRPC multiplexes streams over a single HTTP/2 connection, so you don't need a new channel per call.
- Attach auth metadata. Build the
Basicheader fromapi_key:api_secret, base64-encode it, and attach it to the call (per-RPC metadata is fine; you don't have to use an interceptor). - Set a deadline. The server enforces a 10-minute cap; set your client deadline slightly above that (e.g. 11 minutes) as a safety net.
- Drive the stream. Send one
PushStreamMessage{ init: StreamInit{...} }, then any number ofPushStreamMessage{ push: PushRequest{...} }, then half-close. - Drain responses until EOF. Switch on the
responseoneof: log/collectPushFailures, store the finalPushResponsesummary, and break on EOF (oronCompleted, depending on language).
Self-contained, minimal example clients live under examples/:
examples/go/— Go +protoc, driven by aMakefile.examples/java/— Java + Gradle with theprotobuf-gradle-plugin.examples/rust/— Rust +tonic(codegen viabuild.rs).
All three are roughly 100 lines each and exercise the full flow: open the stream, send StreamInit + one PushRequest, half-close, drain failures, print the summary.
Full client examples for Python (sync and async), Node.js, and grpcurl, plus the complete gRPC status-code table, retry strategy, and the google.protobuf.Struct builders for the extra fields in each language, live in usage.md.
Treat push.proto as a public contract:
- Field numbers are stable. Don't reuse or renumber them locally.
- Only additive changes (new optional fields, new
oneofarms with new tags) should be expected on existing messages. Wire compatibility goes both directions for these. - Pin to a specific git tag or commit when vendoring, so a regen never silently picks up an upstream change.
push.proto # the schema — this is what you generate stubs from
usage.md # full client integration guide (auth, limits, runnable examples)
examples/ # minimal self-contained clients (Go, Java, Rust)
LICENSE