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, extractText, 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: app:write (Designer for Magic Reports, Admin
for full Apps)
Query Parameters: method and path (together) filter the recent
and daily sections to one route.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/webhooks"
}
},
"routes": [
{
"method": "POST",
"path": "/order-created",
"config": {
"public": true
}
}
],
"recent": [
{
"id": "00000000-0000-4000-8000-000000000002",
"method": "POST",
"path": "/order-created",
"startedAt": "2026-01-15T16:20:00.000Z",
"durationMs": 42,
"statusCode": 200,
"memoryPeakBytes": "8388608",
"error": null
},
{
"id": "00000000-0000-4000-8000-000000000003",
"method": "POST",
"path": "/order-created",
"startedAt": "2026-01-15T16:20:00.000Z",
"durationMs": 42,
"statusCode": 200,
"memoryPeakBytes": "8388608",
"error": null
}
],
"daily": [
{
"day": "2026-01-15T16:20:00.000Z",
"count": 2,
"avgDurationMs": 42,
"errorCount": 0
}
]
}
Response Sections:
| Field | Description |
|---|---|
routes | Registered public routes: { method, path, config } |
recent | Last 50 invocations: id, method, path, startedAt, durationMs, statusCode, memoryPeakBytes, and the captured error (if any) |
daily | Per-day aggregates for the last 30 days: count, avgDurationMs, errorCount |
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 Informer session is required; the ?token= JWT authenticates the call
and the {path*} segment is matched against registered webhook routes.
{
"orderId": 1043
}
{
"received": true,
"orderId": 1043
}
Webhook Tokens
Webhook invocation URLs are authenticated with a ?token=<jwt> query parameter. A webhook token is a JWT signed with the app's webhook secret, so its validity is scoped to a single app. Tokens are revocable: deleting a token immediately rejects any future request that presents it with 401, without rotating the app's secret or invalidating other tokens.
These management endpoints require app:write (Designer for Magic
Reports, Admin for full Apps).
POST /api/apps/{id}/webhook-tokens
Mint a new revocable token. Optional body: { "notes": "<label>" }.
{
"notes": "Order Desk CI hook"
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/tokens/00000000-0000-4000-8000-000000000002"
},
"inf:token-type": {
"href": "https://informer.example.com/api/token-types/webhook"
}
},
"resource": "Order Desk",
"permissions": {
"edit": true,
"delete": true
},
"id": "00000000-0000-4000-8000-000000000002",
"type": "webhook",
"readOnly": false,
"notes": "Order Desk CI hook",
"restrict": null,
"host": null,
"cidr": null,
"data": null,
"requests": 0,
"blocked": 0,
"lastAccessedAt": null,
"expiresAt": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"tenant": "acme",
"username": "admin",
"reportId": null,
"queryId": null,
"visualId": null,
"datasourceId": null,
"datasetId": null,
"appId": "00000000-0000-4000-8000-000000000001",
"assistantId": null,
"oauthClientId": null,
"user": {
"permissions": {
"changeDomain": true,
"changeProfile": true,
"changePassword": false,
"delete": true,
"edit": true,
"reassign": true,
"superuser": true,
"enable": true,
"changeTheme": true,
"lock": true,
"setGlobalPermissions": true,
"manageLogin": false
},
"displayName": "acme Administrator",
"email": null
},
"assistant": null,
"oauthClient": null,
"key": "eyJhbGciOiJIUzI1NiJ9.example-token-1.signature",
"_embedded": {
"inf:app": {
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001"
},
"inf:owner": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/owner"
},
"inf:draft": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/draft"
},
"inf:app-shares": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/shares"
},
"inf:app-tags": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/tags"
},
"inf:favorite": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/favorite"
},
"inf:app-tag": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/tags/{tagId}",
"templated": true
},
"inf:export-bundle": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_export"
},
"inf:cloud-publish": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_cloud-publish"
},
"inf:copy": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_copy"
},
"inf:print": [
{
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_print"
},
{
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/_print",
"title": "Print to PDF"
}
],
"inf:migrate": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_migrate"
},
"inf:deploy": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_deploy"
},
"inf:app-tokens": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/tokens"
},
"inf:app-roles": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/roles"
},
"inf:app-dependencies": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/dependencies"
},
"inf:app-dependency": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/dependencies/{name}",
"templated": true
},
"inf:app-environment": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/environment"
},
"inf:app-environment-var": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/environment/{key}",
"templated": true
},
"inf:app-suggest-icon": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/_suggest-icon"
},
"inf:app-modernize-manifest": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/_modernize-manifest"
},
"inf:app-draft-diff": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/draft/_diff"
},
"edit": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/_edit"
},
"inf:app-share": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/shares/{principalId}",
"templated": true
},
"inf:entrypoint": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/view/",
"title": "index.html"
},
"inf:library": {
"href": "https://informer.example.com/api/libraries/00000000-0000-4000-8000-000000000003",
"title": "App Files"
},
"inf:library-contents": {
"href": "https://informer.example.com/api/libraries/00000000-0000-4000-8000-000000000003/contents/index.html",
"title": "Raw Contents"
},
"share": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/view/?token=%7Btoken%7D",
"title": "Share Page"
}
},
"naturalId": "admin:order-desk",
"isManaged": true,
"permissions": {
"assignTags": true,
"bind": true,
"changeOwner": true,
"copy": true,
"delete": true,
"edit": false,
"manage": true,
"purge": true,
"restore": true,
"run": true,
"share": true,
"switchRoles": true,
"write": true
},
"id": "00000000-0000-4000-8000-000000000001",
"type": "app",
"name": "Order Desk",
"tenant": "acme",
"ownerId": "admin",
"slug": "order-desk",
"description": "Track and fulfill open orders",
"defn": {
"entryPoint": "index.html"
},
"settings": {
"sandbox": true,
"passDatasets": true
},
"origin": "deployed",
"source": null,
"sourceId": null,
"shared": false,
"defnUpdatedAt": null,
"libraryId": "00000000-0000-4000-8000-000000000003",
"webhookSecret": "encrypted:iv:0000000000000000",
"dbRolePassword": null,
"deletedAt": null,
"editingId": null,
"deployedAt": "2026-01-15T16:20:00.000Z",
"editingSession": null,
"editingHeartbeatAt": null,
"editingLockedBy": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"folderId": null,
"contentModifiedAt": "2026-01-15T16:20:00.000Z"
}
}
}
GET /api/apps/{id}/webhook-tokens
List the app's revocable webhook tokens (rows are embedded under
_embedded["inf:token"], newest first). Each entry includes the signed
key, re-derived from the current webhook secret on every read.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/webhook-tokens"
}
},
"start": 0,
"count": 1,
"total": 1,
"_embedded": {
"inf:token": [
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/webhook-tokens/00000000-0000-4000-8000-000000000002"
},
"inf:token-type": {
"href": "https://informer.example.com/api/token-types/webhook"
}
},
"permissions": {
"edit": true,
"delete": true
},
"id": "00000000-0000-4000-8000-000000000002",
"type": "webhook",
"readOnly": false,
"notes": "Order Desk CI hook",
"restrict": null,
"host": null,
"cidr": null,
"data": null,
"requests": 0,
"blocked": 0,
"lastAccessedAt": null,
"expiresAt": null,
"createdAt": "2026-01-15T16:20:00.000Z",
"updatedAt": "2026-01-15T16:20:00.000Z",
"tenant": "acme",
"username": "admin",
"reportId": null,
"queryId": null,
"visualId": null,
"datasourceId": null,
"datasetId": null,
"appId": "00000000-0000-4000-8000-000000000001",
"assistantId": null,
"oauthClientId": null,
"key": "eyJhbGciOiJIUzI1NiJ9.example-token-1.signature"
}
]
}
}
DELETE /api/apps/{id}/webhook-tokens/{tokenId}
Revoke a token by deleting it. Subsequent webhook requests presenting that token are rejected with 401. Returns 404 if the token does not exist for this app.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/webhook-tokens/00000000-0000-4000-8000-000000000002"
}
},
"id": "00000000-0000-4000-8000-000000000002"
}
POST /api/apps/{id}/_rotate-webhook-secret
Rotate the app's webhook secret. Every previously-distributed token signature stops verifying at once; existing token rows survive and re-derive against the new secret on the next list.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_rotate-webhook-secret"
}
},
"id": "00000000-0000-4000-8000-000000000001"
}
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