Potion/Docs

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.

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:

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.

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:

NarrowerMeans
eventsAny of record.created, record.updated, record.deleted. All three by default
filterOnly 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>"}]
fieldsAn 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:

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

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.