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_appon the author, or compare the author id to the one inme(), 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. eventsscope 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.