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
- Create a
webhooks/directory in your project root - Add
.jshandler files using the same file-convention routing asserver/ - Run
npm run deploy— Informer scans, bundles, and registers webhook routes as public - External services POST to
/api/apps/{naturalId}/_hook/{path}
File-convention routing
| File | Route | Public 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/ |
| Authentication | Session or app-token (viewer's identity) | Signed ?token=… query parameter (tenant-scoped, no user session) |
request.user | Current viewer's identity | The app owner's identity (handlers run as the owner) |
request.roles | Viewer's assigned roles | [] |
fetch() runs as | The viewer | The app owner (team admin) |
notify() / email() | Available | Available (attributed to the app owner) |
| Use case | App-internal CRUD, user-specific logic | External 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 routes — query, 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