Management contracts
Shared endpoint lifecycle, sender reads, and custom storage interfaces.
good-webhooks/management defines the endpoint management contract that every provider implements, and createManagement builds one over your own storage.
import { createManagement } from 'good-webhooks/management'
import type {
EndpointManagement,
EndpointSource,
EndpointResolution,
ManagementScope,
ManagementOptions,
ManagementRepository,
SecretCipher,
StoredEndpoint,
StoredEndpointPatch,
ManagedEndpoint,
CreateManagedEndpoint,
UpdateManagedEndpoint,
ManagedEndpointWithSecret,
} from 'good-webhooks/management'Most applications use a ready provider: PostgreSQL or Better Auth. The endpoints guide covers the lifecycle; custom senders covers building on these contracts.
EndpointManagement
interface EndpointManagement {
readonly source: EndpointSource
create(scope: ManagementScope, input: CreateManagedEndpoint): Promise<ManagedEndpointWithSecret>
list(scope: ManagementScope): Promise<ManagedEndpoint[]>
get(scope: ManagementScope, id: string): Promise<ManagedEndpoint>
update(scope: ManagementScope, id: string, patch: UpdateManagedEndpoint): Promise<ManagedEndpoint>
pause(scope: ManagementScope, id: string): Promise<ManagedEndpoint>
resume(scope: ManagementScope, id: string): Promise<ManagedEndpoint>
remove(scope: ManagementScope, id: string): Promise<ManagedEndpoint>
rotateSecret(
scope: ManagementScope,
id: string,
options?: { graceMs?: number },
): Promise<ManagedEndpointWithSecret>
removeScope(scope: ManagementScope): Promise<void>
reencrypt(scope: ManagementScope): Promise<number>
}import { createPostgresManagement } from 'good-webhooks/management/postgres'
import { Pool } from 'pg'
const management = createPostgresManagement({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
eventTypes: ['invoice.paid'],
encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!,
})
const scope = { type: 'organization', id: 'org_123' }
const { endpoint, secret } = await management.create(scope, {
url: 'https://receiver.example/webhooks',
eventTypes: ['invoice.paid'],
})
await management.pause(scope, endpoint.id)All direct calls are trusted server operations. They do not authorize the caller. The first argument is ManagementScope: null for application scope or { type, id } for a named scope. See scopes.
| Method | Result and behavior |
|---|---|
create(scope, input) | Promise<ManagedEndpointWithSecret>. Creates an active endpoint and returns { endpoint, secret }. |
list(scope) | Promise<ManagedEndpoint[]>. Returns every nondeleted endpoint in the scope. |
get(scope, id) | Promise<ManagedEndpoint>. Returns an endpoint, including its deleted state. |
update(scope, id, patch) | Promise<ManagedEndpoint>. Changes the supplied mutable fields. |
pause(scope, id) | Promise<ManagedEndpoint>. Changes the status to 'paused'. |
resume(scope, id) | Promise<ManagedEndpoint>. Validates the current URL and changes status to 'active'. |
remove(scope, id) | Promise<ManagedEndpoint>. Sets status to 'deleted' and clears signing secrets. Removing an already deleted record is idempotent. |
rotateSecret(scope, id, options?) | Promise<ManagedEndpointWithSecret>. Changes the signing secret. options.graceMs defaults to 86,400,000 and accepts 0 through 86,400,000. |
removeScope(scope) | Promise<void>. Deletes the scope's currently registered endpoints. |
reencrypt(scope) | Promise<number>. Rewrites live signing-secret ciphertext using the current storage encryption key and returns the count. |
source | Trusted sender reads. See EndpointSource. |
Inputs and limits
CreateManagedEndpoint has url, optional description, and eventTypes. UpdateManagedEndpoint makes those fields optional and accepts description: null to clear the description.
| Value | Limit |
|---|---|
Scope type | 1 through 64 characters, nonblank, no null bytes. |
Scope id | 1 through 200 characters, nonblank, no null bytes. |
| Endpoint ID | 1 through 255 characters, nonblank, no null bytes. |
| URL | At most 2,048 characters. Must pass the configured destination validation, otherwise UNSAFE_URL. |
| Description | At most 2,000 characters, no null bytes. |
eventTypes | 1 through 100 configured event names. Duplicates are collapsed. |
| Endpoints | At most 1,000 nondeleted endpoints per scope. |
graceMs | Integer from 0 through 86,400,000. Default 86,400,000. |
Results
ManagedEndpoint is the shared Endpoint shape: id, url, nullable description, eventTypes, status, createdAt, and updatedAt. Timestamps are Date values. Ordinary results contain neither plaintext signing secrets nor ciphertext. Only create and rotateSecret return the new secret.
Errors
| Error | When |
|---|---|
INVALID_INPUT | An argument breaks a limit above, or names an event outside the configured list. |
UNSAFE_URL | The URL fails destination validation. |
NOT_FOUND | No endpoint with this ID in the scope, or the scope's owner no longer exists. |
INVALID_STATE | The endpoint is deleted, the scope is full, a signing-secret overlap is active, or the record keeps changing. |
EndpointSource
interface EndpointSource {
matchRecipients(scope: ManagementScope, eventType: string): Promise<readonly string[]>
resolveEndpoint(scope: ManagementScope, id: string): Promise<EndpointResolution>
}
type EndpointResolution =
{ status: 'active'; url: string; secrets: readonly string[] } | { status: 'paused' | 'deleted' }import { createDelivery } from 'good-webhooks/delivery'
import type { EndpointSource } from 'good-webhooks/management'
import { Pool } from 'pg'
import { z } from 'zod'
// A fixed, single-endpoint source. Real sources read your endpoint storage.
const source: EndpointSource = {
async matchRecipients() {
return ['endpoint-1']
},
async resolveEndpoint() {
return {
status: 'active',
url: 'https://receiver.example/webhooks',
secrets: [process.env.WEBHOOK_SECRET!],
}
},
}
const delivery = createDelivery({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
events: { 'invoice.paid': z.object({ invoiceId: z.string() }) },
source,
})The sender calls matchRecipients once at publication and resolveEndpoint before every attempt. The source is not an API for untrusted callers.
| Method | Contract |
|---|---|
matchRecipients | Returns the complete set of matching endpoint IDs, including paused endpoints. Lookup failures reject. A source must not convert an outage into an empty result. |
resolveEndpoint | Returns current state. Active results contain the current URL and usable Standard Webhooks signing secrets. Deleted or missing records return 'deleted'. |
The built-in source returns the new signing secret first and the previous secret only while the overlap remains active. It rejects an unknown event name with INVALID_INPUT. Lookup and decryption failures reject.
createManagement
createManagement(options: ManagementOptions): EndpointManagementimport { createManagement } from 'good-webhooks/management'
import { createPostgresManagementRepository } from 'good-webhooks/management/postgres'
import { Pool } from 'pg'
import { cipher } from './cipher.js' // Your SecretCipher, for example backed by a KMS.
export const management = createManagement({
repository: createPostgresManagementRepository(
new Pool({ connectionString: process.env.DATABASE_URL }),
),
cipher,
eventTypes: ['invoice.paid', 'invoice.voided'],
})Creates the shared endpoint lifecycle over custom persistence and encryption.
| Option | Contract |
|---|---|
repository | Required ManagementRepository. |
cipher | Required SecretCipher. |
eventTypes | Required nonempty array of names matching ^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$. No payload validators are required. |
allowLocalhost | Optional boolean, default false. Allows development HTTP loopback URLs. |
validateUrl(url) | Optional async host check after portable URL validation. |
scopeExists(scope) | Optional async ownership check. Returns false for a missing owner and rejects on lookup failure. |
Invalid options throw INVALID_CONFIG. Portable URL validation requires HTTPS and rejects credentials, fragments, and private or reserved IP literals. Loopback hosts, including localhost, need allowLocalhost, which also permits HTTP for them.
ManagementRepository
interface ManagementRepository {
list(scope: ManagementScope): Promise<StoredEndpoint[]>
get(scope: ManagementScope, id: string): Promise<StoredEndpoint | null>
create(scope: ManagementScope, endpoint: Omit<StoredEndpoint, 'id'>): Promise<StoredEndpoint>
update(
scope: ManagementScope,
id: string,
revision: number,
patch: StoredEndpointPatch,
): Promise<StoredEndpoint | null>
}| Method | Contract |
|---|---|
list | Returns every live record in the scope without truncation. |
get | Returns the record, including a deleted one, or null. |
create | Atomically inserts the record and enforces the strict 1,000-record scope limit. |
update | Atomically guards on scope, ID, and revision, increments the revision, and applies the patch. A revision conflict returns null. |
Repository errors propagate. createPostgresManagementRepository is the built-in implementation.
StoredEndpoint adds revision, encryptedSecret, nullable previousEncryptedSecret, and nullable previousSecretExpiresAt to the endpoint shape. StoredEndpointPatch is a partial stored record without id, createdAt, or revision. Stored records must never be sent to clients.
SecretCipher
interface SecretCipher {
encrypt(value: string): Promise<string>
decrypt(value: string): Promise<string>
}The cipher encrypts receiver-facing signing secrets at rest. A cipher that supports storage encryption key rotation writes with the current key and reads the retained keys until re-encryption finishes.