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/webhooksinNebutra/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: Foundation — Type definitions, factory pattern, provider adapters, and injectable dead-letter storage are complete. Production custom deployments still need durable store adapters and queue infrastructure.
pnpm add @nebutra/webhooksimport { 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_...",
});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;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_...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 });
}
}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_...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",
redisUrl: "redis://localhost:6379", // optional, for persistence
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 bundled dead-letter store is intentionally an adapter seam, not a database coupling. For production use, inject a Redis/PostgreSQL-backed implementation and integrate with @nebutra/queue for distributed delivery.
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,
});Create a webhooks provider.
interface WebhookConfig {
provider: "svix" | "custom";
// ... provider-specific options
}Get the default (singleton) provider. Auto-detects from environment.
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>;
}Update an endpoint (URL, eventTypes, active status, metadata).
Delete an endpoint.
List all endpoints for a tenant.
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
}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
}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;
}Manually retry delivery to a specific endpoint.
Rotate the signing secret. Returns new secret.
Verify a webhook signature. Throws on invalid signature.
Graceful shutdown.
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",
});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
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 });
}
}Verification includes timestamp validation. Signatures older than 5 minutes (configurable) are rejected:
verifyPayload(payload, signature, secret, timestamp, toleranceSec = 300);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 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);
}const newSecret = await webhooks.rotateSecret(endpoint.id);
// Store in your DB / notifiable secret manager
// Consumers need to be notified of the rotationSVIX_API_KEY=svix_test_...
WEBHOOK_PROVIDER=svix # optional, auto-detectedREDIS_URL=redis://localhost:6379 # optional, for persistence
WEBHOOK_PROVIDER=custom # optional, auto-detectedFor Svix:
- Set
SVIX_API_KEYin production environment - No additional setup required
For Custom:
- Deploy Redis or use managed Redis (AWS ElastiCache, etc.)
- Integrate with
@nebutra/queuefor 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
When adding new features:
- Update types in
src/types.ts - Implement in both providers (
src/providers/svix.ts,src/providers/custom.ts) - Add tests (if applicable)
- Update this README with examples
MIT