Agent skill

PlanetScale Webhooks Skill

Receive and verify PlanetScale webhooks. Use when setting up PlanetScale webhook handlers, debugging X-PlanetScale-Signature verification, or handling branch.ready, deploy_request.opened, backup.failed and cluster.storage events from PlanetScale (managed Vitess and Postgres).

Install this skill

npx skills add hookdeck/webhook-skills --skill planetscale-webhooks


When to Use This Skill

  • How do I receive PlanetScale webhooks?
  • How do I verify the X-PlanetScale-Signature header?
  • How do I handle branch.ready, deploy_request.opened, backup.failed or cluster.storage events (notify Slack, update a ticket, run CI on schema changes)?
  • Why is my PlanetScale webhook signature verification failing (the docs' JavaScript sample uses JSON.stringify(req.body))?

Verification (core)

PlanetScale signs the raw request body with HMAC-SHA256, keyed with the webhook's secret, and sends the bare lowercase hex digest in X-PlanetScale-Signature. There is no sha256= prefix, no timestamp and no list of signatures: compare the whole header value as one digest.

  • The HMAC key is the secret's UTF-8 bytes, used as-is (not hex- or base64-decoded).
  • Sign bytes: the body exactly as received. Never JSON.parse / JSON.stringify before verifying (the docs' JavaScript sample does JSON.stringify(req.body), which does not match byte-for-byte; the Python sample uses the raw body, and so should you).
  • Require exactly 64 hex characters before decoding (Buffer.from(x, 'hex') silently drops bad characters), then compare in constant time. Hex is case-insensitive.

Node:

const crypto = require('crypto');

function verify(rawBody, signatureHeader, secret) {
  if (typeof signatureHeader !== 'string' || !secret) return false;
  if (!/^[0-9a-f]{64}$/i.test(signatureHeader)) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  return crypto.timingSafeEqual(Buffer.from(signatureHeader, 'hex'), expected);
}

Python (compare bytes so a non-ASCII header returns 401, not a 500):

import hmac, hashlib, re

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature_header):
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header.lower().encode("utf-8"), expected.encode("utf-8"))

Missing, empty or malformed header, or a mismatch: respond 401. In Express use express.raw({ type: '*/*' }) so the body stays a Buffer whatever the content type.

For complete handlers with route wiring, event dispatch, and tests, see:

Common Event Types

The event name is the body's top-level event field (there is no event-name header). timestamp (Unix seconds), organization, database and resource (the resource's API representation) sit beside it.

EventFires when
webhook.testYou click Test webhook (one test every 20 seconds)
branch.readyA branch is created and ready to connect
branch.anomalyA branch has a new anomaly in Insights
deploy_request.openedA deploy request is opened
deploy_request.queuedA deploy request is added to the deploy queue
deploy_request.in_progressA deploy request has started running
backup.failedA backup failed
backup.succeededA backup completed
cluster.storageA Postgres database reached a storage threshold (60/75/85/90/95%)
database.access_requestPlanetScale staff opens an access request

PlanetScale documents 21 events in total, including branch.primary_promoted, branch.out_of_memory, branch.schema_recommendation, branch.sleeping, branch.start_maintenance, deploy_request.pending_cutover, deploy_request.schema_applied, deploy_request.errored, deploy_request.reverted, deploy_request.closed and keyspace.storage. See references/overview.md.

For the full event reference, see PlanetScale webhook events.

Important Headers

HeaderDescription
X-PlanetScale-SignatureHex HMAC-SHA256 of the raw body, keyed with the webhook secret
AuthorizationOnly if you configured one on the webhook; sent unchanged. Authenticates PlanetScale to you and does not replace the signature

Delivery Behaviour

Respond 2xx within 2 seconds or the delivery is marked failed; redirects are not followed; webhooks that repeatedly fail are disabled. No retry policy is documented, so do the work asynchronously and dedupe on your side (there is no delivery ID header; derive a key from event, resource.id and timestamp). Each database can have up to 5 webhooks. Deliveries come from 3.209.149.66, 3.215.97.46 and 34.193.111.15.

Environment Variables

PLANETSCALE_WEBHOOK_SECRET=your_webhook_secret   # Database Settings > Webhooks > ... > Show secret

Setup

In the dashboard open your database, then Settings → Webhooks → Add webhook, enter an https:// URL, pick events and save. Use … → Show secret to copy the secret (one per webhook), and … → Test webhook to send webhook.test. Webhooks can also be managed with pscale webhook or the API. See references/setup.md.

When using Hookdeck, paste the webhook secret into the PlanetScale source's webhook secret field: Hookdeck's source verifies X-PlanetScale-Signature with the same HMAC-SHA256 hex scheme.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 planetscale --path /webhooks/planetscale

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →