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 context object as server routes, with a few differences:
| Property | Type | Description |
|---|---|---|
query | function | SQL access to app workspace |
fetch | function | Authenticated API calls (whitelist enforced) |
respond | function | Send early HTTP response (for deadline-sensitive webhooks) |
emit | function | Create app events to trigger agents |
crypto.hmac | function | crypto.hmac(algorithm, key, data, encoding?) — HMAC digest computation |
base64Decode | async function | Decode base64 to UTF-8 string (handles multi-byte characters) |
base64Encode | async function | Encode UTF-8 string to base64 |
base64UrlDecode | async function | Decode base64url to UTF-8 string |
base64UrlEncode | async function | Encode UTF-8 string to base64url |
env | object | App environment variables from app.defn.env |
request.body | any | Parsed request body |
request.rawBody | string | Raw request body string (for HMAC signature verification) |
request.headers | object | Request headers |
request.params | object | Route parameters |
request.query | object | Query string parameters |
request.user | object | The app owner's identity (not the webhook caller) |
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, you should verify the caller's identity using signature verification:
export async function POST({ request }) {
const signature = request.headers['x-hub-signature-256'];
const expected = crypto.hmac('sha256', SECRET, request.rawBody, 'hex');
if (signature !== `sha256=${expected}`) {
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