Webhooks

Send and receive real-time events between Nebutra and your external systems.

Overview

Nebutra's webhook system (@nebutra/webhooks) allows you to:

  1. Receive events from Nebutra (inbound webhooks — e.g. Stripe, Clerk, GitHub)
  2. Send events to your customers' systems (outbound webhooks — when things happen in your product)

Outbound webhooks

Register an endpoint

Customers register webhook endpoints from Settings → Webhooks → Add Endpoint.

Programmatically:

const endpoint = await nebutra.webhooks.createEndpoint({
  url: "https://your-app.com/webhooks/nebutra",
  events: ["project.created", "invoice.paid", "member.invited"],
  orgId: "org_123",
});

Send an event

import { getWebhooks } from "@nebutra/webhooks";

const webhooks = await getWebhooks();

await webhooks.sendEvent({
  id: crypto.randomUUID(),
  eventType: "invoice.paid",
  payload: {
    invoiceId: "inv_123",
    amount: 9900,
    currency: "usd",
  },
  timestamp: new Date().toISOString(),
  tenantId: "org_123",
});

Nebutra handles delivery, retries (with exponential backoff), and signature signing automatically.

Event delivery guarantees

PropertyValue
DeliveryAt-least-once
Retry attempts3 (with exponential backoff: 1s, 10s, 60s)
SignatureHMAC-SHA256
Payload formatJSON
Timeout per attempt30 seconds

Inbound webhooks (receiving events)

Verifying signatures

Always verify webhook signatures before processing events:

import { NextRequest } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "invoice.paid":
      await handleInvoicePaid(event.data.object as Stripe.Invoice);
      break;
    case "customer.subscription.deleted":
      await handleSubscriptionCancelled(event.data.object as Stripe.Subscription);
      break;
  }

  return new Response("OK", { status: 200 });
}

Clerk webhooks

import { Webhook } from "svix";
import { headers } from "next/headers";

export async function POST(req: Request) {
  const body = await req.text();
  const headerPayload = await headers();
  const svixId = headerPayload.get("svix-id")!;
  const svixTimestamp = headerPayload.get("svix-timestamp")!;
  const svixSignature = headerPayload.get("svix-signature")!;

  const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);

  const event = wh.verify(body, {
    "svix-id": svixId,
    "svix-timestamp": svixTimestamp,
    "svix-signature": svixSignature,
  });

  switch (event.type) {
    case "organization.created":
      await provisionTenant(event.data);
      break;
    case "organizationMembership.created":
      await addMember(event.data);
      break;
  }

  return new Response("OK", { status: 200 });
}

Webhook events reference

EventWhen it fires
org.createdA new organization is created
org.updatedOrg name or settings change
org.deletedOrganization is deleted
member.invitedA member invitation is sent
member.joinedA member accepts an invitation
member.removedA member is removed from the org
api_key.createdAn API key is created
api_key.revokedAn API key is revoked
project.createdA new project is created
project.deletedA project is deleted
invoice.paidA billing invoice is paid
subscription.changedPlan upgrade or downgrade
quota.warningUsage reaches 80% of quota
quota.exceededUsage exceeds 100% of quota

Testing webhooks locally

Use the Stripe CLI to forward events to your local server:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Or use ngrok to expose your local server:

ngrok http 3000
# Copy the HTTPS URL → paste into your webhook endpoint settings

How is this guide?

Edit on GitHub

Last updated on

On this page