Instant features
Webhooks
Webhooks allow you to subscribe to changes to your entities via POST requests to your server.
#How webhooks work
A webhook subscribes to one or more namespaces and actions (create, update, delete). Whenever a matching write commits, Instant queues an event and POSTs it to your endpoint.
Each request carries an Instant-Signature header and a small body. The body holds a short-lived URL and a JWT — you exchange them for the full payload of records:
{"data": [{"namespace": "posts","id": "<entity-id>","action": "update","before": { "id": "<entity-id>", "title": "Old title" },"after": { "id": "<entity-id>", "title": "New title" },"idempotencyKey": "<per-record-key>"}],"idempotencyKey": "<batch-key>"}
before is null on create, after is null on delete. The idempotencyKey is stable across redeliveries — use it to dedupe if your handler isn't idempotent on its own.
#Delivery and retries
Instant retries failed deliveries with backoff. An event moves through these stages:
pending— queued, not yet attemptedprocessing— a sender is actively trying to deliversuccess— receiver returned2xxerror— an attempt failed; another retry is scheduledfailed— all retries exhausted; will not be retried automatically
Each delivery attempt has a 15-second timeout — if your endpoint hasn't responded by then the attempt is recorded as a timeout error and Instant retries. Do any slow work (sending emails, calling third-party APIs, etc.) asynchronously, and respond with 2xx as soon as you've durably enqueued the work.
A webhook that fails too many times in a row is automatically disabled. You can re-enable it from the dashboard or via the SDK once you've fixed the receiver.
#Setting up a webhook
The easiest way to create a webhook is from the Webhooks tab in the dashboard: pick the namespaces, the actions, and the URL Instant should POST to.
You can also manage webhooks programmatically with npx instant-cli webhook or through the admin SDK:
// scripts/create-webhook.tsimport { init } from '@instantdb/admin';import schema from './instant.schema';const db = init({appId: process.env.INSTANT_APP_ID!,adminToken: process.env.INSTANT_APP_ADMIN_TOKEN!,schema,});const webhook = await db.webhooks.manager.create({url: 'https://example.com/api/instant-webhook',namespaces: ['posts', 'comments'],actions: ['create', 'update'],});
The URL must be https and resolve to a public host. An app can have up to 100 active webhooks at a time.
#Receiving webhooks
db.webhooks.processRequest is the one-liner for handling incoming events. It verifies the signature, fetches the payload, and dispatches each record to your code.
#Next.js (App Router)
// app/api/instant-webhook/route.tsimport { init } from '@instantdb/admin';import { sendNewPostEmail } from '@/lib/emails';import schema from '@/instant.schema';const db = init({appId: process.env.INSTANT_APP_ID!,schema,});const { typedHandlers, combineHandlers } = db.webhooks.helpers();const handlers = combineHandlers(typedHandlers('posts', 'create', async (record) => {await sendNewPostEmail(record.after);}),typedHandlers('posts', 'update', (record) => {console.log('post %s changed', record.id, record.before, record.after);}),typedHandlers('$default', (record) => {console.log('unhandled record', record);}),);export async function POST(req: Request) {await db.webhooks.processRequest(handlers, req);return new Response('ok');}
Webhooks.helpers<typeof schema>() gives you typedHandlers and combineHandlers. Inside each handler, record.before and record.after are typed according to your schema — TypeScript will autocomplete fields and narrow on action.
Handler resolution is most-specific-wins: namespace + action, then the namespace's $default, then the top-level $default. Records with no matching handler are skipped.
Handlers run concurrently. processRequest resolves once every handler resolves or a handler rejects; if any handler rejects, the call rejects too — return a non-2xx response so Instant retries.
#Next.js (Pages Router)
The Pages Router gives you a Node-style request, so use processNodeRequest. You also need to disable Next's body parser so the raw bytes are available for signature verification:
// pages/api/instant-webhook.tsimport type { NextApiRequest, NextApiResponse } from 'next';import { init, Webhooks } from '@instantdb/admin';import { sendNewPostEmail } from '@/lib/emails';import schema from '@/instant.schema';// Signature verification requires the raw bytesexport const config = { api: { bodyParser: false } };const db = init({appId: process.env.INSTANT_APP_ID!,adminToken: process.env.INSTANT_APP_ADMIN_TOKEN!,schema,});const { typedHandlers, combineHandlers } = Webhooks.helpers<typeof schema>();const handlers = combineHandlers(typedHandlers('posts', 'create', async (record) => {await sendNewPostEmail(record.after);}),);export default async function handler(req: NextApiRequest,res: NextApiResponse,) {try {await db.webhooks.processNodeRequest(handlers, req);res.status(200).end();} catch (e) {res.status(400).json({ error: String(e) });}}
#Express / other Node frameworks
Anywhere you have a Web Request, processRequest works directly. For frameworks that hand you a Node request, either bridge it to a Request yourself or read the raw body and call validate / fetchPayloads / processPayload:
import express from 'express';import { init, Webhooks } from '@instantdb/admin';import schema from './instant.schema';const { typedHandlers, combineHandlers } = Webhooks.helpers<typeof schema>();const handlers = combineHandlers(typedHandlers('$default', (record) => console.log(record)),);const app = express();app.post('/api/instant-webhook',express.raw({ type: '*/*' }),async (req, res) => {const db = init({appId: process.env.INSTANT_APP_ID!,schema,});const signature = req.header('instant-signature')!;const body = req.body.toString('utf8');try {const webhookBody = await db.webhooks.validate(signature, body);const payload = await db.webhooks.fetchPayloads(webhookBody);await db.webhooks.processPayload(handlers, payload);res.status(200).send('ok');} catch (e) {res.status(400).send(String(e));}},);
#Verifying signatures manually
If you'd rather not use the handler dispatch, you can stop after verification. validate parses and checks the Instant-Signature header against the body and returns the { payloadUrl, token } you'd use to fetch records:
const { payloadUrl, token } = await db.webhooks.validate(signatureHeader,rawBody,{ tolerance: 300 }, // max signature age in seconds; default 300);// Or, if you already have a Web Request:const body = await db.webhooks.validateRequest(req);const payload = await db.webhooks.fetchPayloads({ payloadUrl, token });
validate rejects requests whose signature is older than tolerance (default: 5 minutes) — this is what protects against replays, so don't crank it up without thinking about it.
#Managing webhooks programmatically
db.webhooks.manager exposes CRUD on webhooks and access to their delivery history. Use it from the admin SDK when you want to provision webhooks from code (e.g. during onboarding) rather than from the dashboard.
// Listconst webhooks = await db.webhooks.manager.list();// Createconst hook = await db.webhooks.manager.create({url: 'https://example.com/instant',namespaces: ['posts'],actions: ['create', 'update', 'delete'],});// Update — pass only the fields you want to changeawait db.webhooks.manager.update(hook.id, {actions: ['create', 'update'],});// Disable / re-enableawait db.webhooks.manager.disable(hook.id, { reason: 'paused for migration' });await db.webhooks.manager.enable(hook.id);// Deleteawait db.webhooks.manager.delete(hook.id);
update is a patch — omitted fields keep their current value. disable and enable don't change the config, only whether new events are queued. Events that occurred while a webhook was disabled are not retroactively delivered when you re-enable it.
#Inspecting events
Every delivery attempt is recorded for ~60 days and is queryable through the manager. This is useful when a downstream system seems out of sync, or when you want to replay a missed event.
// Page through events, newest firstlet cursor: string | null = null;do {const { events, pageInfo } = await db.webhooks.manager.listEvents(hook.id, {after: cursor,});for (const event of events) {console.log(event.isn, event.status, event.attempts);}cursor = pageInfo.hasNextPage ? pageInfo.endCursor : null;} while (cursor);// Fetch one event by its isn (Instant Sequence Number)const event = await db.webhooks.manager.getEvent(hook.id, isn);// Fetch the full payload for an eventconst payload = await db.webhooks.manager.getPayload(hook.id, isn);// Force a redelivery (works on success, error, or failed)await db.webhooks.manager.resendEvent(hook.id, isn);
Each event.attempts entry records the HTTP status, response body (first 256 bytes), duration, and an errorType tag (timeout, dns, connect, tls, protocol, network, unknown) when delivery failed — usually enough to tell whether the receiver is the problem or the network is.
resendEvent is rate-limited per event; if you call it twice in quick succession the second call will return a validation error and ask you to wait about a minute.
#Verifying and fetching from any language
The @instantdb/admin SDK is the easiest way to receive webhooks, but the protocol is plain HTTP + Ed25519 — you can implement a receiver in any language. The steps below are what validate and fetchPayloads do under the hood.
#1. The request
Every webhook arrives as a POST with two things you care about:
The
Instant-Signatureheader, a comma-separated list ofkey=valuepairs:Instant-Signature: t=1715551200,kid=1034696293,v1=4a8f...t— Unix timestamp (seconds) of when Instant signed the requestkid— id of the signing keyv1— hex-encoded Ed25519 signature
A JSON body containing a short-lived URL and JWT:
{ "payloadUrl": "https://api.instantdb.com/...", "token": "eyJ..." }
#2. Verify the signature
The signed message is t + . + the raw request body, as UTF-8 bytes. Verify the v1 signature against the Ed25519 public key whose kid matches the header. The public keys are published as a JWK Set at:
https://api.instantdb.com/.well-known/webhooks/jwks.json
Reject requests where t is older than a few minutes (the SDK defaults to 300 seconds) to prevent replays.
# pip install pynacl requestsimport base64, json, time, requestsfrom nacl.signing import VerifyKeyJWKS_URL = "https://api.instantdb.com/.well-known/webhooks/jwks.json"TOLERANCE_SECONDS = 300def _b64url_decode(s: str) -> bytes:return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))def _verify_key_for(kid: str) -> VerifyKey:for k in requests.get(JWKS_URL).json()["keys"]:if k["kid"] == kid and k["kty"] == "OKP" and k["crv"] == "Ed25519":return VerifyKey(_b64url_decode(k["x"]))raise ValueError(f"unknown kid {kid}")def verify_webhook(signature_header: str, raw_body: bytes) -> dict:parts = dict(p.split("=", 1) for p in signature_header.split(","))t, kid, v1 = parts["t"], parts["kid"], parts["v1"]if int(time.time()) - int(t) > TOLERANCE_SECONDS:raise ValueError("signature too old")message = t.encode("ascii") + b"." + raw_body_verify_key_for(kid).verify(message, bytes.fromhex(v1)) # raises BadSignatureErrorreturn json.loads(raw_body) # {"payloadUrl": ..., "token": ...}
#3. Fetch the payload
Once the signature checks out, parse the body as JSON and GET payloadUrl with the JWT in the Authorization header:
GET <payloadUrl>Authorization: Bearer <token>Accept: application/json
The response contains data array of records, plus a top-level idempotencyKey. The token is short-lived and will only fetch the single payload.
Respond 2xx once you've durably enqueued the records. Anything else (or no response within 15 seconds) is treated as a failure and the event is retried.