Skip to main content

Webhooks

Apps can expose webhook endpoints that receive requests from external services (Gmail push, Slack commands, Stripe events, GitHub, Shopify, …) without a logged-in Informer user. Each webhook URL embeds a signed token query parameter that the handler verifies — the endpoint is unguessable and tamper-proof, not anonymous. Webhook handlers run as the app owner.

For authenticated, app-internal routes, use Server Routes instead. To trigger an agent from a webhook, call emit().

How it works

  1. Create a webhooks/ directory in your project root
  2. Add .js handler files using the same file-convention routing as server/
  3. Run npm run deploy — Informer scans, bundles, and registers webhook routes as public
  4. External services POST to /api/apps/{naturalId}/_hook/{path}

File-convention routing

FileRoutePublic URL
webhooks/gmail/push.js/gmail/push/api/apps/{id}/_hook/gmail/push
webhooks/stripe/payment.js/stripe/payment/api/apps/{id}/_hook/stripe/payment
webhooks/slack/commands.js/slack/commands/api/apps/{id}/_hook/slack/commands

Handler structure

Webhook handlers use the same export pattern as server routes. The context adds crypto for HMAC verification and request.rawBody for the original request bytes:

// webhooks/gmail/push.js
export const config = { timeout: 15000 };

export async function POST({ query, request, fetch, emit, log, crypto, env }) {
const payload = request.body;

await query('INSERT INTO webhook_log (source, payload) VALUES ($1, $2)',
['gmail', JSON.stringify(payload)]);

// Trigger an agent via event
await emit('gmail_notification', { historyId: payload.historyId });

return { status: 200, body: { ok: true } };
}

Key differences from server routes

Server Routes (server/)Webhook Routes (webhooks/)
URL prefix/api/apps/{id}/view/api/_server//api/apps/{id}/_hook/
AuthenticationSession or app-token (viewer's identity)Signed ?token=… query parameter (tenant-scoped, no user session)
request.userCurrent viewer's identityThe app owner's identity (handlers run as the owner)
request.rolesViewer's assigned roles[]
fetch() runs asThe viewerThe app owner (team admin)
notify() / email()AvailableAvailable (attributed to the app owner)
Use caseApp-internal CRUD, user-specific logicExternal service callbacks

The signed token is issued at deploy time and retrievable via GET /api/apps/{id}/webhooks. External callers must include it in the URL — the handler runs only if the token verifies against the app's webhook secret.

Sandbox capabilities

Webhook handlers receive the same handler bag as server routesquery, fetch, context (typed dependencies), respond, emit, notify, email, crypto, markdown, log, env, plus the base64* globals. (Earlier releases withheld notify() / email() from webhooks; since the sandbox unification they're available too, attributed to the app owner.)

Webhook routes additionally provide request.rawBody — the original request body as a string, preserving the exact bytes the caller sent. Use this for HMAC verification (not request.body, which is parsed JSON).

Sanitized request inputs

Webhook handlers receive request.headers and request.query with Informer's auth-bearing values removed. The deny-lists are narrower than for server routes because webhook contracts genuinely depend on incoming headers (signatures, shared-secret bearer tokens):

request.headers — stripped: cookie, proxy-authorization. Preserved: authorization (for Bearer <secret> patterns), all signature headers (x-hub-signature-256, stripe-signature, x-shopify-hmac-sha256, …), and service-specific event headers (x-github-event, user-agent, …).

request.query — stripped: token (the URL-gate signed token; verification already happened) and app_token. Your own custom query params pass through untouched.

Verifying webhooks

The signed token proves the URL came from Informer, but not that the right service used it. For end-to-end authenticity, also verify the upstream's signature on the request body — most providers (GitHub, Stripe, Shopify, Slack) sign with HMAC-SHA256. Use crypto.verifyHmac(...) — it computes the HMAC and compares in constant time, avoiding the timing leak of a plain ===/!== comparison.

HMAC signature verification (GitHub, Stripe, Shopify):

// webhooks/github.js
export async function POST({ crypto, request, env, query }) {
// GitHub sends `x-hub-signature-256: sha256=<hex>`
const signature = (request.headers['x-hub-signature-256'] || '').replace(/^sha256=/, '');
if (!signature) return { status: 401, body: { error: 'Missing signature' } };

// Verify over the RAW body bytes — not parsed JSON
const ok = await crypto.verifyHmac('sha256', env.GITHUB_WEBHOOK_SECRET, request.rawBody, signature);
if (!ok) {
return { status: 401, body: { error: 'Invalid signature' } };
}

const event = request.body;
await query('INSERT INTO webhook_log (source, event, payload) VALUES ($1, $2, $3)',
['github', request.headers['x-github-event'], JSON.stringify(event)]);
return { ok: true };
}

Shared secret (custom integrations):

// webhooks/my-callback.js
export async function POST({ request, crypto, env }) {
const ok = await crypto.timingSafeEqual(request.headers['x-webhook-secret'] || '', env.CALLBACK_SECRET);
if (!ok) {
return { status: 401, body: { error: 'Unauthorized' } };
}
return { received: true };
}

Storing webhook secrets

Store secrets in app.defn.env via the app update endpoint — they're then available as env.* in handlers:

await fetch(`/api/apps/${appId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
defn: { env: { GITHUB_WEBHOOK_SECRET: 'your-secret-here' } }
})
});

Tight deadlines — respond()

Many providers enforce a short ack window (Slack: 3s, Stripe retries on slow responses). Use respond() to acknowledge immediately, then finish the work in the background:

export const config = { timeout: 25000 };

export async function POST({ respond, fetch, request }) {
await respond({ status: 200, body: { received: true } }); // ack now
// ...slower processing continues here
}

Imports

Webhook files are bundled by the same esbuild plugin as server routes — relative imports only, no node_modules, no host filesystem. Shared helpers across server/, webhooks/, and tools/ work via relative paths (e.g. import { verifySig } from '../lib/sig.js'). See Server Routes → Imports.

Project structure with webhooks

my-app/
webhooks/
gmail/
push.js → POST /gmail/push
stripe/
payment.js → POST /stripe/payment
slack/
commands.js → POST /slack/commands
server/
orders/
index.js → GET,POST /orders (authenticated)
tools/
send_email.js
migrations/
001-create-tables.sql
informer.yaml
index.html
package.json