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 context object as server routes, with a few differences:

PropertyTypeDescription
queryfunctionSQL access to app workspace
fetchfunctionAuthenticated API calls (whitelist enforced)
respondfunctionSend early HTTP response (for deadline-sensitive webhooks)
emitfunctionCreate app events to trigger agents
crypto.hmacfunctioncrypto.hmac(algorithm, key, data, encoding?) — HMAC digest computation
base64Decodeasync functionDecode base64 to UTF-8 string (handles multi-byte characters)
base64Encodeasync functionEncode UTF-8 string to base64
base64UrlDecodeasync functionDecode base64url to UTF-8 string
base64UrlEncodeasync functionEncode UTF-8 string to base64url
envobjectApp environment variables from app.defn.env
request.bodyanyParsed request body
request.rawBodystringRaw request body string (for HMAC signature verification)
request.headersobjectRequest headers
request.paramsobjectRoute parameters
request.queryobjectQuery string parameters
request.userobjectThe 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 permissions
  • query() 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 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