Skip to content

Commit 283a61c

Browse files
committed
feat: jira.js/webhooks types what Jira posts to you, and proves the delivery came from Jira
Everything else in this library calls Jira. A webhook is the other direction, and until now there was no way to say what arrives — which is all issue #294 ever asked for. Fifty-seven events as a union discriminated by `webhookEvent`, sixteen payload shapes behind it, and the headers Jira attaches, lower-cased as Node delivers them and typed as the strings HTTP headers are — the retry count included, which the issue typed as a number. There is no parser, deliberately. A webhook body is shaped by the site that sent it — custom fields under generated keys, whatever an installed app adds — so a schema strict enough to be worth having would reject bodies that are perfectly valid elsewhere. `verifyWebhookSignature` is the one thing here that runs, because it is the one claim that can be checked rather than asserted. `x-hub-signature` is HMAC-SHA256 over the exact bytes of the body, and it is what separates a delivery from Jira from a POST anyone who found your URL can make. Nothing is imported to compute it — `crypto.subtle` is a global in Node and browsers alike — so the subpath stays free of Node built-ins and the browser bundle does not move. How much of it Atlassian documents is written into the types rather than glossed over. One complete payload is published, the one for issue events; that group is built from it and from a capture of a real delivery, and is the only one whose entity is required. Every other group names its entity optionally, after the entity the event concerns. Closes #294.
1 parent 46728d2 commit 283a61c

17 files changed

Lines changed: 1160 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,46 @@ Three long-standing requests, all of them the same shape: the client had no seam
4747

4848
### Features
4949

50+
* **`jira.js/webhooks` types what Jira posts to you.** Everything else in this library calls Jira; a webhook is the other direction, and until now there was no way to say what arrives. Fifty-seven events as a union discriminated by `webhookEvent`, sixteen payload shapes, and the headers Jira attaches. Closes [#294](https://github.com/MrRefactoring/jira.js/issues/294).
51+
52+
```ts
53+
import type { WebhookHeaders, WebhookPayload } from 'jira.js/webhooks';
54+
55+
app.post('/jira', (request, response) => {
56+
const payload = request.body as WebhookPayload;
57+
58+
switch (payload.webhookEvent) {
59+
case 'jira:issue_created':
60+
console.log(payload.issue.key);
61+
break;
62+
}
63+
64+
response.sendStatus(200);
65+
});
66+
```
67+
68+
There is no parser: a webhook body is shaped by the site that sent it, custom fields and installed apps included, so a schema strict enough to be worth having would reject bodies that are perfectly valid elsewhere.
69+
70+
**`verifyWebhookSignature` is the one thing here that runs**, because it is the one claim that can be checked rather than asserted. `x-hub-signature` is what distinguishes a delivery from Jira from a POST anyone who found your URL can make, and it is HMAC-SHA256 over the exact bytes of the body.
71+
72+
```ts
73+
import { verifyWebhookSignature } from 'jira.js/webhooks';
74+
75+
app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
76+
const trusted = await verifyWebhookSignature({
77+
body: request.body,
78+
secret: process.env.JIRA_WEBHOOK_SECRET!,
79+
signature: request.get('x-hub-signature'),
80+
});
81+
82+
if (!trusted) return response.sendStatus(401);
83+
});
84+
```
85+
86+
The body must be the bytes that arrived — `JSON.stringify` of a parsed object is a different byte sequence for the same data, and never matches. Every untrustworthy delivery answers `false` alike, whether the header is missing, names another algorithm, or carries a digest of the right shape and the wrong value; only an empty secret throws, being a mistake of yours rather than a failed check. The comparison is constant-time, and `crypto.subtle` is a global in Node and browsers alike, so nothing is imported and the browser bundle is unchanged.
87+
88+
How much is documented is worth stating plainly, because the types say it too. Atlassian publishes one complete payload, the one for issue events; that group is written from it and from a capture of a real delivery, and is the only one whose entity is required. Every other group names its entity optionally, after the entity the event concerns. The headers are lower-cased, as they arrive, and every value is typed as the string an HTTP header is — the retry count included.
89+
5090
* **Request parameter types are importable again.** `CreateIssue`, `GetIssue` and the twelve hundred others appeared in no published declaration file: a surface entry point re-exports `api`, `models` and its factory, and never `parameters`. They now have a subpath of their own:
5191

5292
```ts

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ The documentation includes:
132132
- **Jira Cloud platform API**: issues, projects, users, fields, workflows, schemes
133133
- **Jira Software (Agile) API**: sprint management, boards, backlogs, agile workflows
134134
- **Jira Service Management API**: request handling, queues, customers, organizations
135+
- **Webhook types**: the events, payloads and headers Jira posts to *you*`jira.js/webhooks`, types only, no client
135136

136137
There is one platform surface, generated from Jira's v3 specification. `Version2Client` and `Version3Client` are gone — the difference between them was never the endpoints, it was rich text. Rich-text fields still accept a wiki-markup **string**: that write is routed through Jira's v2 endpoint, which parses the markup server-side, and the result is read back so what you get is a real [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) document.
137138

context7.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"Rich text fields (comment bodies, issue descriptions) accept either an Atlassian Document Format document or a wiki-markup string; a string is routed through Jira's v2 endpoint and read back, so it still formats. Reads always return a document, never a string.",
3333
"Retries are off by default. Opt in with `retry: { maxAttempts, initialDelayMs, backoffFactor }` — it covers network errors and 502/503/504 only, never 4xx (including 429) or other 5xx.",
3434
"`getAttachmentContent` accepts an optional `range` (HTTP Range header, e.g. `bytes=0-1023`) for partial/byte-range downloads, returning 206 Partial Content.",
35-
"For smaller bundles import the flat per-endpoint functions from `jira.js/cloud`, `jira.js/agile` or `jira.js/serviceDesk` and pass the client as the first argument, e.g. `getIssue(client, { issueIdOrKey })`. Those subpaths also carry every parameter and response type. They are not re-exported from the package root because the surfaces collide on some names."
35+
"For smaller bundles import the flat per-endpoint functions from `jira.js/cloud`, `jira.js/agile` or `jira.js/serviceDesk` and pass the client as the first argument, e.g. `getIssue(client, { issueIdOrKey })`. Those subpaths also carry every parameter and response type. They are not re-exported from the package root because the surfaces collide on some names.",
36+
"Webhooks go the other way — Jira posts to a server of yours — so `jira.js/webhooks` is types only and compiles to nothing: `WebhookPayload` is a union of fifty-seven events discriminated by `webhookEvent`, and `WebhookHeaders` types the headers in lower case, as Node delivers them, with every value a string including the retry count. Cast the parsed body and headers; there is no parser and no signature verification, because a webhook body is shaped by the site that sent it. Atlassian documents only the issue payload, so that is the one group whose entity is required — everywhere else the entity field is optional."
3637
],
3738
"url": "https://context7.com/mrrefactoring/jira.js",
3839
"public_key": "pk_KmcNhVEfGYbntvK7UL6DZ"

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const guideSidebar = (prefix = '') => [
1818
{ text: prefix ? 'Установка' : 'Installation', link: `${prefix}/guide/installation` },
1919
{ text: prefix ? 'Аутентификация' : 'Authentication', link: `${prefix}/guide/authentication` },
2020
{ text: 'OAuth 2.0 (3LO)', link: `${prefix}/guide/oauth2-authentication` },
21+
{ text: prefix ? 'Вебхуки' : 'Webhooks', link: `${prefix}/guide/webhooks` },
2122
{ text: prefix ? 'Обработка ошибок' : 'Error Handling', link: `${prefix}/guide/error-handling` },
2223
{ text: prefix ? 'Валидация ответов' : 'Response Validation', link: `${prefix}/guide/response-validation` },
2324
{ text: 'Tree-Shaking', link: `${prefix}/guide/tree-shaking` },

docs/guide/webhooks.md

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Webhooks
2+
3+
Everything else in this library calls Jira. A webhook is Jira calling you: you register a URL, something happens on the
4+
site, and a `POST` arrives at a server of yours. There is nothing here to call and no client to build — what was
5+
missing was the shape of what arrives, and that is what `jira.js/webhooks` is.
6+
7+
```typescript
8+
import type { WebhookHeaders, WebhookPayload } from 'jira.js/webhooks';
9+
10+
app.post('/jira', (request, response) => {
11+
const headers = request.headers as WebhookHeaders;
12+
const payload = request.body as WebhookPayload;
13+
14+
switch (payload.webhookEvent) {
15+
case 'jira:issue_created':
16+
console.log(payload.issue.key, 'created by', payload.user?.displayName);
17+
break;
18+
19+
case 'sprint_started':
20+
console.log(payload.sprint?.name, 'started');
21+
break;
22+
}
23+
24+
response.sendStatus(200);
25+
});
26+
```
27+
28+
The subpath is types only. It compiles to `export {}`, adds nothing to a bundle, and works the same with Express,
29+
Fastify, Hono, a Lambda handler or a bare `node:http` server.
30+
31+
## The payload
32+
33+
`WebhookPayload` is a union discriminated by `webhookEvent`, so a `switch` narrows each branch to exactly one payload.
34+
Handle every case and the `default` narrows to `never`, which is how you make an unhandled event a compile error:
35+
36+
```typescript
37+
default: {
38+
const unhandled: never = payload;
39+
40+
throw new Error(`unhandled webhook event: ${JSON.stringify(unhandled)}`);
41+
}
42+
```
43+
44+
Fifty-seven events across sixteen groups: issue, issue property, worklog, comment, attachment, issue link, issue type,
45+
project, version, filter, user, the site-wide `option_*` toggles, sprint, board, the two app-access refusals, and a
46+
failed Jira expression. Each group has its own exported type — `IssueWebhookPayload`, `SprintWebhookPayload` and so on
47+
— if you want to name one directly.
48+
49+
Every payload carries three things:
50+
51+
| | |
52+
|---|---|
53+
| `timestamp` | when Jira raised the event, in milliseconds since the epoch |
54+
| `webhookEvent` | the event, and the field to switch on |
55+
| `matchedWebhookIds` | which registrations this delivery answered — only on webhooks registered through the REST API |
56+
57+
## What is documented, and what is not
58+
59+
Worth knowing before you trust a field: **Atlassian publishes one complete payload**, the one for issue events. Of
60+
everything else it says only that a callback carries "information about the entity associated with the event".
61+
62+
So the issue payload here is written from that example and from a capture of a real delivery. It is the only one with
63+
a required entity:
64+
65+
```typescript
66+
case 'jira:issue_updated':
67+
payload.issue; // Issue — always there
68+
payload.issue_event_type_name; // 'issue_updated', 'issue_commented', 'issue_generic'…
69+
payload.changelog; // what changed
70+
payload.comment; // set when the update was someone commenting
71+
break;
72+
```
73+
74+
`issue_event_type_name` is finer-grained than `webhookEvent` — an edit, a comment and a transition all arrive as
75+
`jira:issue_updated` and are told apart only there. It is typed as a plain string on purpose: a site administrator can
76+
add issue events, so the set is not closed.
77+
78+
Every other group names its entity **optionally**, after the entity the event concerns rather than from a
79+
specification: `sprint` on a sprint event, `board` on a board event, `worklog`, `attachment`, `project`, `version`,
80+
`filter`. Nothing here could verify those against Atlassian's documentation, so the type makes you check, and the
81+
declaration says as much where you hover it.
82+
83+
## The headers
84+
85+
Lower-cased, because that is how they arrive — Node lower-cases every incoming header name and so does every framework
86+
built on it. Every value is a string, including the retry count: an HTTP header has no numbers in it.
87+
88+
| header | |
89+
|---|---|
90+
| `x-atlassian-webhook-identifier` | unique for this delivery within the site, and unchanged across retries — record it to recognise a webhook you have already handled |
91+
| `x-atlassian-webhook-flow` | `Primary` for the event itself, thirty seconds; `Secondary` for the fallout of a bulk or cascading change, fifteen minutes |
92+
| `x-atlassian-webhook-retry` | how many retries so far; absent on the first attempt |
93+
| `x-atlassian-webhook-trace` | whatever a Connect app attached to the request that caused the event |
94+
| `x-hub-signature` | `sha256=…`, present only on a webhook registered with a secret — pass it to `verifyWebhookSignature` |
95+
96+
Deleting an issue is the clearest illustration of the flow header: `jira:issue_deleted` goes out as `Primary`, and
97+
every dependent `comment_deleted`, `attachment_deleted` and `issuelink_deleted` follows as `Secondary`, possibly
98+
minutes later.
99+
100+
## There is no parser
101+
102+
The casts above are the interface, deliberately. A webhook body is shaped by the site that sent
103+
it — custom fields under generated keys in `issue.fields`, whatever an installed app adds, a Data Center release that
104+
differs from Cloud — and a schema strict enough to be worth having would reject bodies that are perfectly valid
105+
somewhere else. Elsewhere in this library a response is validated because the API documents it; here there is nothing
106+
to validate against.
107+
108+
## Verifying the signature
109+
110+
`x-hub-signature` is the only thing that proves a request came from Jira rather than from whoever found your URL. A
111+
webhook registered with a secret carries it as `sha256=<hex>`, over HMAC-SHA256 of the exact bytes of the body.
112+
113+
```ts
114+
import express from 'express';
115+
import { verifyWebhookSignature, type WebhookPayload } from 'jira.js/webhooks';
116+
117+
app.post('/jira', express.raw({ type: 'application/json' }), async (request, response) => {
118+
const trusted = await verifyWebhookSignature({
119+
body: request.body,
120+
secret: process.env.JIRA_WEBHOOK_SECRET!,
121+
signature: request.get('x-hub-signature'),
122+
});
123+
124+
if (!trusted) return response.sendStatus(401);
125+
126+
const payload = JSON.parse(request.body.toString()) as WebhookPayload;
127+
128+
response.sendStatus(200);
129+
});
130+
```
131+
132+
**The body must be the bytes that arrived.** This is where the check usually goes wrong: `express.json()` and every
133+
equivalent hand you a parsed object, and `JSON.stringify` of that object is a different byte sequence for the same
134+
data — key order, whitespace and number formatting are not preserved — so the signature will never match. Reach for
135+
whatever your framework calls a raw body.
136+
137+
The answer is `false` for every way a delivery can fail to be trustworthy: no header, an algorithm other than
138+
`sha256`, a digest that is not hexadecimal, a digest of the right shape and the wrong value. Your response to all four
139+
is the same, and distinguishing them would distinguish them for whoever is probing the endpoint too. The one thing it
140+
throws on is an empty secret, which is a mistake of yours rather than a failed check.
141+
142+
The comparison is constant-time, and nothing is imported to do any of it: `crypto.subtle` is a global in Node and in
143+
browsers alike, so this subpath still adds nothing to a browser bundle.
144+
145+
## Registering one
146+
147+
Two ways, and they behave differently:
148+
149+
- **The admin page**, `https://your-domain.atlassian.net/plugins/servlet/webhooks`. What most people mean by a Jira
150+
webhook. Registered by a person, lives until someone removes it.
151+
- **The REST API**, `POST /rest/api/3/webhook` — Connect and OAuth 2.0 apps only, and the registration expires after
152+
thirty days unless `refreshWebhooks` extends it. These are the deliveries that carry `matchedWebhookIds`, and this
153+
library covers the endpoints: `jira.webhooks.registerDynamicWebhooks`, `getDynamicWebhooksForApp`,
154+
`refreshWebhooks`, `deleteWebhookById`.
155+
156+
Atlassian's own reference is
157+
[Webhooks](https://developer.atlassian.com/cloud/jira/platform/webhooks/) on the Jira Cloud platform.

0 commit comments

Comments
 (0)