Good Webhooks

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.

MethodResult 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.
sourceTrusted 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.

ValueLimit
Scope type1 through 64 characters, nonblank, no null bytes.
Scope id1 through 200 characters, nonblank, no null bytes.
Endpoint ID1 through 255 characters, nonblank, no null bytes.
URLAt most 2,048 characters. Must pass the configured destination validation, otherwise UNSAFE_URL.
DescriptionAt most 2,000 characters, no null bytes.
eventTypes1 through 100 configured event names. Duplicates are collapsed.
EndpointsAt most 1,000 nondeleted endpoints per scope.
graceMsInteger 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

ErrorWhen
INVALID_INPUTAn argument breaks a limit above, or names an event outside the configured list.
UNSAFE_URLThe URL fails destination validation.
NOT_FOUNDNo endpoint with this ID in the scope, or the scope's owner no longer exists.
INVALID_STATEThe 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.

MethodContract
matchRecipientsReturns the complete set of matching endpoint IDs, including paused endpoints. Lookup failures reject. A source must not convert an outage into an empty result.
resolveEndpointReturns 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): EndpointManagement
import { 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.

OptionContract
repositoryRequired ManagementRepository.
cipherRequired SecretCipher.
eventTypesRequired nonempty array of names matching ^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$. No payload validators are required.
allowLocalhostOptional 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>
}
MethodContract
listReturns every live record in the scope without truncation.
getReturns the record, including a deleted one, or null.
createAtomically inserts the record and enforces the strict 1,000-record scope limit.
updateAtomically 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.

On this page