> ## 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 trail events

> Get a webhook when a trail opens, changes, closes, reopens or merges, and verify each delivery.

Entire sends your webhook URL an event when a trail opens, changes, closes, reopens or merges on a repository your plugin is installed on. A trail can open on a branch that already exists, without a push, so these events are the only way to see every trail. Build per-trail work, such as deploy previews, on them, and [comment on the trail](https://docs.entire.com/guides/api/plugins/trail-comments.md) to report the results.

> [!NOTE]
>
> Trail events 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.
* **`contents:read` or `contents:write`.** A trail event names a branch and a title, so Entire sends trail events only to plugins that can read contents. Without either permission you still receive ref and installation events, but no trail events, and nothing tells you so.
* **An active installation.** You receive events for the repositories an installation covers, while it's active. They stop when the installation is suspended or deleted.

Trail events arrive on the same URL as ref and installation events.

## Events

| 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.
* Any change to a trail that is already closed or merged.

A trail opened before trail events launched on 19 October 2026 never sends `trail.opened`. Its first event is its next change.

## 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`. Key builds and check runs on the commit SHA, and start at most one build per SHA.
* **`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. Verify the signature with the key the header's `kid` names, from the delivery JWKS URL Entire gave you at registration, today `https://us-east-2.entire-ci-webhooks.entire.io/.well-known/jwks.json`. Every region signs with the same key set. Cache it, and fetch it again when you see an unknown `kid`. Don't fetch keys from a URL built from `iss`.
4. Check that `iss` is `https://entire.io` or an `https` origin on a host under `entire.io`, such as `https://us-east-2.entire-ci-webhooks.entire.io`.
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`.

Answer a request that fails verification with 401: Entire doesn't retry a 4xx. 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.

## With the Go SDK

`App.WebhookHandler` verifies each delivery, skips deliveries it has already handled, and answers as described above. Decode a trail event with `Event.TrailChange`:

```go theme={null}
func webhooks(app *plugin.App) {
	http.Handle("POST /webhook", app.WebhookHandler(func(ctx context.Context, d *plugin.Delivery) error {
		change, err := d.Event.TrailChange()
		if err != nil {
			return nil // not a trail event
		}
		switch d.Event.Type {
		case plugin.EventTrailClosed, plugin.EventTrailMerged:
			tearDownPreview(change.Trail.ID)
		default:
			if change.Trail.HeadSHA != "" {
				enqueuePreview(d.InstallationID, d.APIURL, change.Trail.ID, change.Trail.HeadSHA)
			}
		}
		return nil
	}))
}
```

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, which protects a single process. If you run more than one instance, set `Config.DeliveryStore` and `Config.ReplayStore` to stores shared across instances, such as your database.
