> ## Documentation Index
> Fetch the complete documentation index at: https://docs.entire.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Receive webhooks

> Verify deliveries and handle changes to installations, refs and trails.

Entire sends installation, ref and trail events to the webhook URL you register. Use installation events to keep track of your access, ref events to react to pushes, and trail events for work such as deploy previews. They use the same delivery format and signature checks.

> [!NOTE]
>
> Webhooks are experimental. Field names and event types may change before the contract is final.

## What you need

* **A webhook URL.** Entire registers it with your plugin, see [Register your plugin](https://docs.entire.com/guides/api/plugins/register.md). It must answer directly: Entire doesn't follow redirects.
* **An active installation.** Ref and trail events cover the repositories the customer selected. They stop when the installation is suspended or deleted. Installation events tell you about those changes.
* **Live `contents:read` or `contents:write` on each covered repository for ref and trail events.** Register the permission and have the customer accept it. Installation lifecycle events do not require repository contents permission.

## Delivery format

Every request identifies the installation at the top level. The event's body is under `event.payload`:

```json theme={null}
{
  "delivery_id": "dlv_...",
  "plugin_id": "acme",
  "installation_id": "ins_...",
  "core_url": "https://us.auth.entire.io",
  "api_url": "https://aws-us-east-2.api.entire.io",
  "event": {
    "id": "evt_...",
    "type": "installation.updated",
    "time": "2026-10-27T00:00:00Z",
    "payload": {
      "installation": {
        "project_id": "<project id>",
        "revision": 3,
        "repository_selection": "selected",
        "repository_count": 1,
        "repository_ids": ["repo_..."]
      }
    }
  }
}
```

`installation_id` is your installation key. There is no `event.payload.installation.id`. The [project object and management link](https://docs.entire.com/guides/api/plugins/install-flow.md#project-identity) may also be present in the installation payload.

`core_url` can differ from the issuer of the install receipt. Check it against the [Cores you trust](https://docs.entire.com/guides/api/regions-and-routing.md#cores), but don't include either host in your installation key. For repository API calls, refresh the repository's placement through the [repository list](https://docs.entire.com/guides/api/plugins/act-for-an-installation.md#list-repositories); hosts can change.

## Events

### Installation events

* `installation.created` and `installation.updated`: an installation was created or its settings changed.
* `installation.repositories_added` and `installation.repositories_removed`: repositories joined or left the installation.
* `installation.suspended`: access stopped.
* `installation.resumed`: access resumed.
* `installation.deleted`: the installation was removed.

Installation fields are under `event.payload.installation`. Keep the highest `revision` you have applied and ignore an event at or below it. If revisions skip, read the repository list again rather than applying a repository delta to an incomplete list.

On created and updated events, replace your stored selection with the event's selection. `repository_selection` is `all` or `selected`. When no repositories match, `repository_count` and `repository_ids` are absent: treat them as zero and an empty list. When `repositories_omitted` is `true`, the list was too long to include; fetch the repository list instead of clearing your stored selection.

For repository additions and removals, apply `added_repository_ids` or `removed_repository_ids`. Stop work on removed repositories.

On suspension, stop work and discard cached access tokens. `suspended_by` is `plugin` or `customer`; only that side can resume the installation. On resumption, refresh the repository list and mint new tokens. On deletion, discard credentials and remove the installation from your active records.

Don't wait for a webhook to handle revoked access. If an API call says the installation or repository is no longer available, stop acting on it.

### Ref events

Ref events describe changes to branches and tags. Use them to start work when a customer pushes to a repository.

| Event | Sent when |
| :- | :- |
| [`ref.created`](https://docs.entire.com/api-reference/webhooks/ref/ref-created) | A branch or tag was created. `before` is all zeros. |
| [`ref.updated`](https://docs.entire.com/api-reference/webhooks/ref/ref-updated) | A branch or tag was updated. `before` and `after` identify the old and new objects. |
| [`ref.deleted`](https://docs.entire.com/api-reference/webhooks/ref/ref-deleted) | A branch or tag was deleted. `after` is all zeros. |

Every ref event carries `repository`, `ref`, `before`, `after` and `forced`. Use `repository.id` as the repository key and the returned `repository.entire_clone_url` for [checkout](https://docs.entire.com/guides/api/plugins/act-for-an-installation.md#clone-a-repository).

`ref` includes `refs/heads/` for a branch or `refs/tags/` for a tag. Don't try to read or check out a zero object ID. If you only handle branches, ignore tag events and answer 2xx.

For work triggered by a push, use the event's `after` SHA rather than the branch's current head, which may have changed by the time you start. Your service decides whether a deletion or force push cancels earlier work; report any cancellation through the [checks API](https://docs.entire.com/guides/api/plugins/report-checks.md).

### Trail events

A trail can open on a branch that already exists, without a push. Use trail events when you need to see that, and [comment on the trail](https://docs.entire.com/guides/api/plugins/trail-comments.md) to report results.

| Event | Sent when |
| :- | :- |
| [`trail.opened`](https://docs.entire.com/api-reference/webhooks/trail/trail-opened) | A trail opened, as a draft or ready for review. |
| [`trail.updated`](https://docs.entire.com/api-reference/webhooks/trail/trail-updated) | A draft or open trail changed its draft state, title, branch, base or head commit. `changes` names each field that changed. |
| [`trail.closed`](https://docs.entire.com/api-reference/webhooks/trail/trail-closed) | A trail closed without a merge. |
| [`trail.reopened`](https://docs.entire.com/api-reference/webhooks/trail/trail-reopened) | A closed trail opened again. |
| [`trail.merged`](https://docs.entire.com/api-reference/webhooks/trail/trail-merged) | A trail merged into its base branch. |

Every event except `trail.opened` carries `changes`: an object keyed by `state`, `title`, `branch`, `base` or `head_sha`, each with `from` and `to`. `trail.closed`, `trail.reopened` and `trail.merged` always carry `changes.state`.

Entire sends nothing for:

* A deleted trail.
* Comments, discussions, reviews or approvals.
* A description edit.
* Ordinary edits to a closed or merged trail. Reopening is an explicit exception.

Existing trails are not backfilled with `trail.opened` when delivery is enabled. Their next qualifying change can produce an event.

## Handle duplicates and order

* **Deduplicate on `delivery_id`.** It stays the same across retries, so the same delivery can arrive more than once. Answer a repeat with 2xx.
* **Expect a commit twice.** A push to a trail's branch sends `trail.updated` with `changes.head_sha` and also `ref.updated`. If both trigger the same work, deduplicate it by installation, repository, commit and the kind of work you run. Allow an explicit rerun to start a new attempt.
* **`head_sha` can be null.** `trail.opened` can arrive before Entire records a commit. Read the branch yourself, or wait for the `trail.updated` that sets `head_sha`.
* **Order isn't guaranteed.** A retry can arrive after a later event for the same trail. `trail` is the state at `trail.updated_at`: ignore an event older than the state you hold.
* **`actor` and `trail.author` can be null** when Entire doesn't know who acted, such as for a push or a change on the forge.
* **Answer 2xx to event types you don't handle.** Entire may add event types to the same URL.

## Verify a delivery

The [Go SDK](#with-the-go-sdk) does this for you. Without it, verify each request before you trust its body. Each delivery carries `Authorization: Bearer <token>`, an EdDSA JWT:

1. Keep the exact bytes of the request body.
2. Check that the token header's `alg` is `EdDSA`.
3. Check `iss` against the delivery issuers you trust. Configured production issuers are listed below. Don't accept a host just because it ends in `entire.io`.
4. Verify the signature with the key the header's `kid` names. Use the delivery JWKS URL Entire gave you at registration, or the trusted issuer's `/.well-known/jwks.json`. Cache the keys, and fetch them again for an unknown `kid`. Never fetch keys from an unchecked `iss`, and don't use a Core's receipt-signing keys for deliveries.
5. Check that `aud` is your plugin slug.
6. Check `iat` and `exp`. A token is valid for 5 minutes.
7. Compare the `body_sha256` claim with the unpadded base64url SHA-256 of the raw body. Do this before you parse the JSON.
8. Reject a `jti` you've already seen. Each attempt, including a retry, carries a new token and `jti`.

Configured production delivery issuers are:

```text theme={null}
https://us-east-2.entire-ci-webhooks.entire.io
https://eu-central-1.entire-ci-webhooks.entire.io
https://eu-west-2.entire-ci-webhooks.entire.io
https://ap-south-1.entire-ci-webhooks.entire.io
https://ap-southeast-1.entire-ci-webhooks.entire.io
https://ap-southeast-2.entire-ci-webhooks.entire.io
https://sa-east-1.entire-ci-webhooks.entire.io
```

These are different from the [plugin JWT issuer and receipt audience](https://docs.entire.com/guides/api/authentication.md#plugin-jwts). A delivery's audience is still your slug, not `urn:entire:plugin:{slug}`.

Answer a request that fails verification with 401, which Entire doesn't retry. If you can't fetch the key set, answer 503 so that Entire retries later.

## Respond

Answer 2xx within 10 seconds, as soon as the delivery is verified and stored, and do slow work from a queue. Entire retries a 429, a 5xx, a timeout or a network error up to 7 times over about 15 minutes, with the same `delivery_id`. Any other status, including a redirect, fails the delivery for good.

You can return `Retry-After` in seconds. Entire caps it at its longest retry interval and ignores the HTTP-date form.

## With the Go SDK

`App.WebhookHandler` verifies each delivery and checks its delivery store for handled IDs. It returns `401` for authentication failures, `400` for malformed bodies or plugin mismatches, `405` for a wrong method, `413` for an oversized body, `503` when keys are unavailable, and `500` when your callback fails. It always fetches keys from `Config.DeliveryJWKSURL`, not from the token's issuer. Its default issuer policy is looser than the list above: it accepts any HTTPS origin under `entire.io` or `entire.com`, plus the legacy issuers `https://entire.io` and `https://entire.com`. Set `Config.DeliveryIssuer` to pin one issuer.

Decode a trail event with `Event.TrailChange` and pass it to your own storage function:

```go theme={null}
func webhooks(app *plugin.App, saveAndQueue func(context.Context, string, *plugin.TrailChange) error) {
	http.Handle("POST /webhook", app.WebhookHandler(func(ctx context.Context, d *plugin.Delivery) error {
		switch d.Event.Type {
		case "trail.opened", "trail.updated", "trail.closed", "trail.reopened", "trail.merged":
		default:
			return nil
		}
		change, err := d.Event.TrailChange()
		if err != nil {
			return err
		}
		return saveAndQueue(ctx, d.InstallationID, change)
	}))
}
```

`saveAndQueue` is application code, not an SDK method. In one database transaction, lock the stored trail, compare `change.Trail.UpdatedAt` with the stored timestamp, and ignore older snapshots. Store an accepted snapshot and queue work before committing. Key the record by installation, repository and trail ID. Return storage failures so Entire retries the delivery.

Workers must also serialize work for each trail and check its latest stored state before acting. For a preview service, a closed or merged trail means teardown; an open trail with a head SHA can create or update a preview. This prevents a delayed update or queued job from recreating a preview after closure. Delivery deduplication alone doesn't enforce that order.

The SDK turns a null `branch`, `head_sha`, `from` or `to` into an empty string.

The SDK's default stores keep handled deliveries and seen tokens in memory. Use durable shared `Config.DeliveryStore` and `Config.ReplayStore` implementations across instances and restarts. The handled check, callback and handled marker are separate operations; a marker-write failure is logged but still answers `200`. Use `delivery_id` as a unique queue key to prevent duplicate jobs, and make worker actions idempotent before retrying them.
