Events
Developers

Events

Everything an app can be told about: the envelope, the full catalog, ordering, retries and what each body carries.

Events

One stream covers the whole lifecycle of a piece of work: it is described, it is worked on, code is pushed, a pull request is reviewed, it ships. Both transports deliver the identical body, so nothing on this page depends on which one you chose.

The envelope

Every delivery, on every transport, is this shape:

json { "event": "ticket.status_changed", "event_id": "8f14e45f-ea1a-4f3f-9c1c-2b3d4e5f6a7b", "organization_id": "9d1c8a2e-0f77-4a55-8e21-6b0c4d9f1a33", "project_id": "3b7c1f90-51de-4a2b-a0c9-77e2a1b4c5d6", "data": { } }

Field Meaning
event The event name, from the catalog below. Frozen — never renamed, never repurposed.
event_id Shared by every delivery caused by one action. Use it to correlate; never to deduplicate.
organization_id The workspace this happened in.
project_id The project, or null for workspace-wide events.
data The event-specific body.

data is bounded on purpose. It carries enough to decide whether you care — identifiers, names, statuses — and not the free text a person typed. Descriptions and comment bodies are unbounded and can contain anything, so they are not pushed at you; fetch them over the API if you need them. That fetch is also the reconciliation path described below, so you want it in your app anyway.

New fields appear in data without notice. That is explicitly allowed by the compatibility promise: existing fields keep their name, type and meaning, and new ones are added beside them. Ignore keys you do not recognise. An app that rejects bodies it does not fully understand will break on a release nobody told it about.

What is deliberately absent

There is no prompt_context field, and its absence is a decision rather than an oversight.

The idea was to ship pre-assembled agent context in the event so that a third-party agent would not have to re-derive it over the API. We are not doing that in v1, for two reasons. It is unbounded free text, which is the exact category data excludes above. And text assembled by us, delivered to you, and fed to a model is a prompt-injection carrier that neither side can audit.

If it ever ships it arrives as a new key inside data, which the compatibility promise already permits without notice. Nothing you build against today's envelope breaks when that happens.

Shared shapes

These recur across families, so they are described once.

A person or app. Never an email address.

json { "id": "b6e2…", "username": "ada", "display_name": "Ada Lovelace", "is_app": false }

is_app tells you whether the actor was a person or another integration — useful, because reacting to your own writes is the most common way an app ends up in a loop with itself.

A project. {"key": "JUJU", "name": "JuhJuh"}.

A repository. Identified by its address, <workspace>/<project>/<name>, plus the URL you would clone. Never by where its bytes are stored — storage layout is not something you can act on and it changes without being a breaking change.

Nullable references are null, not omitted and not "". An unassigned ticket has "assignee": null.


The catalog

Fifteen events. An event name is frozen the moment it ships: new ones are added, existing ones are never renamed or given a new meaning.

Tickets

Event Fires when
ticket.created A new ticket is created in a project.
ticket.status_changed A ticket moves from one status to another.
ticket.assigned A ticket's assignee changes.
ticket.delegated An app is recorded as acting on a ticket, leaving its assignee intact.
ticket.commented Someone comments on a ticket.

Every ticket event carries the same ticket and project objects:

json { "ticket": { "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", "key": "JUJU-412", "title": "Retry failed deliveries", "status": "completed", "type": "bug", "priority": "high", "assignee": { "id": "b6e2…", "username": "ada", "display_name": "Ada Lovelace", "is_app": false }, "delegate": null, "url": "/acme/tickets/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d/" }, "project": { "key": "JUJU", "name": "JuhJuh" } }

title is truncated to 200 characters — it is a label, not the content. There is no description; read the ticket for that.

ticket.status_changed adds the transition:

json "transition": { "from_status": "running", "to_status": "completed" }

You get every transition, including intermediate ones a human-facing notification would suppress. An app that only cares about a few should filter on to_status rather than assume the stream is already filtered for it.

ticket.delegated is worth understanding: delegation records that an app acted on a ticket without taking it away from the person it is assigned to. assignee stays who it was; delegate is the app.

Chat

Event Fires when
chat.message_created Someone posts a message in a channel your app can see.
chat.reaction_added Someone reacts to a message.

data carries the message (its id, its author, its channel, and enough to fetch it) and the channel it landed in. Your app only receives these for channels it has been added to — being subscribed is not the same as having access, and access is what actually decides.

Two things to get right:

  • Your own messages come back to you. Check is_app on the author, or compare the author id to the one in me(), before replying. Skipping this is the classic way to build an app that talks to itself forever.
  • Do not treat a message event as the message. Bodies can be edited and deleted after the fact.

Repositories

Event Fires when
repo.push Commits are pushed to a branch.
repo.pull_request A pull request is opened, closed, merged, reviewed or commented on.
repo.ci_build A build is queued, started or finished.

data names the repository by address, the ref that changed, and what it changed to. repo.pull_request and repo.ci_build carry an action or a state inside data — one event name covers the family so that subscribing does not have to be re-done every time a new sub-action is added.

Because pushes arrive in whatever order they are delivered, never reconstruct history from the stream. Treat a push event as "this branch moved, go look".

Container registry

Event Fires when
registry.image_pushed A container image is pushed to a project's registry.
registry.image_deleted A container image is deleted from a project's registry.

data names the image by its registry address (<org>/<project>/<image>), the tag, the digest and the media type. There is no repository.name: an image belongs to the project's registry, not to any one source repository.

These are the only honest signals that an image was published or removed — they come from the registry itself on a confirmed operation, so an aborted or rejected push never fires one. A multi-arch push emits one event, not one per manifest.

Push and delete are separate names rather than one event with an action, so an app that only cares about publishes does not have to receive removals and filter them out.

Branch on the digest, not the tag, if you care that the bytes actually changed — a tag can be moved onto an image you have already seen.

Pull request reviews

Event Fires when
pull_request.review_requested Someone is asked to review a pull request.
pull_request.review_submitted A review is approved, rejected or commented.

data carries the pull_request (number, title, state, URL, its project and repository) and the review: who was asked, or who submitted what.

These are separate from repo.pull_request because reviews are the part most integrations actually want — a reminder bot needs to know a review was requested and does not care that a branch moved.

Agents

Event Fires when
agent.run_completed An agent finishes working on a ticket.
agent.run_failed An agent stops without completing its work.

data carries the run (its id, the agent, timing, outcome) and the ticket it was working on, in the same shape as the ticket family above.

agent.run_failed is the interesting one for an integration: it is where a ticket needs a human, which makes it the natural trigger for a page, a comment, or a status update somewhere else.

Workflows

Event Fires when
workflow.run_completed A workflow run reaches a terminal state.

data carries the run and its outcome. "Terminal" includes the unhappy endings — succeeded, partly done, failed, stopped — so branch on the outcome rather than assuming completion means success.


Delivery semantics

At-least-once and unordered. Both halves of that sentence have teeth.

Deduplicate on the delivery id

Every attempt carries a header:

Header Unique per Use it for
X-JuhJuh-Delivery-Id delivery — stable across every retry of it deduplication
X-JuhJuh-Event-Id action correlation

They are not interchangeable, and swapping them is a real bug in both directions:

  • Deduplicating on the event id drops events you wanted. One action can produce several deliveries — to your app and to someone else's, to several of your subscriptions — and they all share it deliberately.
  • Correlating on the delivery id joins nothing. It is unique to one delivery to one subscription, so it never matches anything else — which is exactly what makes it a sound dedupe key and a useless correlation key.

On the socket transport the same values are in the frame rather than in headers.

Assume no order

A ticket's created and its first status_changed can arrive in either order. Two status changes can arrive reversed. There is no sequence number, and adding one would not help: the second event might simply be the one that got lost.

So: use an event as a trigger, and read the current state from the API.

python @app.on("ticket.status_changed") async def moved(event): key = event.data["ticket"]["key"] current = api.ticket_by_key(key) # what is true now ...

Retries, and when we stop

Webhook deliveries retry on 5xx, 429, timeouts and connection failures: 8 attempts over roughly 4 hours, doubling from 30 seconds. A 4xx other than 429 is not retried — that is your endpoint saying it rejected the body, and repeating it eight more times helps nobody.

A subscription that fails continuously for 24 hours is switched off, with the reason shown on the workspace's Apps & Bots page. The window is measured from the first failure of the current streak, so an endpoint that fails intermittently is never disabled — only one that is genuinely gone.

The socket transport has no retry and no acknowledgement deadline. A frame sent while your app is disconnected is missed, full stop. That is not a gap to work around, it is the reason the next section exists.

Reconcile against the API

The stream is a notification layer. The API is the source of truth.

An app that stores state purely from events will drift: it will miss a frame during a deploy, apply two out of order, or double-apply a retry. The fix is the same in all three cases — when an event says something changed, fetch the thing and use what comes back.

Where an app must survive a long outage, do a sweep on startup: list the tickets or pull requests you care about, updated since you last saw them, and catch up. Then let the stream keep you current.

Subscribing

python api.create_subscription( events=["ticket.status_changed", "pull_request.review_requested"], transport="socket", )

  • Name every event. An empty list means no events, never all of them.
  • Scope to a project with project_key= when you only care about one. Left off, you get the whole workspace.
  • events scope is required to hold a socket or receive any delivery. If it is removed from your app later, existing subscriptions stop firing without being deleted.
  • Subscribing to an event you have no other scope for gets you the event but not the ability to fetch its detail — grant the read scope for anything you intend to reconcile.