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-Signatureheader? - How do I handle
branch.ready,deploy_request.opened,backup.failedorcluster.storageevents (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.stringifybefore verifying (the docs' JavaScript sample doesJSON.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.
| Event | Fires when |
|---|---|
webhook.test | You click Test webhook (one test every 20 seconds) |
branch.ready | A branch is created and ready to connect |
branch.anomaly | A branch has a new anomaly in Insights |
deploy_request.opened | A deploy request is opened |
deploy_request.queued | A deploy request is added to the deploy queue |
deploy_ | A deploy request has started running |
backup.failed | A backup failed |
backup.succeeded | A backup completed |
cluster.storage | A Postgres database reached a storage threshold (60/75/85/90/95%) |
database.access_request | PlanetScale staff opens an access request |
PlanetScale documents 21 events in total, including branch.primary_promoted, branch.out_of_memory, branch.schema_, branch.sleeping, branch.start_maintenance, deploy_, deploy_, 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
| Header | Description |
|---|---|
X-PlanetScale-Signature | Hex HMAC-SHA256 of the raw body, keyed with the webhook secret |
Authorization | Only 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
- references/overview.md - PlanetScale webhook concepts, events and payload
- references/setup.md - Configuration guide
- references/verification.md - Signature verification details