Skip to content

Nebutra/webhooks

Repository files navigation

@nebutra/webhooks

Public mirror for @nebutra/webhooks from Nebutra/Nebutra-Sailor.

This repository is generated from the Nebutra Sailor monorepo. Package releases are cut from the monorepo and mirrored here for discovery, standalone cloning, and contribution intake.

  • Canonical source: packages/integrations/webhooks in Nebutra/Nebutra-Sailor
  • Package registry: npm and GitHub Packages
  • Contributions: open issues or PRs here; maintainers port accepted changes back into the monorepo source package

Provider-agnostic webhook outbound management system for Nebutra. Supports Svix (managed) and custom (self-hosted) webhook delivery.

Status: Stable — Svix-managed outbound webhooks are production-capable. The custom self-hosted provider exposes retry/dead-letter seams and refuses memory-backed production delivery unless explicitly overridden.

Installation

pnpm add @nebutra/webhooks

Quick Start

1. Initialize the webhooks provider

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

// Auto-detects provider from environment
const webhooks = await getWebhooks();

// Or explicit config
import { createWebhooks } from "@nebutra/webhooks";

const webhooks = await createWebhooks({
  provider: "svix",
  apiKey: "svix_test_...",
});

2. Create a webhook endpoint

const endpoint = await webhooks.createEndpoint(
  "org_123", // tenantId
  {
    url: "https://example.com/webhooks",
    eventTypes: ["user.created", "invoice.paid"], // optional; empty = all events
    active: true,
    metadata: { team: "engineering" },
  }
);

const endpointId = endpoint.id; // whe_...
const signingSecret = endpoint.secret;

3. Dispatch an event

const messageId = await webhooks.sendEvent({
  eventType: "user.created",
  payload: {
    userId: "user_123",
    email: "alice@example.com",
    createdAt: new Date().toISOString(),
  },
  tenantId: "org_123",
});

const deliveryMessageId = messageId; // msg_...

4. Verify incoming webhooks (consumer side)

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

// In your API route handler
export async function POST(req: Request) {
  const signature = req.headers.get("Webhook-Signature");
  const payload = await req.text();

  // Extract timestamp from signature header
  const parts = signature.split(".");
  const timestamp = parts[1];

  try {
    verifyPayload(payload, parts[2], secret, timestamp);
    // Signature valid, process webhook
  } catch (error) {
    // Invalid or expired signature
    return new Response("Unauthorized", { status: 401 });
  }
}

Providers

Svix (Managed)

Best for: SaaS products, managed infrastructure, enterprise features.

Auto-detects if SVIX_API_KEY is set. Otherwise, pass config:

const webhooks = await createWebhooks({
  provider: "svix",
  apiKey: "svix_test_...",
});

Features:

  • ✅ Managed retry logic (exponential backoff)
  • ✅ Built-in rate limiting & security
  • ✅ Event replay and retry UI
  • ✅ Webhook signing (Svix format)
  • ✅ Application isolation per tenant
  • ❌ No direct access to delivery attempts (API limitation)

Environment variables:

SVIX_API_KEY=svix_test_...

Custom (Self-Hosted)

Best for: Full control, on-premise deployments, fine-grained observability.

Auto-detects if SVIX_API_KEY is not set. Otherwise:

const webhooks = await createWebhooks({
  provider: "custom",
  maxRetries: 6,
  initialBackoffSec: 5,
});

Features:

  • ✅ Full control over delivery logic
  • ✅ Injectable dead-letter store seam; default store is in-memory
  • ✅ Exponential backoff: 5s, 30s, 2m, 15m, 1h, 6h
  • ✅ Manual retry & delivery observability
  • ✅ Dead-letter metadata after retry exhaustion
  • ✅ HMAC-SHA256 signing (industry standard)
  • ❌ You handle infra, scaling, monitoring

Note: The custom provider is a self-hosted adapter seam. Its bundled endpoint/message state is in-memory, so the factory refuses this mode in production unless ALLOW_MEMORY_WEBHOOKS_IN_PRODUCTION=true is set as an explicit temporary override. Prefer Svix for production until a durable endpoint/message store is injected alongside a Redis/PostgreSQL-backed dead-letter implementation and distributed queue.

import { createWebhooks, type WebhookDeadLetterStore } from "@nebutra/webhooks";

const deadLetterStore: WebhookDeadLetterStore = {
  async upsert(record) {
    // Persist by `${record.messageId}:${record.endpointId}` in Redis/PostgreSQL.
  },
  async delete(messageId, endpointId) {
    // Remove the dead-letter record after a successful manual replay.
  },
  async list(messageId) {
    // Return all records or records for one message.
    return [];
  },
};

const webhooks = await createWebhooks({
  provider: "custom",
  maxRetries: 6,
  deadLetterStore,
});

API

createWebhooks(config?: WebhookConfig): Promise<WebhookProvider>

Create a webhooks provider.

interface WebhookConfig {
  provider: "svix" | "custom";
  // ... provider-specific options
}

getWebhooks(): Promise<WebhookProvider>

Get the default (singleton) provider. Auto-detects from environment.

WebhookProvider Interface

createEndpoint(tenantId, endpoint): Promise<WebhookEndpoint>

Register a new webhook endpoint for a tenant.

interface WebhookEndpoint {
  id: string;                    // whe_...
  url: string;                   // https://example.com/webhooks
  tenantId: string;              // org_123
  secret: string;                // signing secret (base64)
  eventTypes: string[];          // ["user.created", "invoice.paid"]
  active: boolean;
  createdAt: string;             // ISO-8601
  metadata?: Record<string, unknown>;
}

updateEndpoint(endpointId, updates): Promise<WebhookEndpoint>

Update an endpoint (URL, eventTypes, active status, metadata).

deleteEndpoint(endpointId): Promise<void>

Delete an endpoint.

listEndpoints(tenantId): Promise<WebhookEndpoint[]>

List all endpoints for a tenant.

sendEvent(event): Promise<string>

Dispatch an event to all matching endpoints. Returns message ID.

interface WebhookMessage {
  eventType: string;                // "user.created"
  payload: Record<string, unknown>; // event data
  tenantId: string;                 // org_123
}

getDeliveryAttempts(messageId): Promise<WebhookDeliveryAttempt[]>

Get delivery attempt history for a message.

interface WebhookDeliveryAttempt {
  id: string;
  messageId: string;
  endpointId: string;
  status: "success" | "failed" | "pending" | "timeout";
  statusCode: number | null;
  response: string | null;
  attemptNumber: number;
  nextRetryAt: string | null; // ISO-8601
  attemptedAt: string;        // ISO-8601
}

getDeadLetterDeliveries(messageId?): Promise<WebhookDeadLetterDelivery[]>

Get dead-lettered deliveries after all retry attempts are exhausted.

interface WebhookDeadLetterDelivery {
  id: string;
  messageId: string;
  endpointId: string;
  tenantId: string;
  eventType: string;
  payload: Record<string, unknown>;
  finalAttemptId: string;
  finalAttemptNumber: number;
  statusCode: number | null;
  response: string | null;
  failedAt: string;
  deadLetteredAt: string;
}

retryMessage(messageId, endpointId): Promise<void>

Manually retry delivery to a specific endpoint.

rotateSecret(endpointId): Promise<string>

Rotate the signing secret. Returns new secret.

verifySignature(payload, signature, secret): Promise<boolean>

Verify a webhook signature. Throws on invalid signature.

close(): Promise<void>

Graceful shutdown.

Event Types

enum WebhookEventType {
  // User events
  USER_CREATED = "user.created",
  USER_UPDATED = "user.updated",
  USER_DELETED = "user.deleted",

  // Invoice events
  INVOICE_PAID = "invoice.paid",
  INVOICE_FAILED = "invoice.failed",
  INVOICE_UPDATED = "invoice.updated",

  // Subscription events
  SUBSCRIPTION_CREATED = "subscription.created",
  SUBSCRIPTION_UPDATED = "subscription.updated",
  SUBSCRIPTION_CANCELLED = "subscription.cancelled",

  // Organization events
  ORG_CREATED = "org.created",
  ORG_UPDATED = "org.updated",
  ORG_DELETED = "org.deleted",
}

To add custom events, extend the enum or use string literals:

await webhooks.sendEvent({
  eventType: "custom.myevent",
  payload: { ...data },
  tenantId: "org_123",
});

Signing & Verification

Standard Format

Webhooks are signed using HMAC-SHA256. The signature is included in the Webhook-Signature header:

Webhook-Signature: whsec_{secret}.{timestamp}.{signature}

Where:

  • secret — base64-encoded signing secret (32 bytes)
  • timestamp — Unix timestamp (seconds since epoch)
  • signature — base64-encoded HMAC-SHA256 hash

Verification

import { verifyPayload, parseWebhookSignatureHeader } from "@nebutra/webhooks";

export async function POST(req: Request) {
  const payload = await req.text();
  const headerValue = req.headers.get("Webhook-Signature");

  const parsed = parseWebhookSignatureHeader(headerValue);
  if (!parsed) {
    return new Response("Invalid signature format", { status: 400 });
  }

  try {
    await webhooks.verifySignature(payload, headerValue, endpoint.secret);
    // Process webhook
  } catch (error) {
    return new Response("Unauthorized", { status: 401 });
  }
}

Replay Attack Protection

Verification includes timestamp validation. Signatures older than 5 minutes (configurable) are rejected:

verifyPayload(payload, signature, secret, timestamp, toleranceSec = 300);

Examples

Create & List Endpoints

const webhooks = await getWebhooks();

// Create
const endpoint = await webhooks.createEndpoint("org_123", {
  url: "https://api.example.com/webhooks",
  eventTypes: ["invoice.paid"],
  metadata: { team: "payments" },
});

// List
const endpoints = await webhooks.listEndpoints("org_123");
return endpoints;

// Delete
await webhooks.deleteEndpoint(endpoint.id);

Send & Retry

// Send event
const messageId = await webhooks.sendEvent({
  eventType: "invoice.paid",
  payload: {
    invoiceId: "inv_123",
    amount: 9999,
    currency: "USD",
  },
  tenantId: "org_123",
});

// Check delivery status
const attempts = await webhooks.getDeliveryAttempts(messageId);
const deliverySummary = attempts.map((attempt) => ({
  endpointId: attempt.endpointId,
  status: attempt.status,
  attemptNumber: attempt.attemptNumber,
}));

// Manual retry
if (attempts.some((a) => a.status === "failed")) {
  await webhooks.retryMessage(messageId, failedEndpoint.id);
}

Rotate Secrets

const newSecret = await webhooks.rotateSecret(endpoint.id);

// Store in your DB / notifiable secret manager
// Consumers need to be notified of the rotation

Environment Variables

Svix

SVIX_API_KEY=svix_test_...
WEBHOOK_PROVIDER=svix  # optional, auto-detected

Custom

REDIS_URL=redis://localhost:6379  # optional, for persistence
WEBHOOK_PROVIDER=custom             # optional, auto-detected

Production Checklist

For Svix:

  • Set SVIX_API_KEY in production environment
  • No additional setup required

For Custom:

  • Deploy Redis or use managed Redis (AWS ElastiCache, etc.)
  • Integrate with @nebutra/queue for distributed delivery
  • Inject a persistent dead-letter store
  • Add monitoring/alerting on delivery failures
  • Add replay guard helpers for duplicate valid signatures
  • Consider rate limiting per endpoint

Contributing

When adding new features:

  1. Update types in src/types.ts
  2. Implement in both providers (src/providers/svix.ts, src/providers/custom.ts)
  3. Add tests (if applicable)
  4. Update this README with examples

License

MIT

About

Outbound webhook contracts and delivery primitives for Nebutra services.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors