Webhooks
Use @beignet/core/webhooks when an app needs to receive external HTTP events
without scattering raw-body parsing, signature checks, event validation, and
handler routing through route files.
Inbound webhooks are not ordinary JSON API routes. Signature verification usually depends on the exact raw request body, so the adapter must read the body before it is parsed.
Choose a route helper
Use createPaymentWebhookRoute(...) from @beignet/next for app billing
flows backed by ctx.ports.payments. This is the route generated by
beignet make payments; it verifies through
ctx.ports.payments.verifyWebhook(...) and passes your handler a normalized
PaymentWebhookEvent from @beignet/core/payments.
Use createWebhookRoute(...) for generic inbound webhooks backed by a typed
defineWebhook(...) event catalog. Provider verifier packages such as
@beignet/provider-webhooks-github and @beignet/provider-webhooks-stripe
adapt vendor signature rules to this generic webhook primitive. Use the Stripe
webhook verifier for non-payment Stripe events, or for apps that deliberately
model Stripe through a generic webhook catalog instead of the payments port.
Define a webhook
Define webhook event catalogs near the feature that owns the workflow:
// features/integrations/webhooks.ts
import { defineWebhook } from "@beignet/core/webhooks";
import { z } from "zod";
type StripeWebhookPayload = {
id: string;
object: string;
};
const stripeWebhookPayloadSchema = z.custom<StripeWebhookPayload>(
(value): value is StripeWebhookPayload =>
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { object?: unknown }).object === "string",
);
export const stripeWebhook = defineWebhook("integrations.stripe", {
provider: "stripe",
events: {
"customer.created": stripeWebhookPayloadSchema,
"charge.dispute.created": stripeWebhookPayloadSchema,
},
});The event catalog validates the payload passed to your handler. The verifier owns authenticity and converts provider-specific requests into Beignet webhook events.
Install vendor verifier packages when a provider has signature details that should not be reimplemented in app code:
bun add @beignet/provider-webhooks-stripe stripe @beignet/core
bun add @beignet/provider-webhooks-github @beignet/coreExpose a Next.js route
Use createWebhookRoute(...) from @beignet/next for raw-body route handling:
// app/api/webhooks/stripe/route.ts
import { createWebhookRoute } from "@beignet/next";
import { createStripeWebhookVerifier } from "@beignet/provider-webhooks-stripe";
import { stripeWebhook } from "@/features/integrations/webhooks";
import { handleStripeWebhookUseCase } from "@/features/integrations/use-cases";
import { env } from "@/lib/env";
import { server } from "@/server";
export const runtime = "nodejs";
const stripeWebhookVerifier = createStripeWebhookVerifier({
secret: () => env.STRIPE_WEBHOOK_SECRET,
});
export const { POST } = createWebhookRoute({
server,
webhook: stripeWebhook,
verify: ({ input }) => stripeWebhookVerifier.verify(input),
handle: async ({ ctx, event }) => {
await handleStripeWebhookUseCase.run({ ctx, input: event });
return { status: 200, body: { received: true } };
},
});By default, createWebhookRoute(...) acknowledges verified event types that are
not in the catalog and passes them to the handler as untyped webhook events.
Set allowUnknownEvents: false only when an unregistered event type should be
treated as endpoint misconfiguration and returned as a 400.
Use the context-aware verify option when verification depends on app ports.
Otherwise, create a reusable verifier in the route or server layer and pass it
through verify.
GitHub webhooks
GitHub event names and delivery IDs live in headers, so use
@beignet/provider-webhooks-github instead of the generic HMAC verifier:
// features/integrations/webhooks.ts
import { defineWebhook } from "@beignet/core/webhooks";
import { z } from "zod";
export const githubWebhook = defineWebhook("integrations.github", {
provider: "github",
events: {
issues: z.object({
action: z.string(),
issue: z.object({ number: z.number() }),
repository: z.object({ full_name: z.string() }),
}),
},
});// app/api/webhooks/github/route.ts
import { createWebhookRoute } from "@beignet/next";
import { createGitHubWebhookVerifier } from "@beignet/provider-webhooks-github";
import { githubWebhook } from "@/features/integrations/webhooks";
import { handleGitHubWebhookUseCase } from "@/features/integrations/use-cases";
import { env } from "@/lib/env";
import { server } from "@/server";
export const runtime = "nodejs";
const githubWebhookVerifier = createGitHubWebhookVerifier({
secret: () => env.GITHUB_WEBHOOK_SECRET,
});
export const { POST } = createWebhookRoute({
server,
webhook: githubWebhook,
verify: ({ input }) => githubWebhookVerifier.verify(input),
handle: async ({ ctx, event }) => {
await handleGitHubWebhookUseCase.run({ ctx, input: event });
return { status: 200, body: { received: true } };
},
});The verifier reads X-Hub-Signature-256, X-GitHub-Delivery, and
X-GitHub-Event, then verifies the raw body before parsing JSON.
Generic HMAC verification
For simple JSON webhooks that use an HMAC signature over the raw body, use the generic verifier:
import {
createHmacWebhookVerifier,
defineWebhook,
} from "@beignet/core/webhooks";
import { z } from "zod";
export const issueWebhook = defineWebhook("issues.provider", {
provider: "provider",
events: {
"issue.created": z.object({
id: z.string(),
type: z.literal("issue.created"),
issueId: z.string(),
title: z.string(),
}),
},
verifier: createHmacWebhookVerifier({
secret: process.env.PROVIDER_WEBHOOK_SECRET ?? "",
signatureHeader: "x-provider-signature",
signaturePrefix: "sha256=",
}),
});createHmacWebhookVerifier(...) expects a JSON payload with string id and
type fields by default. Use eventIdPath and eventTypePath when a provider
uses different names.
Handling safely
Webhook providers retry. Handlers should be idempotent:
await runIdempotently({
idempotency: ctx.ports.idempotency,
namespace: "billing.handlePaymentWebhook",
key: event.id,
scope: event.provider,
fingerprint: event.type,
run: () => applyBillingEvent(ctx, event),
});For workflows that must survive process restarts, record a domain event or job through the outbox after verification and acknowledge the provider quickly. Keep long-running work out of the route handler.
The route helpers use HTTP status codes as the provider retry contract:
- Invalid signatures, malformed payloads, and strict unknown-event failures return 400.
- Verified duplicate events should be acknowledged after idempotency confirms the original result.
- Handler failures return 500 so at-least-once webhook providers can retry.
- Verified event types your app does not handle should usually be acknowledged and ignored unless you intentionally configured a strict catalog.
Testing
Use the memory verifier for route and use-case tests:
import {
createMemoryWebhookVerifier,
defineWebhook,
verifyWebhook,
} from "@beignet/core/webhooks";
const verifier = createMemoryWebhookVerifier();
verifier.queue({
id: "evt_1",
type: "issue.created",
payload: { issueId: "issue_1" },
});
const webhook = defineWebhook("issues.provider", { verifier });
const event = await verifyWebhook(webhook, {
rawBody: "{}",
headers: {},
});Related pages
- Ports and adapters for app-facing dependency boundaries.
- Payments for Stripe-backed billing webhooks.
- Idempotency for retry-safe webhook handlers.
- Outbox for durable post-verification work.