Agent skill

Google Cloud Pub/Sub Webhooks (Push Subscriptions) Skill

Receive and verify Google Cloud Pub/Sub push subscription webhooks. Use when setting up a Pub/Sub push endpoint, verifying the OIDC `Authorization: Bearer` JWT that Pub/Sub attaches to authenticated push subscriptions (iss, aud, email, email_verified), parsing the push envelope (`message.data` base64, `message.attributes`, `messageId`, `publishTime`, `subscription`), securing an unauthenticated push endpoint, or debugging redelivery and ack deadline issues.

Install this skill

npx skills add hookdeck/webhook-skills --skill google-pubsub-webhooks


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:
PostureWhat Pub/Sub sendsWhat you can check
Default (no subscription auth)Nothing proving originAn 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.

The Push Envelope

POST with 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 (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.

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.

For complete handlers with envelope parsing, subscription allowlisting, and tests, see:

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

# 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

# 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:

FieldValue
AudienceThe subscription's --push-auth-token-audience, or the full push endpoint URL if the subscription sets none
Service Account EmailThe 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 27, 2026

View on GitHub →