Use when configuring webhook integrations in Bknd. Covers receiving incoming webhooks via HTTP triggers, sending outgoing webhooks with FetchTask, event-triggered webhooks on data changes, signature verification, retry patterns, and async processing.
npx claudepluginhub cameronapak/bknd-expert --plugin bknd-research-skillsThis skill uses the workspace's default tool permissions.
Configure webhook integrations for receiving external events and sending notifications on data changes.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Configure webhook integrations for receiving external events and sending notifications on data changes.
bknd-custom-endpoint)Webhook configuration requires code. No UI approach available.
| Type | Description | Approach |
|---|---|---|
| Incoming | Receive webhooks from external services | HTTP Trigger + Flow |
| Outgoing | Send webhooks when events occur | EventTrigger + FetchTask |
import { App, Flow, HttpTrigger, Task } from "bknd";
import { s } from "bknd/utils";
class WebhookReceiverTask extends Task<typeof WebhookReceiverTask.schema> {
override type = "webhook-receiver";
static override schema = s.strictObject({});
override async execute(input: Request) {
const body = await input.json();
const eventType = input.headers.get("x-event-type");
console.log(`Received webhook: ${eventType}`, body);
return { received: true, event: eventType };
}
}
const receiverTask = new WebhookReceiverTask("receive", {});
const webhookFlow = new Flow("incoming-webhook", [receiverTask]);
webhookFlow.setRespondingTask(receiverTask);
webhookFlow.setTrigger(
new HttpTrigger({
path: "/webhooks/external",
method: "POST",
mode: "async", // Return 200 immediately
})
);
const app = new App({
flows: { flows: [webhookFlow] },
});
import { createHmac, timingSafeEqual } from "crypto";
class SecureWebhookTask extends Task<typeof SecureWebhookTask.schema> {
override type = "secure-webhook";
static override schema = s.strictObject({
secret: s.string(),
});
override async execute(input: Request) {
const signature = input.headers.get("x-webhook-signature");
const body = await input.text();
// Verify signature
if (!this.verifySignature(body, signature)) {
throw this.error("Invalid signature", { signature });
}
const data = JSON.parse(body);
return { verified: true, data };
}
private verifySignature(payload: string, signature: string | null): boolean {
if (!signature) return false;
const expected = createHmac("sha256", this.params.secret)
.update(payload)
.digest("hex");
const sig = Buffer.from(signature);
const exp = Buffer.from(`sha256=${expected}`);
return sig.length === exp.length && timingSafeEqual(sig, exp);
}
}
const secureTask = new SecureWebhookTask("verify", {
secret: process.env.WEBHOOK_SECRET!,
});
const secureFlow = new Flow("secure-webhook", [secureTask]);
secureFlow.setRespondingTask(secureTask);
secureFlow.setTrigger(
new HttpTrigger({
path: "/webhooks/secure",
method: "POST",
})
);
import Stripe from "stripe";
class StripeWebhookTask extends Task<typeof StripeWebhookTask.schema> {
override type = "stripe-webhook";
static override schema = s.strictObject({
webhookSecret: s.string(),
});
override async execute(input: Request) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const sig = input.headers.get("stripe-signature")!;
const body = await input.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
this.params.webhookSecret
);
} catch (err) {
throw this.error("Webhook verification failed", { err });
}
// Handle event types
switch (event.type) {
case "checkout.session.completed":
const session = event.data.object;
// Process successful payment...
break;
case "customer.subscription.deleted":
// Handle subscription cancellation...
break;
}
return { received: true, type: event.type };
}
}
class GitHubWebhookTask extends Task<typeof GitHubWebhookTask.schema> {
override type = "github-webhook";
static override schema = s.strictObject({
secret: s.string(),
});
override async execute(input: Request) {
const event = input.headers.get("x-github-event");
const delivery = input.headers.get("x-github-delivery");
const signature = input.headers.get("x-hub-signature-256");
const body = await input.text();
// Verify GitHub signature
const expected = createHmac("sha256", this.params.secret)
.update(body)
.digest("hex");
if (signature !== `sha256=${expected}`) {
throw this.error("Invalid GitHub signature");
}
const payload = JSON.parse(body);
switch (event) {
case "push":
console.log(`Push to ${payload.ref} by ${payload.pusher.name}`);
break;
case "pull_request":
console.log(`PR ${payload.action}: ${payload.pull_request.title}`);
break;
case "issues":
console.log(`Issue ${payload.action}: ${payload.issue.title}`);
break;
}
return { event, delivery };
}
}
For simpler cases, use plugin routes:
import { createPlugin } from "bknd";
import { Hono } from "hono";
const webhooksPlugin = createPlugin({
name: "webhooks",
onServerInit: (server) => {
const webhooks = new Hono();
// Stripe
webhooks.post("/stripe", async (c) => {
const sig = c.req.header("stripe-signature");
const body = await c.req.text();
// Verify and process...
return c.json({ received: true });
});
// GitHub
webhooks.post("/github", async (c) => {
const event = c.req.header("x-github-event");
const body = await c.req.json();
// Process...
return c.json({ received: true });
});
// Generic
webhooks.post("/:source", async (c) => {
const source = c.req.param("source");
const body = await c.req.json();
console.log(`Webhook from ${source}:`, body);
return c.json({ received: true });
});
server.route("/webhooks", webhooks);
},
});
Use Flows with EventTrigger to send webhooks when data changes.
import { App, Flow, FetchTask, EventTrigger } from "bknd";
// Task to send webhook
const sendWebhook = new FetchTask("send-webhook", {
url: "https://example.com/webhook",
method: "POST",
headers: [
{ key: "Content-Type", value: "application/json" },
{ key: "X-Webhook-Source", value: "my-app" },
],
body: "{{JSON.stringify(input)}}", // Forward event data
});
const webhookFlow = new Flow("outgoing-webhook", [sendWebhook]);
// Trigger on data event
webhookFlow.setTrigger(
new EventTrigger({
event: "mutator-insert-after", // After record created
mode: "async",
})
);
const app = new App({
flows: { flows: [webhookFlow] },
});
import { App, Flow, FetchTask, Task, EventTrigger } from "bknd";
import { s } from "bknd/utils";
// Filter task to check entity
class EntityFilterTask extends Task<typeof EntityFilterTask.schema> {
override type = "entity-filter";
static override schema = s.strictObject({
targetEntity: s.string(),
});
override async execute(input: any) {
if (input.entity?.name !== this.params.targetEntity) {
throw this.error("Skip - wrong entity");
}
return input;
}
}
const filterTask = new EntityFilterTask("filter", {
targetEntity: "orders",
});
const sendWebhook = new FetchTask("send", {
url: "https://api.example.com/orders/webhook",
method: "POST",
headers: [{ key: "Content-Type", value: "application/json" }],
body: "{{JSON.stringify({ event: 'order.created', data: input.changed })}}",
});
const flow = new Flow("order-webhook", [filterTask, sendWebhook]);
flow.task(filterTask).asInputFor(sendWebhook);
flow.setTrigger(
new EventTrigger({
event: "mutator-insert-after",
mode: "async",
})
);
import { Flow, FetchTask, EventTrigger, Condition } from "bknd";
// Send to multiple endpoints
const sendSlack = new FetchTask("slack", {
url: "https://hooks.slack.com/services/XXX/YYY/ZZZ",
method: "POST",
headers: [{ key: "Content-Type", value: "application/json" }],
body: '{"text": "New order: {{input.changed.id}}"}',
});
const sendDiscord = new FetchTask("discord", {
url: "https://discord.com/api/webhooks/XXX/YYY",
method: "POST",
headers: [{ key: "Content-Type", value: "application/json" }],
body: '{"content": "New order: {{input.changed.id}}"}',
});
const sendCustom = new FetchTask("custom", {
url: process.env.CUSTOM_WEBHOOK_URL!,
method: "POST",
headers: [{ key: "Content-Type", value: "application/json" }],
body: "{{JSON.stringify(input)}}",
});
const flow = new Flow("multi-webhook", [sendSlack, sendDiscord, sendCustom]);
// All tasks run in parallel (no connections)
flow.setTrigger(
new EventTrigger({
event: "mutator-insert-after",
mode: "async",
})
);
import { Flow, FetchTask, Task, Condition, EventTrigger } from "bknd";
import { s } from "bknd/utils";
class RetryTask extends Task<typeof RetryTask.schema> {
override type = "retry-webhook";
static override schema = s.strictObject({
url: s.string(),
maxRetries: s.number({ default: 3 }),
delayMs: s.number({ default: 1000 }),
});
override async execute(input: any) {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.params.maxRetries; attempt++) {
try {
const response = await fetch(this.params.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (response.ok) {
return { success: true, attempt };
}
lastError = new Error(`HTTP ${response.status}`);
} catch (err) {
lastError = err as Error;
}
// Wait before retry (exponential backoff)
if (attempt < this.params.maxRetries) {
await new Promise((r) => setTimeout(r, this.params.delayMs * attempt));
}
}
throw this.error("All retries failed", { lastError: lastError?.message });
}
}
Bknd emits these events that can trigger webhooks:
| Event Slug | Description | Payload |
|---|---|---|
mutator-insert-before | Before record created | { entity, data } |
mutator-insert-after | After record created | { entity, data, changed } |
mutator-update-before | Before record updated | { entity, entityId, data } |
mutator-update-after | After record updated | { entity, entityId, data, changed } |
mutator-delete-before | Before record deleted | { entity, entityId } |
mutator-delete-after | After record deleted | { entity, entityId, data } |
| Event Slug | Description | Payload |
|---|---|---|
file-uploaded | File uploaded | { name, meta, etag, file, state } |
file-deleted | File deleted | { name } |
file-access | File accessed | { name } |
// Event payload structure for mutator-insert-after
interface InsertAfterPayload {
entity: {
name: string; // Entity name, e.g., "orders"
fields: Field[]; // Entity fields
};
data: Record<string, any>; // Original input data
changed: Record<string, any>; // Resulting record with ID
}
class ProcessEventTask extends Task {
override async execute(input: InsertAfterPayload) {
const entityName = input.entity.name;
const recordId = input.changed.id;
const recordData = input.changed;
// Send webhook with structured data
await fetch("https://api.example.com/webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: `${entityName}.created`,
timestamp: new Date().toISOString(),
data: recordData,
}),
});
return { sent: true };
}
}
import { App, em, entity, text, number, Flow, FetchTask, Task, EventTrigger, Condition } from "bknd";
import { s } from "bknd/utils";
// Schema
const schema = em({
orders: entity({
customer_email: text().required(),
total: number().required(),
status: text().default("pending"),
}),
});
// Filter for orders only
class OrderFilterTask extends Task<typeof OrderFilterTask.schema> {
override type = "order-filter";
static override schema = s.strictObject({});
override async execute(input: any) {
if (input.entity?.name !== "orders") {
throw this.error("Not an order");
}
return input.changed; // Pass order data
}
}
// Format webhook payload
class FormatWebhookTask extends Task<typeof FormatWebhookTask.schema> {
override type = "format-webhook";
static override schema = s.strictObject({});
override async execute(order: any) {
return {
event: "order.created",
timestamp: new Date().toISOString(),
order: {
id: order.id,
email: order.customer_email,
total: order.total,
status: order.status,
},
};
}
}
const filterTask = new OrderFilterTask("filter", {});
const formatTask = new FormatWebhookTask("format", {});
// Send to multiple destinations
const sendSlack = new FetchTask("slack", {
url: process.env.SLACK_WEBHOOK_URL!,
method: "POST",
headers: [{ key: "Content-Type", value: "application/json" }],
body: '{"text": "New order #{{input.order.id}} - ${{input.order.total}}"}',
});
const sendExternal = new FetchTask("external", {
url: process.env.EXTERNAL_WEBHOOK_URL!,
method: "POST",
headers: [
{ key: "Content-Type", value: "application/json" },
{ key: "X-API-Key", value: process.env.EXTERNAL_API_KEY! },
],
body: "{{JSON.stringify(input)}}",
});
// Build flow
const orderWebhookFlow = new Flow("order-notifications", [
filterTask,
formatTask,
sendSlack,
sendExternal,
]);
// Connect: filter -> format -> [slack, external] (parallel)
orderWebhookFlow.task(filterTask).asInputFor(formatTask);
orderWebhookFlow.task(formatTask).asInputFor(sendSlack);
orderWebhookFlow.task(formatTask).asInputFor(sendExternal);
// Trigger on new orders
orderWebhookFlow.setTrigger(
new EventTrigger({
event: "mutator-insert-after",
mode: "async",
})
);
const app = new App({
data: { schema },
flows: { flows: [orderWebhookFlow] },
});
# Basic test
curl -X POST http://localhost:7654/webhooks/external \
-H "Content-Type: application/json" \
-H "X-Event-Type: test" \
-d '{"test": true}'
# With signature (HMAC-SHA256)
PAYLOAD='{"test":true}'
SECRET="your-secret"
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
curl -X POST http://localhost:7654/webhooks/secure \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
-d "$PAYLOAD"
Use webhook.site or similar:
// Temporarily point to test URL
const sendWebhook = new FetchTask("send", {
url: "https://webhook.site/your-unique-id",
method: "POST",
body: "{{JSON.stringify(input)}}",
});
Then create a record:
curl -X POST http://localhost:7654/api/data/orders \
-H "Content-Type: application/json" \
-d '{"customer_email": "test@example.com", "total": 99.99}'
Problem: Incoming webhook returns 200 but doesn't process
Fix: Check mode - async returns immediately:
// Async mode processes in background
new HttpTrigger({ mode: "async" });
// For debugging, use sync
new HttpTrigger({ mode: "sync" });
Problem: Valid webhooks rejected
Fix: Ensure you're reading raw body before parsing:
// WRONG - body already parsed
const body = await input.json();
const sig = verify(JSON.stringify(body), signature);
// CORRECT - read raw text first
const bodyText = await input.text();
const verified = verify(bodyText, signature);
const body = JSON.parse(bodyText);
Problem: EventTrigger flow doesn't run
Fix: Check event name matches exactly:
// Available events (use exact slugs)
"mutator-insert-after" // Not "data:entity:created"
"mutator-update-after" // Not "data:entity:updated"
"mutator-delete-after" // Not "data:entity:deleted"
Problem: Webhook fires for every entity, not just target
Fix: Add entity filter task:
class EntityFilter extends Task {
async execute(input) {
if (input.entity?.name !== "orders") {
throw this.error("Skip"); // Stops flow
}
return input;
}
}
Problem: {{input}} appears literally in body
Fix: Use proper template syntax:
// WRONG
body: "{ data: {{input}} }"
// CORRECT
body: "{{JSON.stringify({ data: input })}}"
DO:
mode: "async" for incoming webhooks (return 200 fast)DON'T: