|
| 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