Skip to main content

Webhooks

Looking for the product view?
The Help Center explains this surface as users see it: API Routes & 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, 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() 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: 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.

List webhook routes and statsGET /api/apps/00000000-0000-4000-8000-000000000001/webhooks
recent holds the last 50 invocations; daily aggregates the last 30 days. method/path query params filter both.
Response · 200
{
"_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
}
]
}
Captured from the API examples test suite; ids and timestamps are normalized.

Response Sections:

FieldDescription
routesRegistered public routes: { method, path, config }
recentLast 50 invocations: id, method, path, startedAt, durationMs, statusCode, memoryPeakBytes, and the captured error (if any)
dailyPer-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 webhook
  • POST /api/apps/{id}/_hook/{path*} — Public POST webhook
  • PUT /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.

Invoke a webhook (public)POST /api/apps/00000000-0000-4000-8000-000000000001/_hook/order-created?token=eyJhbGciOiJIUzI1NiJ9.example-token-1.signature
No Informer session: the ?token= JWT authenticates the call and the handler runs as the app owner. GET, POST, and PUT are supported.
Request body
{
"orderId": 1043
}
Response · 200
{
"received": true,
"orderId": 1043
}
Captured from the API examples test suite; ids and timestamps are normalized.

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>" }.

Mint a webhook tokenPOST /api/apps/00000000-0000-4000-8000-000000000001/webhook-tokens
The token is a JWT signed with the app webhook secret; embed it as ?token= on the _hook URL. Revoking the row immediately invalidates it.
Request body
{
"notes": "Order Desk CI hook"
}
Response · 201
{
"_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"
}
}
}
Captured from the API examples test suite; ids and timestamps are normalized.

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.

List an App's webhook tokensGET /api/apps/00000000-0000-4000-8000-000000000001/webhook-tokens
Response · 200
{
"_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"
}
]
}
}
Captured from the API examples test suite; ids and timestamps are normalized.

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.

Revoke a webhook tokenDELETE /api/apps/00000000-0000-4000-8000-000000000001/webhook-tokens/00000000-0000-4000-8000-000000000002
Response · 200
{
"_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"
}
Captured from the API examples test suite; ids and timestamps are normalized.

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.

Rotate the webhook secretPOST /api/apps/00000000-0000-4000-8000-000000000001/_rotate-webhook-secret
Invalidates every previously-distributed token signature at once. Token rows survive and re-derive against the new secret on the next list.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/00000000-0000-4000-8000-000000000001/_rotate-webhook-secret"
}
},
"id": "00000000-0000-4000-8000-000000000001"
}
Captured from the API examples test suite; ids and timestamps are normalized.

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