Gareth Wilson Gareth Wilson

Guide to PlanetScale Webhooks: Features and Best Practices

Published


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_request.schema_applied, 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

FeatureDetails
Signaturehex HMAC-SHA256
Signature headerx-planetscale-signature
BodyJSON
Event routingBody 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 2xx before marking the delivery as failed.
  • Webhooks that repeatedly fail are disabled.
  • Deliveries come from 3.209.149.66, 3.215.97.46 or 34.193.111.15.
  • An optional Authorization header 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.test event every 20 seconds.

Events and payloads

| Event routing | Body field event |

Some events depend on the database engine:

EventTriggered WhenEngines
branch.primary_promotedA new primary was promoted for the branchPostgres
branch.out_of_memoryA Postgres database had an out of memory eventPostgres
branch.schema_recommendationA new schema recommendation was createdVitess, Neki, Postgres
branch.sleepingThe branch is now sleepingVitess, Neki, Postgres
branch.start_maintenanceA production branch is about to start maintenanceVitess, Neki, Postgres
deploy_request.pending_cutoverThe deploy request is ready to cut over and waiting on the userVitess
deploy_request.schema_appliedThe deploy request finished applying the schemaVitess
deploy_request.erroredThe deploy request stopped due to an errorVitess
deploy_request.revertedThe deploy request was revertedVitess
deploy_request.closedThe deploy request was closedVitess
keyspace.storageA Vitess keyspace reached a storage thresholdVitess

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

  1. In the PlanetScale dashboard, select the database, then Settings > Webhooks.
  2. Click Add webhook and enter an HTTPS URL, for example https://example.com/webhooks/planetscale.
  3. If your receiver also wants a bearer token, add the full Authorization value, scheme included.
  4. Choose the events and click Save webhook.
  5. Click ... next to the webhook, then Show secret, and store it as PLANETSCALE_WEBHOOK_SECRET.
  6. Use ... > Test webhook to send a webhook.test event.

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.id and timestamp.
  • For state events, compare resource.state or updated_at with 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.