The Salesforce Pub/Sub API: gRPC event streaming that replaces CometD
Your CometD subscriber still works, but it is a legacy long-polling client on an API Salesforce no longer invests in. The Pub/Sub API is the gRPC replacement — one interface for publish, subscribe, and schema, with flow control and 72-hour replay. Here is how it actually works, the Avro decode step everyone forgets, and the limits that bite in production.
You have a Node or Python service subscribed to a Platform Event or a Change Data Capture channel, and it works. It’s a CometD client — long-polling over the Bayeux protocol, the pattern every Salesforce streaming tutorial taught for a decade. It reconnects, it replays, it’s been in production for two years. So why does every new Salesforce streaming doc point somewhere else?
Because CometD is the API Salesforce stopped investing in, and the Pub/Sub API is where the platform’s event streaming actually lives now. It’s a single gRPC interface — publish, subscribe, and fetch schemas through one API over HTTP/2 — that replaces the separate, older surfaces for streaming Platform Events, Change Data Capture, and real-time event monitoring. It’s faster, it’s language-agnostic, it gives you real back-pressure, and it hands you a binary payload you have to decode yourself. That last part is where most first integrations stall.
This is the working developer’s guide: what the Pub/Sub API is, the six RPCs, the flow-control model that makes it different from everything before it, the Avro decode step nobody warns you about, and the production limits that catch teams out. If you’re choosing an integration pattern for a new event-driven build, this is the streaming half of that decision.
What it actually is
The Pub/Sub API is a gRPC API based on HTTP/2, and Salesforce’s own service definition is blunt about its scope: it “provides a single interface for publishing and subscribing to platform events, including real-time event monitoring events, and change data capture events.” One API, one connection style, three event families. That consolidation is the first thing it buys you — no more one client for Platform Events and a different mechanism for CDC.
Two properties define how you work with it:
- It’s gRPC, so you generate a client from a
.protofile. Salesforce publishespubsub_api.proto(packageeventbus.v1) in the official repo. You run it through your language’s gRPC codegen and get typed stubs. Officially supported samples exist for Java, Python, and Go; other languages work because gRPC is language-agnostic, but they’re generate-your-own. For Node, the communitypub-sub-api-node-clientis the well-worn path. - Payloads are binary Apache Avro, not JSON. Every event’s
payloadis Avro-encoded bytes. You don’t get a readable object off the wire — you get bytes plus aschema_id, and you decode them against the Avro schema you fetch separately. This trips up everyone once and never again.
You connect over SSL to api.pubsub.salesforce.com:7443. (There’s also a :443 endpoint if you have no data-in-transit privacy requirement; both are secure gRPC channels.)
Authentication is three metadata headers
There’s no special OAuth dance beyond getting a normal Salesforce session. Any supported OAuth flow mints the token — for a server integration, the JWT bearer flow is the usual pick. What’s specific to Pub/Sub is how you present it: three gRPC metadata entries on every call, with these exact keys:
accesstoken : your OAuth access token
instanceurl : your Salesforce instance URL
tenantid : the org (tenant) id
Miss one, or use a my.salesforce.com host where the metadata expects the org id, and you’ll get authentication errors that don’t point at the missing header. Attach all three to every RPC’s metadata and the rest is plumbing.
The six RPCs
The whole surface is six methods on the PubSub service. Knowing which is streaming and which is unary tells you how to call each:
Subscribe— bidirectional streaming. You send a stream ofFetchRequests and receive a stream ofFetchResponses. This is the subscription, and its two-way nature is the whole flow-control story below.GetSchema— unary. Give it aschema_id, get back the Avro schema as JSON. You need this to decode payloads.GetTopic— unary. Returns topic info:topic_name,can_publish,can_subscribe, and the currentschema_id.Publish— unary. Synchronously publish a batch of events to a topic.PublishStream— bidirectional streaming. Same asPublishbut you can keep multiple publish batches in flight for a higher throughput ceiling.ManagedSubscribe— bidirectional streaming, open beta. LikeSubscribe, but Salesforce tracks your replay position server-side so you don’t have to persist it yourself.
Most integrations use two of these: Subscribe and GetSchema. Publishers add Publish or PublishStream.
Flow control is what makes it different
Here’s the mental model shift from CometD. In the old world, events were pushed at you and you coped. In Pub/Sub, you pull, using a credit system. The first FetchRequest on a Subscribe stream names the topic, sets where to start, and requests a number of events with num_requested. That number is a credit balance, not a batch size — the server delivers up to that many events, then pauses. When you’re ready for more, you send another FetchRequest with more credits down the same open stream.
The ceiling on num_requested is 100. The response carries pending_num_requested so you always know how many credits the server still thinks it owes you. There is no guaranteed one-to-one mapping between events requested and events delivered in a single response — you might ask for 100 and get them across several FetchResponse messages, or get an empty one (more on that shortly).
This is genuine back-pressure. A slow consumer simply stops asking for credits, and Salesforce stops delivering, instead of overwhelming you or silently dropping you the way a saturated push client can. You control the rate, which means you’re also responsible for keeping the credits topped up — a subscriber that never sends a second FetchRequest receives exactly its first batch and then goes quiet, which looks like a broken subscription but is really an empty credit balance.
Replay: the 72-hour window you design around
Every event carries a replay_id — opaque bytes, not a number you should parse or assume is contiguous. It marks that event’s position in the stream. The first FetchRequest sets your starting point via replay_preset:
LATEST(the default) — start at the tip; only new events from now on.EARLIEST— start at the oldest retained event.CUSTOM— start immediately after a specificreplay_idyou provide.
The retention window is 72 hours (three days) for Platform Events and CDC. If your subscriber dies and comes back within that window, you resubscribe with CUSTOM and the last replay_id you successfully processed, and you pick up exactly where you left off. Come back after 72 hours and that position is gone — you can only resume from EARLIEST (the oldest still-retained event) or LATEST.
One sharp edge from the spec: replay_preset and replay_id are read only from the first FetchRequest on a stream. You can’t change your replay position mid-stream by sending a new preset — to restart at a different point you open a fresh Subscribe stream. Design your reconnect logic to open a new stream with the stored replay_id, not to mutate the existing one.
The empty response is a feature, not a bug
If there are no events to deliver, the server doesn’t leave you hanging — it sends an empty FetchResponse carrying the latest replay_id, within roughly 270 seconds. This does double duty: it’s a keepalive that proves the stream is alive, and it advances your replay bookmark past a quiet period.
The discipline this demands is easy to miss: persist latest_replay_id from every response, including the empty ones. If you only store replay IDs from responses that contained events, then after a quiet stretch you resubscribe from an old position and re-process everything published in between — duplicates that look like a bug in your consumer but are really a bookmarking mistake.
Subscribing, end to end
Here’s the shape of a Python subscriber. Subscribe takes a stream, so you feed it a generator of FetchRequests and iterate the responses. Every symbol here maps to a real proto message or a stub generated from the .proto:
import grpc, io, queue
import avro.schema, avro.io
import pubsub_api_pb2 as pb2 # generated from pubsub_api.proto
import pubsub_api_pb2_grpc as pb2_grpc
auth = (
("accesstoken", access_token), # from any Salesforce OAuth flow
("instanceurl", instance_url),
("tenantid", org_id),
)
channel = grpc.secure_channel(
"api.pubsub.salesforce.com:7443", grpc.ssl_channel_credentials()
)
stub = pb2_grpc.PubSubStub(channel)
TOPIC = "/event/Order_Event__e"
# A queue drives the outbound FetchRequest stream. Only the FIRST request
# names the topic and replay position; later ones just replenish credits.
requests = queue.Queue()
requests.put(pb2.FetchRequest(
topic_name=TOPIC,
replay_preset=pb2.ReplayPreset.LATEST, # or CUSTOM + replay_id=<bytes>
num_requested=100, # credit balance, max 100
))
def fetch_requests():
while True:
yield requests.get() # blocks until we top up credits
# Fetch each schema once and cache it — schemas rarely change.
schema_cache = {}
def get_schema(schema_id):
if schema_id not in schema_cache:
info = stub.GetSchema(pb2.SchemaRequest(schema_id=schema_id), metadata=auth)
schema_cache[schema_id] = avro.schema.parse(info.schema_json)
return schema_cache[schema_id]
def decode(schema, payload_bytes):
decoder = avro.io.BinaryDecoder(io.BytesIO(payload_bytes))
return avro.io.DatumReader(schema).read(decoder)
for resp in stub.Subscribe(fetch_requests(), metadata=auth):
save_bookmark(resp.latest_replay_id) # even on empty keepalives
for evt in resp.events: # ConsumerEvent
schema = get_schema(evt.event.schema_id)
record = decode(schema, evt.event.payload) # <-- the Avro step
handle(record, evt.replay_id)
# Replenish credits once you're ready for more (this is the back-pressure gate).
requests.put(pb2.FetchRequest(num_requested=100))
Note the schema cache. GetSchema is a network call, and schemas change rarely, so calling it per event is wasteful — fetch once per schema_id, reuse for the life of the process, and refresh only if a schema_id you haven’t seen shows up.
Publishing, and the limit myth to kill
Publishing is the mirror image. Publish is a unary call that takes a PublishRequest with a topic and a list of ProducerEvents — each event is a schema_id plus an Avro-encoded payload (you encode against the schema the same way you decoded). The PublishResponse returns a results list, one PublishResult per event, each carrying the assigned replay_id, an optional Error, and a correlation_key so you can tie a result back to the event you sent. PublishStream is the higher-throughput variant: keep batches in flight without waiting for each PublishResponse.
Now the myth. It is tempting — and wrong — to believe that publishing through Pub/Sub API dodges the platform event limits because it’s “just gRPC.” Every published event counts as one unit against your org’s hourly platform-event publishing allocation, regardless of how you publish it — Pub/Sub, Apex EventBus.publish, or REST. The transport is new; the allocation is the same shared meter. Budget your publish volume against that allocation exactly as you would for any other method, and remember it lives alongside the rest of your org’s API and event limits.
A few more numbers worth pinning before you size a publisher:
- Max 1 MB per event, a recommended batch total under 3 MB (below the 4 MB gRPC message limit), and no more than ~200 events per
PublishRequest. PublishStreamliveness: send a valid publish request (at least one event) at least every 70 seconds or the server closes the stream.Subscribecadence: expect the ~270-second empty-response keepalive; treat a longer silence as a dead stream and reconnect.
Delivery guarantees, and the ordering you don’t get
Two honest caveats to design around:
- Delivery is at-least-once. Reconnects, retries, and replay can all redeliver an event you already processed. Make your consumers idempotent — key on the event’s business identity and no-op on a repeat. Don’t assume exactly-once; you won’t get it.
- There’s no global ordering guarantee. For Change Data Capture, each event carries a
ChangeEventHeaderwith atransactionKeyandsequenceNumberyou can use to reconstruct order within a transaction, but across the stream you should not assume events arrive in commit order. If order matters, sort on what the event tells you, not on arrival.
If you’d rather not persist replay IDs at all, the beta ManagedSubscribe flow tracks and commits your position server-side against a ManagedEventSubscription record, with an explicit CommitReplay step. It’s genuinely convenient, but it’s beta and its minimum API version has moved between releases — confirm the current requirement against the live docs before you build on it.
Should you migrate off CometD today?
Be precise about the status, because the internet overstates it. CometD / the Bayeux-based Streaming API is legacy and no longer the recommended API for new event integrations — but it has not been formally announced as retired or deprecated. What is deprecated is the old EMP Connector sample client, which Salesforce has said will be archived. So:
- Building something new that streams events? Use the Pub/Sub API. It’s the invested-in path, it consolidates Platform Events and CDC, and flow control alone is worth the migration.
- Running a stable CometD subscriber that meets your needs? There’s no fire drill. Plan the move deliberately — but don’t start new work on the old client, and don’t build new tooling on the deprecated EMP Connector.
For the declarative end of event-driven work — reacting to changes inside the platform rather than streaming them to an external service — the Data 360-triggered flows and data actions and Flow HTTP Callout surfaces solve a different shape of problem without a gRPC client at all. Reach for Pub/Sub when an external system needs a durable, replayable, back-pressured feed of Salesforce events.
Takeaways
- Pub/Sub API is the gRPC replacement for CometD — one HTTP/2 interface for publishing and subscribing to Platform Events, Change Data Capture, and real-time event monitoring, generated from the official
pubsub_api.proto. - Auth is three gRPC metadata headers —
accesstoken,instanceurl,tenantid— on every call; the endpoint isapi.pubsub.salesforce.com:7443. - Flow control is a credit system.
FetchRequest.num_requested(max 100) is a balance you top up down an open bidirectional stream — real back-pressure, and your job to keep replenishing. - Payloads are Avro bytes. Fetch the schema once with
GetSchema(schema_id), cache it, and decode everypayloadagainst it. This is the step first integrations forget. - Replay is a 72-hour window. Persist
latest_replay_idfrom every response including empty keepalives, and resubscribe withCUSTOM+ that id — or re-process duplicates. - Publishing still counts against the hourly platform-event allocation regardless of method, and delivery is at-least-once with no global ordering — so make consumers idempotent.
Get the auth headers, the credit loop, and the Avro decode right, and the Pub/Sub API is the most capable event pipe Salesforce has ever shipped. If you’re wiring Salesforce events into the rest of your stack and want it observable and replay-safe rather than brittle, that’s exactly the integration work we do.