# Webhooks

A webhook tells another system that something changed in Potion. Subscribe a URL to a **structure**, and whenever one of its rows is created, updated or deleted — from the app, iOS, an assistant, an html block or the API — Potion POSTs the row to that URL within seconds.

That is how Potion reaches the world outside it: a server that sends a message when a weight crosses 90, a script that copies each expense to a spreadsheet, a bridge that turns a real light on when the lamp note is switched.

A webhook needs a public URL. A program that has none (on a laptop, in a terminal) can **watch** the structure instead: one open connection that receives the rows again on every change. See [Watching live](/docs/api#watching-live).

::toc

## Setting one up

**In the app.** Open **Records**, pick the structure, switch to **Structure**, and find **Webhooks** → **Add**. Give the URL, pick the events, and optionally narrow it. The signing secret is shown once, when you save: copy it to your receiver. Each webhook has **Send test**, a **Log** of what was sent, and **Turn off**.

**With the API.** `POST /v1/webhooks` with an API key:

```text
curl -X POST https://api.potion.run/v1/webhooks \
  -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"structure":"weight","url":"https://example.com/hooks/potion","filter":[{"field":"kg","op":"gte","value":90}],"fields":["kg"]}'
```

The answer carries `secret`. Every endpoint is listed in [the API](/docs/api#webhooks).

**With an assistant.** Ask it: "call https://example.com/hooks/potion whenever I log a weight over 90." It uses `create_webhook`, and hands you the secret.

## What fires it

A subscription is always **one structure**. Two optional narrowers make it smaller:

| Narrower | Means |
| --- | --- |
| `events` | Any of `record.created`, `record.updated`, `record.deleted`. All three by default |
| `filter` | Only rows that match — the same flat filter as record reads, e.g. `[{"field":"kg","op":"gte","value":90}]`. One record is `[{"field":"id","op":"eq","value":"<id>"}]` |
| `fields` | An update fires only when one of these fields changed. Created and deleted always fire |

A change that touches nothing but the timestamps fires nothing. A filter or field the structure cannot answer is refused when you save it, not discovered later by its silence.

## What arrives

A `POST` with `Content-Type: application/json`:

```json
{
  "id": "the event id — the same on every retry",
  "type": "record.updated",
  "occurred_at": "2026-09-23T19:20:26Z",
  "workspace_id": "…",
  "structure": { "id": "…", "name": "weight" },
  "record": { "id": "…", "data": { "kg": 91.2, "when": "2026-09-23" }, "created_at": "…", "updated_at": "…", "deleted_at": null },
  "changed": ["kg"],
  "previous": { "kg": 89.8 },
  "source": { "surface": "api", "key_id": "…" }
}
```

- The **whole row**, never a diff. On an update, `changed` names the fields that moved and `previous` holds their old values.
- `source.surface` says where the write came from: `web`, `ios`, `chrome`, `mcp`, `api` or `block`. `source.key_id` names the API key when one made it.
- The **Send test** button and `POST /v1/webhooks/:id/test` send `"type": "webhook.test"` with `record: null`.

Headers: `X-Potion-Event` (the type), `X-Potion-Delivery` (this delivery), `X-Potion-Signature`.

## Checking the signature

Anyone can POST to your URL; the signature proves it was Potion. `X-Potion-Signature` is `t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `<t>.<raw body>` keyed with your secret. Verify the **raw body**, before parsing it:

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/hooks/potion', express.raw({ type: 'application/json' }), (req, res) => {
  const [, t, v1] = (req.header('x-potion-signature') ?? '').match(/^t=(\d+),v1=([0-9a-f]{64})$/) ?? [];
  const expected = crypto.createHmac('sha256', process.env.POTION_SECRET).update(`${t}.${req.body}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  if (!v1 || !fresh || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  console.log(event.type, event.record?.data);
  res.sendStatus(200);
});

app.listen(4000);
```

No Potion library is needed: any language with HMAC-SHA256 can do this. Automation tools (n8n, Zapier, Make, Pipedream) accept the URL as it is.

## Delivery

- Answer with any **2xx** within **10 seconds**. Do the slow work after answering.
- Anything else is **retried** after 1 minute, 10 minutes, 1 hour and 6 hours, then marked failed. **Resend** in the log, or `POST …/deliveries/:id/redeliver`, sends one again.
- **Ten failures in a row pause the webhook.** Events keep queueing while it is paused; **Resume** (or `resume: true`) sends them.
- An event can arrive **twice**, and order is not guaranteed: deduplicate on `id`, and compare `record.updated_at` if order matters.
- Only public **https** addresses are called, and redirects are not followed.
- The log keeps 30 days. A workspace holds up to 25 webhooks. Rotate a leaked secret with `POST /v1/webhooks/:id/rotate-secret`; the old one stops at once.

## Writing back

A receiver often writes back — the light reports its real state, the spreadsheet row gets an id. Writing to the **same** structure would fire the webhook again, forever. So subscribe with `"ignore_own_key": true` and write with that same API key: writes made with that key never fire that webhook, and everything else still does.

## Example: a real light

The lamp note writes a row to a `lamp` structure (`color`, `brightness`, `on`). To make a real bulb follow it:

1. Add a webhook on `lamp` with `fields: ["color", "brightness", "on"]` and `ignore_own_key: true`, pointing at a small server at home (behind a tunnel) or in the cloud.
2. The receiver verifies the signature, reads `event.record.data`, and calls the light's own API — Home Assistant's `light/turn_on` with `rgb_color` and `brightness_pct`, or the Hue bridge directly.
3. When someone uses the wall switch, the receiver writes the real state back with `POST /v1/records`; the lamp note, which watches the structure, shows it.
