# Custom senders

> Keep your own delivery pipeline and use Good Webhooks endpoint management as its source of recipients and signing secrets.

Source: https://good-webhooks.dev/docs/guides/custom-senders



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:

```ts
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 [#what-your-sender-must-do]

1. **Capture recipients** (on publish): Call `matchRecipients()` once and store the full list with the event.
2. **Resolve** (before each attempt): Call `resolveEndpoint()` for the current URL, status, and secrets.
3. **Sign and send** (each attempt): Standard Webhooks signature, safe connection, no redirects.

### Capture recipients once [#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 [#resolve-before-every-attempt]

```ts
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-and-send-safely]

* Sign with the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) 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](https://good-webhooks.dev/docs/better-auth/delivery.md) to the same source instead.

## What the source guarantees [#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()` returns `deleted`. A failed owner lookup rejects.
* During a signing-secret rotation, `secrets` lists the new secret first and the old one until the overlap ends.

## Custom storage instead [#custom-storage-instead]

To keep the endpoint lifecycle API but store endpoints somewhere else, implement the [`ManagementRepository` contract](https://good-webhooks.dev/docs/reference/management.md#managementrepository) and pass it to `createManagement()`. A sender only ever needs `EndpointSource`.
