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

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-planetscale-signature` |
| Body | JSON |
| Event routing | Body field `event` |

From PlanetScale's [webhook documentation](https://planetscale.com/docs/api/webhooks):

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

| 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_recommendation` | 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_request.pending_cutover` | The deploy request is ready to cut over and waiting on the user | Vitess |
| `deploy_request.schema_applied` | 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/planetscale/latest/backup.failed.json):

```json
{
  "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:

```bash
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

```javascript
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.

```javascript
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](/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) 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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) acknowledges PlanetScale instantly and retries delivery to your services.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle PlanetScale webhooks reliably in minutes.