Webhooks
Apps can expose public webhook endpoints — server-side handlers that receive requests without requiring Informer authentication. Webhooks are ideal for receiving callbacks from external services (Slack, Stripe, GitHub, etc.).
How Webhooks Work
Webhooks use the same file-convention routing as server routes, but files go in a webhooks/ directory instead of server/:
| Directory | Auth Required | URL Pattern |
|---|---|---|
server/ | Yes (session/token) | /api/apps/{id}/view/api/_server/... |
webhooks/ | No (public) | /api/apps/{id}/_hook/... |
During deploy, both directories are scanned and bundled. Webhook routes are registered with a public: true flag.
Defining Webhooks
Create handler files in webhooks/ using the same conventions as server routes:
// webhooks/stripe.js
export const config = { timeout: 60000 };
export async function POST({ query, fetch, request }) {
const event = request.body;
// Verify signature using rawBody
// (rawBody is available on webhook requests for HMAC verification)
await query(
'INSERT INTO webhook_events (type, payload) VALUES ($1, $2)',
[event.type, JSON.stringify(event)]
);
return { status: 200, body: { received: true } };
}
// webhooks/slack/commands.js
export const config = { timeout: 25000 };
export async function POST({ query, fetch, respond, request }) {
const { text, response_url } = request.body;
// Ack Slack immediately (3-second deadline)
await respond({ response_type: 'ephemeral', text: 'Processing...' });
// Do the real work in background
const result = await fetch('datasets/admin:sales/_search', {
method: 'POST',
body: { query: { match_all: {} }, size: 10 }
});
// Post back to Slack
await fetch('integrations/slack/request', {
method: 'POST',
body: {
url: response_url,
method: 'POST',
data: { text: `Found ${result.body.hits.total} records` }
}
});
}
File Routing
| File | Route | URL |
|---|---|---|
webhooks/stripe.js | POST /stripe | /api/apps/{id}/_hook/stripe |
webhooks/slack/commands.js | POST /slack/commands | /api/apps/{id}/_hook/slack/commands |
webhooks/github/[action].js | POST /github/:action | /api/apps/{id}/_hook/github/push |
Webhook Handler Context
Webhook handlers receive the same single context bag as server routes — query, fetch, context (typed dependencies), respond, emit, notify, email, crypto, markdown, log, and env, plus the base64 globals (base64Encode/base64Decode/base64UrlEncode/base64UrlDecode, btoa/atob). See Server Routes for the full reference on each callback, and The crypto Object for the crypto surface.
notify()andemail()are available on webhooks — handlers run as the app owner, so message attribution is well-defined. This is at parity with server routes.
The differences are all in how the request is identified — webhooks are public, so there's no authenticated caller:
| Property | Webhook value |
|---|---|
request.rawBody | Raw request body string — present on webhooks for HMAC signature verification |
request.user | The app owner's identity (handlers run as the owner), not the caller |
request.roles | Always [] (a public caller has no app roles) |
request.body / headers / params / query | Same as server routes |
Authentication
Webhook requests run with the app owner's credentials. This means:
fetch()calls use the owner's API permissionsquery()accesses the owner's workspace schema- The owner credentials are cached for 5 minutes
Since webhooks are public, verify the caller's identity using signature verification. Use crypto.verifyHmac(...) — it computes the HMAC and compares in constant time, avoiding the timing leak of a plain ===/!== comparison:
export async function POST({ request, crypto, env }) {
// GitHub sends `x-hub-signature-256: sha256=<hex>`
const signature = (request.headers['x-hub-signature-256'] || '').replace(/^sha256=/, '');
const ok = await crypto.verifyHmac('sha256', env.GITHUB_WEBHOOK_SECRET, request.rawBody, signature);
if (!ok) {
return { status: 401, body: { error: 'Invalid signature' } };
}
// Process verified webhook...
}
Webhook API Endpoints
GET /api/apps/{id}/webhooks
List registered webhook routes and their invocation statistics.
Authentication: Required
Permissions Required: Member+ role (write permission)
Response:
{
"routes": [
{
"method": "post",
"path": "/stripe",
"handlerPath": "webhooks/stripe.js",
"config": { "public": true }
}
],
"stats": [
{
"method": "POST",
"path": "/stripe",
"date": "2025-03-01",
"count": 42,
"avgDuration": 156,
"errorCount": 1
}
]
}
Webhook Invocation Endpoints
These are the public endpoints that external services call:
GET /api/apps/{id}/_hook/{path*}— Public GET webhookPOST /api/apps/{id}/_hook/{path*}— Public POST webhookPUT /api/apps/{id}/_hook/{path*}— Public PUT webhook
No authentication required. The {path*} segment is matched against registered webhook routes.
Emitting Events from Webhooks
Webhooks can trigger agents by emitting events:
// webhooks/github.js
export async function POST({ emit, request }) {
const event = request.headers['x-github-event'];
const payload = request.body;
// Emit an event that triggers an agent
await emit('github_push', {
repo: payload.repository.full_name,
branch: payload.ref,
commits: payload.commits.length
});
return { status: 200, body: { received: true } };
}
Invocation Tracking
Each webhook invocation is recorded with:
- HTTP method and path
- Response status code
- Execution duration (ms)
- Peak memory usage
- Error details (if any)
Daily aggregates are available via GET /api/apps/{id}/webhooks for the last 30 days.
Project Structure
my-app/
server/
orders/index.js ← Auth-required routes
webhooks/
stripe.js ← Public webhook (POST /stripe)
slack/
commands.js ← Public webhook (POST /slack/commands)
events.js ← Public webhook (POST /slack/events)
migrations/
001-create-tables.sql
informer.yaml
index.html
package.json