---
title: "Events"
description: "How forges deliver changes, and how providers turn them into one event type."
canonical_url: "https://forges.link/concepts/events"
---
# Events

> How forges deliver changes, and how providers turn them into one event type.

A `ForgeEvent` describes something that happened on a forge, such as a comment, a push, a review or a label change. Forges tell you about these changes in different ways, so providers produce events from three sources:

<table>
<thead>
  <tr>
    <th>
      Source
    </th>
    
    <th>
      How it works
    </th>
    
    <th>
      Capability
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        webhook
      </code>
    </td>
    
    <td>
      The forge sends an HTTP request to your server.
    </td>
    
    <td>
      <code>
        sources.webhook
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        poll
      </code>
    </td>
    
    <td>
      You list notifications or activity feeds on a schedule.
    </td>
    
    <td>
      <code>
        sources.poll
      </code>
      
      , <code>
        repos.events
      </code>
      
      , <code>
        users.events
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        subscribe
      </code>
    </td>
    
    <td>
      You keep a connection open and receive events as they happen.
    </td>
    
    <td>
      <code>
        sources.subscribe
      </code>
    </td>
  </tr>
</tbody>
</table>

Whichever source an event came from, `event.source` tells you. Most forges support webhooks, and every forge that lists notifications can also be polled, so `sources.poll` follows `notifications.list`. Activity feeds have capabilities of their own. Tangled is the exception that offers `subscribe`, through Jetstream.

## Shape of an event

An event has the same fields whatever its source:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Content
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        kind
      </code>
    </td>
    
    <td>
      What kind of thing happened, such as <code>
        comment
      </code>
      
      , <code>
        review
      </code>
      
      , <code>
        state_change
      </code>
      
      , <code>
        label
      </code>
      
      , <code>
        push
      </code>
      
       or <code>
        release
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        action
      </code>
    </td>
    
    <td>
      The verb, such as <code>
        opened
      </code>
      
      , <code>
        closed
      </code>
      
      , <code>
        merged
      </code>
      
      , <code>
        created
      </code>
      
       or <code>
        labelled
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        summary
      </code>
    </td>
    
    <td>
      One line that suits a list row
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        actor
      </code>
      
      , <code>
        repo
      </code>
      
      , <code>
        thread
      </code>
    </td>
    
    <td>
      Who did it, and where, when the event has them
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threadSnapshot
      </code>
    </td>
    
    <td>
      What the source reports about <code>
        thread
      </code>
      
      , such as its title, state and labels
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        isPublic
      </code>
    </td>
    
    <td>
      Whether anyone can see the event, when the forge says. Activity feeds on GitHub, Forgejo and Gitea report it
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        detail
      </code>
    </td>
    
    <td>
      Typed fields for the kind, discriminated by <code>
        detail.type
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        occurredAt
      </code>
    </td>
    
    <td>
      When the change happened, as a <code>
        Date
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        undated
      </code>
    </td>
    
    <td>
      <code>
        true
      </code>
      
       when the forge reported no time for the event
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        payload
      </code>
    </td>
    
    <td>
      The unnormalised delivery
    </td>
  </tr>
</tbody>
</table>

To see which kinds and actions each forge delivers, and which fields each `detail.type` has, look at the [events reference](https://forges.link/reference/events).

## When an event happened

`occurredAt` comes from the forge's own timestamps, not from the time the event reached you. The source decides which timestamp that is:

- A webhook event takes the time of the change itself where the payload has one: `merged_at` for a merge, the comment's `updated_at` for an edited comment, the review's `submitted_at` for a review. Without one, it takes the time the thread or repository last changed, unless the event is a deletion. The [events reference](https://forges.link/reference/events#event-times) lists what each forge sends.
- A feed event takes the time the forge added it to the feed.
- A timeline event from `threads.events()` takes the time of the entry.
- A Tangled event from `subscribe` takes the time in its record. A record without one is undated, at the time Jetstream relayed it.

Some payloads carry no time at all, such as a GitHub ping or a Bitbucket repository transfer, and a deletion carries no time of its own. The event then has `undated: true`, and `occurredAt` is the time the event reached `forges`, or the Unix epoch for an entry of a thread's timeline. If you order events last-writer-wins, or match a webhook delivery against the same change from a poll by `occurredAt`, compare times only when neither event is undated:

```ts
import type { ForgeEvent } from 'forges'

function isNewer(event: ForgeEvent, stored: ForgeEvent): boolean {
  if (event.undated || stored.undated) {
    return true
  }
  return event.occurredAt > stored.occurredAt
}
```

With this check, a deletion replaces whatever you stored, but any later event replaces the deletion in turn, including a redelivered edit of the deleted comment. Keep a deleted record as a tombstone, and drop the events that arrive for it afterwards.

## Thread snapshots

Most sources say something about the thread an event belongs to, and `threadSnapshot` holds that part, using the field names of `Thread`. A webhook usually embeds the whole issue or pull request, so its snapshot has the title, body, state, labels and URL. An activity feed sends less. A GitLab feed item names only the thread's title, and a GitHub pull request event in a feed has only its branches.

A missing field means the source didn't report it, not that it's empty: `labels: []` says the thread has no labels, and no `labels` says nothing. When you need a field that's missing, read the thread with `threads.get()` or `threads.getMany()`. The values describe the thread when the forge built the payload, which can be later than the event itself. `threads.events()` doesn't fill `threadSnapshot`, because you already have the thread.

## Raw names stay available

The normalised `kind` and `action` are derived from the forge's own event name and verb, and those stay available in `kindRaw` and `actionRaw`. If a forge uses wording that has no equivalent in the shared model, `action` is `other`, and the raw name still tells you what happened.

GitLab needs a special mention here. For some changes to a thread, the event kind comes from the text of a system note and not from a field of its own, which makes it a best guess. For this reason, the GitLab provider reports `eventKinds: 'heuristic'` in its capabilities. If it doesn't recognise a note, `kind` can be `other`, while `kindRaw` and `payload` remain exact.

## Activity feeds

When you want to know what happened in a repository, or what an account did, without registering a webhook, read the forge's activity feed. `repos.events()` lists the activity of a repository, and `users.events()` lists the activity of an account across the repositories that the credential can see. Both return events with `source: 'poll'`, in the order the forge added them to its feed, latest first:

```ts
for await (const event of gh.users.events('octocat')) {
  console.log(event.occurredAt, event.repo?.name, event.summary)
}
```

A feed covers what the forge records in it, which differs by forge. It usually has pushes, new and deleted branches and tags, issues and pull requests that were opened, closed or merged, comments and reviews. A feed event has the same `kind`, `action`, `thread` and `detail` as the webhook delivery for the same change. An activity without a shared kind, such as a sponsorship, has `kind: 'other'`, and `kindRaw` names it.

Feed items are often smaller than webhook deliveries. A GitHub push, for example, can leave out its commits, and a GitLab push names only the newest one, so trust `detail.commitCount` over `detail.commits.length`. When you want the rest, hand the push's `before` and `after` to `contents.compare()`. The provider normalises what the feed contains and doesn't send more requests to fill in the rest. To poll a feed without paying for an unchanged page, see [Polling with conditional requests](https://forges.link/guides/pagination#polling-with-conditional-requests).

## Subscriptions

`sources.subscribe()` returns an async iterable of `{ event, cursor }`. To pick up where you left off after a restart, save the cursor and pass it back when you subscribe again. The provider doesn't reconnect when the connection drops. Instead, it throws `SubscriptionClosedError` with the last cursor, and you call `subscribe()` again:

```ts
for await (const { event, cursor } of reader.sources.subscribe({
  cursor: saved,
  signal,
})) {
  console.log(event.kind, event.summary)
  saved = cursor
}
```

## Event identity

Every event has an `id`, but what it identifies depends on the source:

- A webhook event's `id` is the id of the delivery. GitHub sends it in `X-GitHub-Delivery`, GitLab in `X-Gitlab-Event-UUID`, Forgejo and Gitea in `X-Forgejo-Delivery` or `X-Gitea-Delivery`, Bitbucket in `X-Request-UUID`, Tangled in `X-Tangled-Delivery` and Gitee in `X-Gitee-Delivery`. Azure DevOps and Cursor Origin put it in the body. When the header is missing, the provider makes up an id: a random UUID, or on Gitee the hook name and the timestamp.
- A polled event's `id` is the id of the timeline or feed entry. On GitHub, a thread's timeline uses the entry's node ID, and falls back to its numeric ID or its commit sha.
- A subscribed event on Tangled uses the URI of the record, followed by `#` and the revision when the record is updated or deleted.

When a delivery can become several events, such as a push that updates several refs, the provider adds `:<index>` to the delivery's id, so that each event has an id of its own.

As a result, `event.id` lets you skip a redelivery of the same webhook, or an entry that you polled twice. It doesn't match across sources: when you poll a thread and also receive its webhooks, the same comment arrives twice with two different ids. To tell that they concern the same comment, compare the keys of what they touch instead.

In the same way, a review event names its review in `detail.review`, with the id that `threads.reviews()` lists for it, so a webhook delivery and a timeline entry for the same review agree. GitHub and Cursor Origin report it, as does a Forgejo or Gitea timeline entry. GitLab and Bitbucket list one review for each approver, so their review events name that review: `approval:<username>` on GitLab, and `participant:<uuid>` on Bitbucket.

## Keep a cache up to date

`affectedKeys()` takes an event or a notification and returns the keys of what it concerns. It sends no request:

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Content
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        repos
      </code>
    </td>
    
    <td>
      <code>
        repoKey()
      </code>
      
       of every repository, including the repository of each thread
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads
      </code>
    </td>
    
    <td>
      <code>
        threadKey()
      </code>
      
       of every thread
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        comments
      </code>
    </td>
    
    <td>
      <code>
        commentKey()
      </code>
      
       of a comment, for <code>
        comment
      </code>
      
       and <code>
        review_comment
      </code>
      
       events that name it in <code>
        detail.comment
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        releases
      </code>
    </td>
    
    <td>
      <code>
        releaseKey()
      </code>
      
       of a release
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        notifications
      </code>
    </td>
    
    <td>
      <code>
        notificationKey()
      </code>
      
       of a notification
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        unresolved
      </code>
    </td>
    
    <td>
      Threads without a <code>
        number
      </code>
      
      , which have no key yet
    </td>
  </tr>
</tbody>
</table>

If you cache rows under these keys, drop or read again the rows that a delivery touches:

```ts
import { affectedKeys } from 'forges'

for (const event of await provider.webhooks.ingest(delivery)) {
  const { repos, threads, comments } = affectedKeys(event)
  for (const key of [...repos, ...threads, ...comments]) {
    cache.delete(key)
  }
}
```

A few things to keep in mind:

- A push or a ref event only names its repository. If a pull request's head branch moved, finding that pull request takes a read, such as listing the open pull requests.
- A comment event names its comment in `detail.comment`, with the id that `threads.comments()` lists for it, when the payload carries that id. An Azure DevOps webhook for a work item comment doesn't, and neither does one for a pull request comment whose `_links` leave out the comment's thread. Without `detail.comment`, `comments` is empty, and the key of the comment's thread is in `threads`.
- For a renamed or transferred repository, `repos` also holds the key of the new path and, where the event names it, the old one. The key by the forge's ID stays the same, but rows that you cached under a path are affected too.
- Some threads arrive without a number, so they have no key. A Tangled webhook names a pull request by its display number, unless you pass `resolveDisplayNumber` to the provider. A GitHub notification for a discussion has no number when `notifications.list()` can't match the discussion by its title. Those threads land in `unresolved`. Their repositories are in `repos`, and every thread and comment key starts with the key of its repository and `#`, so you can drop the rows under that prefix instead.
- The keys of two events match when their refs carry the same identity. A provider adds the forge's repository ID to the repository refs that it reads from the forge, such as those of a webhook delivery or of `repos.get()`, and `repoKey()` uses that ID. A thread or an event that you read through a ref keeps the repository ref that you passed in. So start from refs that a provider returned, such as `repo.ref`, and not from refs that you build by hand.

## Next steps

- [Receive webhooks](https://forges.link/guides/webhooks) shows how to handle deliveries in your server.
- The [events reference](https://forges.link/reference/events) lists kinds and actions by forge.
- [Page through results](https://forges.link/guides/pagination) explains polling with cursors.


## Sitemap

See the full [sitemap](https://forges.link/sitemap.md) for all pages.
