Custom senders
Keep your own delivery pipeline and use Good Webhooks endpoint management as its source of recipients and signing secrets.
Already have a queue that sends HTTP requests? Keep it, and use Good Webhooks only for endpoint management. Both providers expose management.source, an EndpointSource with the two reads a sender needs:
import type { EndpointSource } from 'good-webhooks/management'
// interface EndpointSource {
// matchRecipients(scope, eventType): Promise<readonly string[]>
// resolveEndpoint(scope, endpointId): Promise<
// | { status: 'active'; url: string; secrets: readonly string[] }
// | { status: 'paused' | 'deleted' }
// >
// }scope is null for application scope or { type, id }. The source returns plaintext signing secrets, so keep it in trusted server code.
What your sender must do
- on publishCapture recipients
Call
matchRecipients()once and store the full list with the event. - before each attemptResolve
Call
resolveEndpoint()for the current URL, status, and secrets. - each attemptSign and send
Standard Webhooks signature, safe connection, no redirects.
Capture recipients once
When you accept an event, call matchRecipients() and store the complete list with it, paused endpoints included. On a repeat of the same event, reuse the stored list instead of asking again.
A rejected lookup is a failure, not "no recipients". Never store a partial list.
Resolve before every attempt
const endpoint = await source.resolveEndpoint(scope, endpointId)
if (endpoint.status === 'active') {
// Send to endpoint.url, signing with every secret in endpoint.secrets.
} else if (endpoint.status === 'paused') {
// Keep the delivery queued, within your expiry policy.
} else {
// Deleted or missing: cancel this delivery.
}Do this for retries too. The URL and secrets from publish time may be stale.
Sign and send safely
- Sign with the Standard Webhooks format, once per secret in
secrets, so receivers keep working during a rotation. - Keep the event ID and body identical across retries and replays.
- Before each connection, resolve the hostname, reject private and otherwise unsafe addresses, connect to the checked address with TLS hostname checks, and refuse redirects. Checks at registration don't cover later DNS changes.
Durable queuing, bounded retries, attempt history, retention, and concurrency limits are also yours. If that list is longer than you want, connect the built-in delivery to the same source instead.
What the source guarantees
matchRecipients()returns every matching endpoint, or rejects. Concurrent edits may or may not be seen; there's no scope-wide snapshot.- The Better Auth provider also checks that the owning user or organization still exists. A missing owner means no recipients, and
resolveEndpoint()returnsdeleted. A failed owner lookup rejects. - During a signing-secret rotation,
secretslists the new secret first and the old one until the overlap ends.
Custom storage instead
To keep the endpoint lifecycle API but store endpoints somewhere else, implement the ManagementRepository contract and pass it to createManagement(). A sender only ever needs EndpointSource.