Guide to Google Cloud Pub/Sub Webhooks: Features and Best Practices
Google Cloud Pub/Sub push subscriptions deliver messages from a topic to your HTTPS endpoint as POST requests, which makes Pub/Sub the webhook layer for anything publishing into Google Cloud: your own services, Cloud Storage notifications, Cloud Build, Eventarc, or Firebase. If a system you depend on publishes to a topic, a push subscription is how you react without polling.
This guide covers how Pub/Sub push works, the two authentication postures, the push envelope, the acknowledgement and redelivery semantics, and the best practices for production.
What are Pub/Sub webhooks?
Pub/Sub is a message bus, not a webhook product. It becomes a webhook source through a push subscription: a subscription configured with a pushEndpoint URL, which Pub/Sub POSTs each message to instead of waiting for your client to pull. Two things follow from that transport-first design. First, there's no signing secret and no HMAC header of any kind; authentication, when you enable it, is a Google-signed OIDC identity token. Second, Pub/Sub defines no event catalog: whatever your publisher put in the message is what you receive.
Pub/Sub webhook features
| Feature | Details |
|---|---|
| Configuration | Push subscription on a topic (gcloud pubsub subscriptions create --push-endpoint=...); endpoint must be public HTTPS |
| Authentication | None by default; opt-in OIDC via --push-auth-service-account sends a Google-signed RS256 ID token in Authorization: Bearer |
| Signature | None. No HMAC, no signing secret; the body is never signed |
| Envelope | { "message": { "data" (base64), "attributes", "messageId", "publishTime" }, "subscription" } |
| Acknowledgement | HTTP status: 200/201/202/204/102 ack; any other status or a timeout nacks and redelivers |
| Delivery | At-least-once; ack deadline 10s by default (configurable to 600s); push backoff 100ms to 60s on sustained nacks |
| Events | No catalog; semantics live in publisher-set data and attributes |
| SDK | google-auth-library (Node) / google-auth (Python) for token verification |
Event types come from your publisher
Pub/Sub owns no event names. If you see order.created in a payload, your publisher (or another Google service publishing through Pub/Sub) chose it. Route on something the publisher controls:
- An attribute, the common convention: the publisher sets
attributes.eventTypeand your handler switches on it without decodingdata. - A field inside the decoded payload, such as
payload.type. - The subscription itself: one topic and subscription per event kind, so the
subscriptionfield is the discriminator.
Don't hardcode event names you haven't agreed with your publisher; there's no authoritative list to check them against.
The envelope arrives as Content-Type: application/json:
{
"message": {
"attributes": { "eventType": "order.created" },
"data": "eyJvcmRlcklkIjoiMTIzIn0=",
"messageId": "2070443601311540",
"publishTime": "2026-08-13T19:13:12.201Z"
},
"subscription": "projects/my-project/subscriptions/my-sub"
}
data is base64 and may be absent (attribute-only messages are valid and common), messageId is stable across redeliveries, and orderingKey and deliveryAttempt appear only when those features are enabled. A subscription can also be configured for unwrapped delivery, where the raw message body is the HTTP body and attributes arrive as X-Goog-Pubsub-* headers instead.
See Pub/Sub push payloads in action. Inspect and replay sample Google Cloud Pub/Sub push envelopes in the Hookdeck Console — no account or setup required.
Setting up Pub/Sub push webhooks
Create a topic, a service account to act as the push identity, and a push subscription with OIDC authentication enabled:
gcloud pubsub topics create my-topic
gcloud iam service-accounts create pubsub-push
# Let the Pub/Sub service agent mint tokens for that identity
PROJECT_NUMBER=$(gcloud projects describe PROJECT_ID --format='value(projectNumber)')
gcloud iam service-accounts add-iam-policy-binding \
pubsub-push@PROJECT_ID.iam.gserviceaccount.com \
--member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" \
--role="roles/iam.serviceAccountTokenCreator"
gcloud pubsub subscriptions create my-sub \
--topic=my-topic \
--push-endpoint=https://example.com/webhooks/google-pubsub \
--push-auth-service-account=pubsub-push@PROJECT_ID.iam.gserviceaccount.com \
--push-auth-token-audience=https://example.com/webhooks/google-pubsub
The service account needs no roles of its own; it exists as an identity for the token, and its email becomes the email claim you verify. If you omit --push-auth-token-audience, the aud claim defaults to the full push endpoint URL, and your receiver must match it byte for byte (a trailing slash difference is enough to fail verification). Setting an explicit, stable audience is easier to operate.
Add a dead letter topic (--dead-letter-topic, --max-delivery-attempts) so a message your handler can never process doesn't retry forever.
For local development, Pub/Sub requires a public HTTPS endpoint, so pair your local server with the Hookdeck CLI (hookdeck listen 3000 google-pubsub --path /webhooks/google-pubsub) and use the printed URL as the push endpoint. Note that the Pub/Sub emulator never sends an Authorization header, so emulator traffic always exercises the unauthenticated path.
Securing Pub/Sub push webhooks
A default push subscription sends no proof of origin at all: no signature, no token, no header worth checking. Anyone who learns the URL can POST a fake envelope. Configure OIDC authentication and verify the token on every request.
Verifying the OIDC token
With --push-auth-service-account set, every push carries Authorization: Bearer <JWT>, a Google-signed RS256 OpenID Connect ID token. Verify it with Google's official auth library, which fetches and caches Google's public keys and checks the signature, aud, and exp. Three claims are left to you: iss, email, and email_verified.
const { OAuth2Client } = require("google-auth-library");
const client = new OAuth2Client();
// Both are valid Google issuers; the official libraries accept either
const ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
app.post("/webhooks/google-pubsub", express.json(), async (req, res) => {
// 1. Verify the token: signature, aud, and exp via the library
const [scheme, token] = String(req.headers.authorization || "").split(" ");
if (!token || scheme.toLowerCase() !== "bearer") return res.sendStatus(401);
let claims;
try {
const ticket = await client.verifyIdToken({
idToken: token,
audience: process.env.PUBSUB_AUDIENCE,
});
claims = ticket.getPayload();
} catch {
return res.sendStatus(401);
}
// 2. Claims the library leaves to you
if (
!ISSUERS.includes(claims.iss) ||
claims.email_verified !== true ||
claims.email.toLowerCase() !==
process.env.PUBSUB_SERVICE_ACCOUNT_EMAIL.toLowerCase()
) {
return res.sendStatus(401);
}
// 3. Parse the envelope and ack fast; data is base64 and may be absent
const { message } = req.body;
const payload = message.data
? JSON.parse(Buffer.from(message.data, "base64").toString("utf8"))
: null;
processQueue.add({
id: message.messageId,
type: message.attributes?.eventType,
payload,
});
res.sendStatus(204);
});
Skipping the email check is the classic mistake: without it, any Google-signed ID token with the right audience is accepted, including one minted by an unrelated project. Three comparisons must stay lenient or you'll reject valid deliveries: accept both issuer forms, match the Bearer scheme case-insensitively (RFC 9110), and compare the service account email case-insensitively. Tokens on push requests can be up to an hour old, so don't add a tighter freshness check of your own.
Note what the token does and doesn't prove: it authenticates the caller (your push service account), not the body. There's no body-integrity guarantee, which also means there's no raw-body requirement; parsing JSON before authenticating is safe here in a way it never is for an HMAC webhook.
If you can't use OIDC
The documented fallback is an unguessable token in the push endpoint URL (?token=...), checked server-side; Google's own App Engine sample uses this pattern under the name PUBSUB_VERIFICATION_TOKEN. It's a shared-secret convention rather than a signature scheme: it authenticates nothing about the body and is only as strong as your TLS and logging hygiene, since URLs with query strings end up in access logs. Prefer OIDC, and pair either approach with network-level ingress restriction.
Make Pub/Sub push production-ready. Hookdeck Event Gateway verifies the Google-signed OIDC token upstream, deduplicates, and durably queues every message.
Pub/Sub webhook limitations and pain points
Unauthenticated by default
The Problem: A push subscription created without --push-auth-service-account sends nothing that proves the request came from Google, and your handler can't tell a real envelope from a fabricated one.
Why It Happens: Pub/Sub is a transport, and authentication is a per-subscription opt-in rather than a platform default.
Workarounds:
- Create every production subscription with a push auth service account, and use the URL-token pattern plus ingress restriction only where OIDC genuinely isn't possible.
How Hookdeck Can Help: Hookdeck's Google Pub/Sub source supports only the OIDC posture, with no unauthenticated option, so routing through it forces the authenticated configuration.
Verification has sharp edges
The Problem: The audience must match byte for byte (a trailing slash fails it), iss has two valid forms, the Bearer scheme and service account email are case-insensitive, and the email/email_verified checks are yours to remember. Each of these is a plausible source of rejected-but-valid deliveries, or worse, an acceptance hole.
Why It Happens: OIDC token verification is split between what the library checks (signature, aud, exp) and what the receiver must check itself, and the failure modes differ in direction.
Workarounds:
- Use the official
google-auth-libraryorgoogle-authpackages rather than hand-rolling JWT verification, and always checkemailandemail_verifiedafter the library returns.
How Hookdeck Can Help: The Google Pub/Sub source takes exactly two fields, the audience and the service account email, and runs the full claim verification upstream, so the sharp edges are handled once rather than in every receiver.
A 10-second ack deadline and status-code acknowledgement
The Problem: The default ack deadline is 10 seconds, any non-ack status or timeout is a nack, and sustained nacks trigger a push backoff of 100ms to 60 seconds across the whole subscription, so one slow or failing handler degrades delivery for everything on it.
Why It Happens: Push delivery reuses the HTTP response as the acknowledgement channel, and backoff protects the subscription rather than the individual message.
Workarounds:
- Ack immediately and process asynchronously, raise the ack deadline (up to 600 seconds) where queuing isn't an option, and configure a dead letter topic so poison messages stop retrying.
How Hookdeck Can Help: Hookdeck acknowledges Pub/Sub instantly, then delivers to your handler at a rate you set with automatic retries on failure, so a slow consumer never triggers subscription-wide backoff.
At-least-once means duplicates
The Problem: Duplicate deliveries are normal, not exceptional, and a handler that isn't idempotent will double-process.
Why It Happens: Pub/Sub guarantees at-least-once delivery; exactly-once exists only for pull subscriptions.
Workarounds:
- Deduplicate on
message.messageId, which is stable across redeliveries, and make side effects idempotent.
How Hookdeck Can Help: Deduplication rules filter repeats at the edge before they reach your handler. See our guide to webhook idempotency.
Best practices
Configure OIDC and verify every claim
Create subscriptions with a push auth service account, and verify the signature, aud, iss (both forms), email (case-insensitive), and email_verified on every request.
Match the audience exactly
If the subscription sets no explicit audience, the audience is the full push endpoint URL, byte for byte. Set an explicit, stable audience so the endpoint URL can change without breaking verification.
Ack fast, process asynchronously
You have 10 seconds by default, and the acknowledgement is your status code. Return 2xx immediately and defer work to a queue. See why to process webhooks asynchronously.
Dedupe on messageId
message.messageId is stable across redeliveries. Key your idempotency checks on it.
Agree a routing attribute with your publisher
There's no event catalog, so establish a convention like attributes.eventType and route on it without decoding data.
Handle absent data
Messages can be published with attributes only. A handler that assumes message.data exists will crash on valid traffic.
Add a dead letter topic
Without one, a message your handler can never process retries forever.
Conclusion
Pub/Sub push subscriptions have no HMAC signature: authentication is an opt-in Google-signed OIDC token that proves the caller rather than the content, the acknowledgement is your HTTP status code against a 10-second default deadline, and delivery is at-least-once with no event catalog beyond what your publisher defines. Enable OIDC, verify all the claims, ack fast, and dedupe on messageId.
Hookdeck Event Gateway verifies the OIDC token upstream, deduplicates, durably queues every message, and delivers to your handler at a controlled rate with retries and replay, so backoff and redelivery stop being your handler's problem.
Get started with Hookdeck for free and handle Google Cloud Pub/Sub push messages reliably in minutes.