# Google Cloud Pub/Sub Webhooks (Push Subscriptions)

## When to Use This Skill

* How do I receive Google Cloud Pub/Sub messages at an HTTP endpoint?
* How do I verify a Pub/Sub push request is really from Google?
* How do I validate the OIDC JWT in the `Authorization: Bearer` header?
* Why does Pub/Sub keep redelivering the same message to my webhook?
* How do I decode `message.data` from a Pub/Sub push envelope?
* Why is my Pub/Sub push endpoint returning 401?

## How Pub/Sub Push Differs From HMAC Webhooks

Pub/Sub is not a signed-webhook provider. Read this before looking for a
signature header — there isn't one.

* No signing secret, no HMAC header. There is no `X-Goog-Signature`, no
  Standard Webhooks (`webhook-id` / `webhook-timestamp` / `webhook-signature`).
  Do not invent one.
* The body is never signed. On an authenticated push subscription the
  `Authorization: Bearer <JWT>` header proves who is calling (your push service
  account), not what they sent. So unlike Stripe or Shopify, you do not
  need the raw body — parsing JSON first is fine here.
* No event catalog. Pub/Sub carries whatever your publisher published. Event
  semantics live entirely in `message.data` and `message.attributes`. There are
  no `payment.succeeded`-style Pub/Sub event names.
* Two auth postures, depending on how the subscription was created:

| Posture | What Pub/Sub sends | What you can check |
| --- | --- | --- |
| Default (no subscription auth) | Nothing proving origin | An unguessable `?token=` in the push URL, plus network-level ingress restriction |
| OIDC (`--push-auth-service-account`, recommended) | `Authorization: Bearer <Google-signed OIDC JWT>` | RS256 signature, `iss`, `aud`, `email`, `email_verified` |

Configure OIDC. See [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/google-pubsub-webhooks/references/setup.md).

## The Push Envelope

`POST` with `Content-Type: application/json`:

```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 (messages can be published with
attributes only). `attributes` is optional. `orderingKey` and `deliveryAttempt`
appear only when enabled.

## Verification (core)

Verify the OIDC token with Google's official auth library — it fetches and
caches Google's public keys and checks the RS256 signature, `aud`, and `exp`.
You must still check the service account yourself.

```javascript
const { OAuth2Client } = require('google-auth-library');
const client = new OAuth2Client();

// Both are valid Google issuers — accept either, as the official libraries do.
const GOOGLE_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];

async function verifyPushJwt(authorizationHeader) {
  // RFC 9110: the auth scheme is case-insensitive.
  const [scheme, token] = String(authorizationHeader || '').split(' ');
  if (!token || scheme.toLowerCase() !== 'bearer') return null;

  let claims;
  try {
    // Verifies the RS256 signature against Google's public keys, plus aud + exp.
    const ticket = await client.verifyIdToken({
      idToken: token.trim(),
      audience: process.env.PUBSUB_AUDIENCE, // defaults to the push endpoint URL
    });
    claims = ticket.getPayload();
  } catch {
    return null;
  }

  // Checks the library does not do for you.
  if (!claims.iss || !GOOGLE_ISSUERS.includes(claims.iss)) return null;
  if (claims.email_verified !== true) return null;

  // Service account emails are case-insensitive — normalize before comparing.
  const email = String(claims.email || '').toLowerCase();
  if (!email || email !== process.env.PUBSUB_SERVICE_ACCOUNT_EMAIL.trim().toLowerCase()) {
    return null;
  }
  return claims;
}

```

Python is the same shape with `google-auth`:
`id_token.verify_oauth2_token(token, google.auth.transport.requests.Request(), audience)`,
then check `iss` / `email` / `email_verified` yourself. Details and the
unauthenticated fallback are in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/google-pubsub-webhooks/references/verification.md).

> For complete handlers with envelope parsing, subscription allowlisting, and tests, see:
> 
> * [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/google-pubsub-webhooks/examples/express/)
> * [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/google-pubsub-webhooks/examples/nextjs/)
> * [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/google-pubsub-webhooks/examples/fastapi/)

## Acknowledgement and Redelivery

* Ack by returning `200`, `201`, `202`, `204`, or `102`. Any other status
  code — and any timeout — is a nack, and Pub/Sub redelivers.
* Respond within the subscription's ack deadline (default 10 seconds,
  configurable up to 600 seconds). Do slow work asynchronously and ack fast.
* Delivery is at-least-once: duplicates are normal, not a bug. De-duplicate
  on `message.messageId`, which is stable across redeliveries of the same message.
* Repeated nacks trigger push backoff (100 ms up to 60 s) across the whole
  subscription, not just the failing message.

## Environment Variables

```bash
# OIDC push subscription (recommended)
PUBSUB_AUDIENCE=https://example.com/webhooks/google-pubsub
PUBSUB_SERVICE_ACCOUNT_EMAIL=pubsub-push@my-project.iam.gserviceaccount.com

# Optional: shared token embedded in the push endpoint URL (?token=...)
PUBSUB_VERIFICATION_TOKEN=

# Optional: reject envelopes from a subscription you don't expect
PUBSUB_SUBSCRIPTION=projects/my-project/subscriptions/my-sub

# Escape hatch for the Pub/Sub emulator, which sends no auth at all
PUBSUB_ALLOW_UNAUTHENTICATED=false

```

There is no signing secret — nothing here is a shared HMAC key. The examples
fail closed: if neither OIDC nor a verification token is configured, they reject
requests until `PUBSUB_ALLOW_UNAUTHENTICATED=true` is set explicitly.

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 google-pubsub --path /webhooks/google-pubsub

```

Use the printed HTTPS URL as the subscription's push endpoint. If the
subscription has no explicit audience, the audience is that full URL — set
`PUBSUB_AUDIENCE` to match it exactly, including the path.

The Pub/Sub emulator sends no `Authorization` header, so local emulator
testing always exercises the unauthenticated path.

## Using the Hookdeck `GOOGLE_PUBSUB` Source

If you receive these through Hookdeck rather than at your own endpoint,
OIDC is mandatory — the source type's verification takes exactly two
required fields and there is no unauthenticated or URL-token option:

| Field | Value |
| --- | --- |
| Audience | The subscription's `--push-auth-token-audience`, or the full push endpoint URL if the subscription sets none |
| Service Account Email | The subscription's `--push-auth-service-account` |

Hookdeck verifies the JWT signature and the `iss` / `aud` / `email` /
`email_verified` claims, but deliberately ignores `exp`: it re-verifies
stored requests on retry, and enforcing expiry would fail a request that was
valid when it arrived. Your own endpoint is in a different position — it sees
each request once, live — so the examples here do let the library enforce
`exp`. Do not copy the gateway's expiry behaviour into a direct receiver.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/google-pubsub-webhooks/references/overview.md) - Push vs pull, envelope fields, attributes, delivery semantics
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/google-pubsub-webhooks/references/setup.md) - Create a topic, an OIDC push subscription, and grant the IAM roles
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/google-pubsub-webhooks/references/verification.md) - OIDC token validation, claims, unauthenticated fallback, gotchas