# Get notified with webhooks (/docs/build/webhooks)



A webhook lets your own systems hear about member activity as it happens. Use one to:

* **Ping your team** when a submission needs review.
* **Update your CRM** when a member completes a quest or earns points.
* **Ship a reward** your own system fulfils, such as merch, when a member earns it.

You add an endpoint, a web address on your backend, and pick the events it should receive. Domino then sends each matching event there, signed so your backend can tell it came from Domino, and retries when your backend doesn't answer. Your team can see every delivery in Console and send a failed one again.

> [!DEV]
> Configure a public HTTPS receiver, then subscribe to activity from one project and environment. Use a management credential with `publish` permission. Store the returned signing secret in your backend's secret store.

## Manage endpoints in Console [#manage-endpoints-in-console]

Open **Settings → Webhooks** in the project and environment you want. Choose **Add endpoint**, enter the public HTTPS URL, tick the events it should receive, then choose **Create endpoint**. Copy the one-time signing secret to your backend before leaving the page.

Choose **View endpoint** to edit the URL or event filters (an event name Console doesn't list stays subscribed when you save), disable or enable delivery, or rotate the signing secret. Its **Endpoint ID** has a copy button, for the CLI and `management.webhooks` calls that name the endpoint. The delivery log shows status, attempts, HTTP response and failure reason. After fixing the receiver, enable the endpoint and choose **Replay delivery** on a completed, failed or skipped delivery. Use **Load older deliveries** to continue the log and **Refresh deliveries** to check retry results.

People with read permission can inspect endpoints and deliveries; Console hides mutation controls from them. Changes, secret rotation and replay require publish permission. A pending delivery has no replay action until it finishes. These operational controls work in both Test and Live.

## Connect your backend [#connect-your-backend]

### 1. Create an endpoint [#1-create-an-endpoint]

```ts
import { createManagementClient, activityTypes } from "@domino-sdk/relay/server"

const management = createManagementClient({
  baseUrl: "https://relay.domino.run",
  organization: "your-organization",
  project: "your-project",
  environment: "test",
  getToken: async () => "your-management-token",
})
const created = await management.webhooks.create({
  url: "https://your-backend.example/webhooks/domino",
  types: [activityTypes.completionCreated, activityTypes.reviewPending],
})
const endpointId = created.endpoint.id
const signingSecret = created.secret
```

The secret is returned only when you create the endpoint or rotate its secret. List responses omit secrets. Keep the endpoint ID for delivery inspection and recovery.

To use the CLI, save the URL and `types` in `endpoint.json`, then run:

```sh
domino webhooks create endpoint.json --environment test --json
```

Save the one-time `secret` from the output securely. List endpoints with `domino webhooks list --json`.

Domino refuses an event type it never sends, such as a typo, with `unknown-activity-type`. The error lists the valid types.

If the answer is lost, for example when the connection drops, do not create the endpoint again. The CLI saves the create as an operation and exits with code 3: run `domino operations resume <id>` with the ID from the error to get the same endpoint and secret. The saved operation keeps the secret in your local Domino configuration directory only until the CLI has printed it once; after that, rotate the secret if you lost it. From the SDK, call `create` again with the same `actionId`.

### 2. Verify the raw request [#2-verify-the-raw-request]

Read the body once as text. Pass the `Domino-Signature` header and raw body to `verifyWebhook` before you process the event.

```ts
import { verifyWebhook, type WebhookEvent } from "@domino-sdk/relay/server"

export async function receive(
  request: Request,
  secret: string,
  acceptOnce: (event: WebhookEvent) => Promise<void>,
): Promise<Response> {
  let event: WebhookEvent
  try {
    event = await verifyWebhook({
      body: await request.text(),
      signature: request.headers.get("Domino-Signature") ?? "",
      secret,
    })
  } catch {
    return new Response("Invalid webhook", { status: 400 })
  }
  await acceptOnce(event)
  return new Response(null, { status: 204 })
}
```

Branch on `event.type`, a string, by comparing it with `activityTypes`, and ignore a type you don't handle instead of failing on it, since Domino adds event types. To type your own handlers, use `ActivityType` from `@domino-sdk/relay/server`: it names the known types and stays open to new ones.

Implement `acceptOnce` with durable storage keyed by `event.id`. Commit the dedupe record and your business effect in the same transaction, or atomically enqueue durable work. Return a 2xx response only after that commit. Apply a 64 KiB request-body limit in your HTTP server before reading the body.

> [!IMPORTANT] Dedupe is separate from signature verification
> The signature helper checks authenticity and timestamp age. A valid request can be sent again within that window. Persist processed event IDs, including across deployments and manual replays.

### 3. Rotate the signing secret [#3-rotate-the-signing-secret]

Call `management.webhooks.rotateSecret(endpointId)` or `domino webhooks rotate-secret <endpoint> --json`. Install the new secret in your receiver immediately. Domino signs with both old and new secrets for 24 hours, so either verifies during deployment. A second rotation during a usable overlap window returns 409. After 24 hours only the new secret signs requests.

If a rotation's answer is lost, resume its CLI operation, or call `rotateSecret(endpointId, { actionId })` again with the same `actionId`. Either returns the same new secret during the overlap. After a later rotation, the earlier one's retry returns 409 `secret-superseded`.

> [!DEV] When the old secret is unusable
> If Domino cannot decrypt the old secret after an infrastructure encryption-key replacement, rotation restores signing immediately and returns `previousValidUntil: null`. There is no overlap: install the returned secret in your receiver, re-enable the endpoint if needed, and replay failed deliveries. An unusable overlap does not block this recovery.

## Recover a failed delivery [#recover-a-failed-delivery]

1. Call `management.webhooks.deliveries(endpointId)` or `domino webhooks deliveries <endpoint> --json`. Inspect `status`, `attempts`, `responseStatus`, and `lastError`.
2. Fix the receiver. If the endpoint is disabled, call `management.webhooks.update(endpointId, { enabled: true })`.
3. Call `management.webhooks.replay(endpointId, deliveryId)` or `domino webhooks replay <endpoint> <delivery> --json`.

Replay uses the original event ID and payload, the current endpoint URL, and the current signing secrets. The new delivery gets its own ID and retry budget. Pending deliveries cannot be replayed. Replaying a successful event still reaches your dedupe check.

If a replay's answer is lost, check the delivery log before replaying again, or pass an action ID up front: `domino webhooks replay <endpoint> <delivery> --action-id <uuid>`. Sent again with the same ID, the replay returns the delivery it first created instead of creating another.

Pass the returned `nextCursor` back as `cursor` (`--cursor` in the CLI) to continue the delivery log. Endpoint changes and replay are audited. The same delivery diagnostics and replay controls are available in **Settings → Webhooks**.

> [!NOTE] Test also sends requests
> Test and live endpoints are independent, and both send real HTTPS requests. A [promotion](/docs/concepts/promotion) never copies endpoints: when test has one and live has none, the promotion warns, and **Open Webhooks** on it in **Live → Deployments** opens live's webhook settings. CLI sign-in sessions cannot change live endpoints. A live API key with `publish` permission can manage live endpoints.

See the [webhook reference](/docs/reference/webhooks) for payload fields, signing format, event types, and limits.

## Member activity and event names [#member-activity-and-event-names]

The same committed changes can drive a member's cursor-polled activity feed and your backend's webhooks. Member polling works without a webhook endpoint. Subscribe to `activityTypes.attemptRejected` (`attempt.rejected`) for rejected attempts, `activityTypes.entitlementGranted` (`entitlement.granted`) for new or upgraded earned tiers, and `activityTypes.rewardDeliveryUpdated` (`reward.delivery.updated`) for delivery state changes, including Discord reconciliation. Existing completion, review-pending, point-award, collection and referral-qualification events remain available.

Webhook payloads identify the affected member. The member feed explicitly selects public fields and omits review notes, provider diagnostics and the other member's referral identity. Its event names describe member outcomes, such as `attempt.accepted` and `delivery.updated`; do not assume they are identical to webhook event names. See [Participant activity](/docs/reference/participant-api#participant-activity).

> [!DEV]
> A newly queued integration reward publishes `reward.delivery.updated` with status `pending` in the same transaction as its delivery record. The event does not depend on the Discord reconciliation worker starting.

## Delete an endpoint [#delete-an-endpoint]

With publish permission, choose **Delete endpoint** in Console **Settings → Webhooks**, read its current revision and provide a reason. Deletion stops new delivery and retries, frees endpoint capacity and keeps read-only delivery history under **Deleted endpoints**. An HTTP request already in flight may finish. Deleted endpoints cannot be resumed, rotated or replayed; create a new endpoint to send again.

> [!DEV]
> Use `management.webhooks.state(id)` for the current fence, then `management.webhooks.delete(id, { expectedRevision: endpoint.revision, reason })`. `management.webhooks.states({ cursor, limit })` pages active and deleted endpoints, and `deliveries(id, query)` retains their history. The CLI provides `domino webhooks get ID`, `domino webhooks states` and `domino webhooks delete ID --expected-revision N --reason TEXT`. Mutation inputs accept an optional action ID for identical retries. These controls retain the existing publish permission and credential rules in Test and Live.
