Skip to main content

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/:

DirectoryAuth RequiredURL 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

FileRouteURL
webhooks/stripe.jsPOST /stripe/api/apps/{id}/_hook/stripe
webhooks/slack/commands.jsPOST /slack/commands/api/apps/{id}/_hook/slack/commands
webhooks/github/[action].jsPOST /github/:action/api/apps/{id}/_hook/github/push

Webhook Handler Context

Webhook handlers receive the same single context bag as server routesquery, 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() and email() 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:

PropertyWebhook value
request.rawBodyRaw request body string — present on webhooks for HMAC signature verification
request.userThe app owner's identity (handlers run as the owner), not the caller
request.rolesAlways [] (a public caller has no app roles)
request.body / headers / params / querySame as server routes

Authentication

Webhook requests run with the app owner's credentials. This means:

  • fetch() calls use the owner's API permissions
  • query() 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 webhook
  • POST /api/apps/{id}/_hook/{path*} — Public POST webhook
  • PUT /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