Guide to PlanetScale Webhooks: Features and Best Practices
PlanetScale runs managed MySQL-compatible databases on Vitess, and Postgres, with Git-style branches and deploy requests for schema changes. Its webhooks let your tools follow that workflow: a CI job can wait for deploy_, an on-call channel can hear about backup.failed, and a capacity dashboard can pick up storage thresholds.
Below we cover how PlanetScale scopes webhooks per database, the 21 events and which engines send them, signature verification (including a trap in PlanetScale's own JavaScript sample), and its 2-second response limit.
What are PlanetScale webhooks?
A PlanetScale webhook belongs to one database. Each database can have up to five, and each webhook has its own URL, event list and secret. Only database administrators can create them, in the dashboard, with pscale webhook or through the API.
Deliveries are HTTPS POST requests with a JSON body. The body names the organisation and database, so one endpoint can serve webhooks from several databases.
PlanetScale webhook features
| Feature | Details |
|---|---|
| Signature | hex HMAC-SHA256 |
| Signature header | x-planetscale-signature |
| Body | JSON |
| Event routing | Body field event |
From PlanetScale's webhook documentation:
- The URL must use HTTPS and be reachable from the internet. Redirects aren't followed.
- PlanetScale waits at most 2 seconds for a
2xxbefore marking the delivery as failed. - Webhooks that repeatedly fail are disabled.
- Deliveries come from
3.209.149.66,3.215.97.46or34.193.111.15. - An optional
Authorizationheader value is sent unchanged with every delivery. It's write-only once saved, and it supplements the signature rather than replacing it. - You can send one
webhook.testevent every 20 seconds.
Events and payloads
| Event routing | Body field event |
Some events depend on the database engine:
| Event | Triggered When | Engines |
|---|---|---|
branch.primary_promoted | A new primary was promoted for the branch | Postgres |
branch.out_of_memory | A Postgres database had an out of memory event | Postgres |
branch.schema_ | A new schema recommendation was created | Vitess, Neki, Postgres |
branch.sleeping | The branch is now sleeping | Vitess, Neki, Postgres |
branch.start_maintenance | A production branch is about to start maintenance | Vitess, Neki, Postgres |
deploy_ | The deploy request is ready to cut over and waiting on the user | Vitess |
deploy_ | The deploy request finished applying the schema | Vitess |
deploy_request.errored | The deploy request stopped due to an error | Vitess |
deploy_request.reverted | The deploy request was reverted | Vitess |
deploy_request.closed | The deploy request was closed | Vitess |
keyspace.storage | A Vitess keyspace reached a storage threshold | Vitess |
Every body has timestamp (Unix seconds), event, organization, database and resource. resource has the same shape as PlanetScale's API response for that object, such as a branch, deploy request or backup. cluster.storage is different: it carries branch_name, storage_percentage and a storage array. There's no event ID.
A backup.failed delivery from the webhook samples library:
{
"timestamp": 1698252879,
"event": "backup.failed",
"organization": "myorg",
"database": "example_database",
"resource": {
"id": "k2n8pqw4x1yz",
"type": "Backup",
"name": "2026.08.27 15:04:05.123",
"state": "failed",
"size": 0,
"estimated_storage_cost": 0,
"created_at": "2026-08-27T15:04:05.123Z",
"updated_at": "2026-08-27T15:12:44.890Z",
"started_at": "2026-08-27T15:04:06.001Z",
"expires_at": "2026-09-26T15:04:05.123Z",
"completed_at": "2026-08-27T15:12:44.890Z",
"deleted_at": null,
"pvc_size": 0,
"uncompressed_size": 0,
"protected": false,
"required": true,
"restored_branches": [],
"actor": null,
"backup_policy": {
"id": "p0licy12abcd",
"type": "BackupPolicy",
"name": "production",
"target": "production",
"retention_value": 2,
"retention_unit": "day",
"frequency_value": 12,
"frequency_unit": "hour",
"schedule_time": "00:00",
"created_at": "2026-01-15T12:00:00.000Z",
"updated_at": "2026-01-15T12:00:00.000Z"
},
"schema_snapshot": null,
"database_branch": {
"id": "q9x7ylb3hcmx",
"type": "Branch",
"name": "main"
}
}
}
Setting up PlanetScale webhooks
- In the PlanetScale dashboard, select the database, then Settings > Webhooks.
- Click Add webhook and enter an HTTPS URL, for example
https://example.com/webhooks/planetscale. - If your receiver also wants a bearer token, add the full
Authorizationvalue, scheme included. - Choose the events and click Save webhook.
- Click ... next to the webhook, then Show secret, and store it as
PLANETSCALE_.WEBHOOK_ SECRET - Use ... > Test webhook to send a
webhook.testevent.
To route through Hookdeck, create a PlanetScale source with the secret and use its URL as the webhook URL. If you firewall your endpoint to PlanetScale's three IPs, allow Hookdeck's traffic instead once Hookdeck is in front. For local development:
npx hookdeck-cli listen 3000 planetscale --path /webhooks/planetscale
Securing PlanetScale webhooks
X-PlanetScale-Signature is a SHA-256 HMAC hex digest of the request body, keyed with the webhook secret. The header is the bare digest, with no prefix or timestamp.
PlanetScale's JavaScript and TypeScript examples compute the HMAC over JSON.stringify(req.body). That re-serialises the parsed body and can produce different bytes from what PlanetScale signed. Its Python and Ruby examples, and the prose on the same page, use the exact body, which is what the code below does.
Node.js verification
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-planetscale-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
The handler checks the signature, optionally checks the Authorization value you configured, and queues the event so the response goes back well inside 2 seconds.
const express = require('express');
const app = express();
app.post('/webhooks/planetscale', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.PLANETSCALE_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const expectedAuth = process.env.PLANETSCALE_WEBHOOK_AUTH;
if (expectedAuth && req.headers.authorization !== expectedAuth) return res.sendStatus(401);
let parsed;
try {
parsed = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
const { event, organization, database, resource, timestamp } = parsed;
try {
if (event === 'webhook.test') return res.sendStatus(200);
await queue.add('planetscale', { event, organization, database, resource, timestamp });
} catch {
return res.sendStatus(503);
}
res.sendStatus(200);
});
Hookdeck's PlanetScale source verifies the same header. It returns 200 to PlanetScale immediately and records a mismatch as failed verification, which keeps you clear of the 2-second limit. PlanetScale signs no timestamp, and Hookdeck doesn't apply one.
Make PlanetScale webhooks production-ready. Hookdeck Event Gateway answers PlanetScale well within 2 seconds, so your webhook isn't disabled by slow handlers, and holds events for retry when your service is down.
PlanetScale webhook limitations and pain points
A 2-second response limit
The Problem: Handlers that call another API or write to a slow store can miss the deadline, and the delivery counts as failed.
Why It Happens: PlanetScale waits at most 2 seconds for a 2xx, and PlanetScale itself recommends responding immediately and processing asynchronously.
Workarounds:
- Verify, enqueue and return. Do everything else in a worker.
- Keep the endpoint warm, since a cold start alone can use most of the budget.
How Hookdeck Can Help: Hookdeck responds to PlanetScale straight away and delivers to your service with a longer timeout.
Failing webhooks are disabled, and retries are undocumented
The Problem: You can't tell whether a failed delivery will be sent again, and a run of failures turns the webhook off.
Why It Happens: PlanetScale says repeatedly failing webhooks will be disabled, but doesn't document retries, how many failures trigger disabling, or whether you're told.
Workarounds:
- Check the webhook's status in the dashboard after an outage.
- After downtime, check deploy request and backup state in PlanetScale rather than assuming you saw every event.
How Hookdeck Can Help: Hookdeck accepts every delivery, so PlanetScale sees no failures, and Hookdeck retries your service under a policy you set.
The JavaScript sample can reject valid requests
The Problem: Copying PlanetScale's Node example can produce signature mismatches.
Why It Happens: It hashes JSON.stringify(req.body) after Express has parsed the JSON, rather than the raw bytes. Any difference in key order, spacing or escaping breaks the match.
Workarounds:
- Use
express.raw()on the webhook route and hash the buffer. - Make sure no global JSON middleware runs before it.
How Hookdeck Can Help: Hookdeck verifies against the bytes it received, so a body-parsing mistake in your framework can't cause it to reject genuine PlanetScale requests.
No event ID
The Problem: Telling a repeat from a new event isn't straightforward.
Why It Happens: The body carries timestamp, event and the resource, but no delivery or event identifier.
Workarounds:
- Build a key from
event,resource.idandtimestamp. - For state events, compare
resource.stateorupdated_atwith what you've stored.
How Hookdeck Can Help: Hookdeck can deduplicate on chosen body fields, such as event and resource.id, within a time window.
Best practices
Use one endpoint for many databases
Each database has its own webhooks, but organization and database are in every body. Point them all at one URL and store a secret per database, chosen by those fields after parsing. Verify with each candidate secret if you don't want to parse before verifying.
Watch the five-webhook limit
With five webhooks per database, fan-out to several tools quickly uses them up. Send one webhook to a router and distribute from there.
Keep processing off the request
Two seconds leaves no room for synchronous work. Our guide to async webhook processing describes the queue pattern.
Guard against duplicate side effects
Re-running a migration step or re-posting an alert is the usual cost of a repeat. See the webhook idempotency guide for ways to record what you've handled.
Conclusion
PlanetScale webhooks are per database, signed with a hex HMAC-SHA256 in X-PlanetScale-Signature, and expect a 2xx within 2 seconds. Hash the raw body rather than following the JavaScript sample, build your own deduplication key, and keep handlers fast enough that PlanetScale doesn't disable the webhook.
Hookdeck Event Gateway acknowledges PlanetScale instantly and retries delivery to your services.
Get started with Hookdeck for free and handle PlanetScale webhooks reliably in minutes.