# Jira Service Management setup for the Spun helpdesk bridge

Workflow: `spun-jira-service-management-bridge.n8n.json` (import into n8n 1.x).
Reference facts: `API-NOTES.md` in this folder (Atlassian's public developer and support
documentation, read 10/2026, not yet live-tested). General template notes, node map and
the live-test checklist: `../README.md`.

Every Jira shape below is marked **verified** (stated in Atlassian's documentation) or
**assumed** (the documentation is ambiguous or silent; the conservative choice is
taken). Treat the assumed items as the first things to check in the live run.

## 0. What you end up with

| Side | Item |
|---|---|
| Jira | One integration account that is an agent on the service project, a classic API token for it, a "WhatsApp number" text field on one request type, and one admin webhook pointing at n8n. |
| n8n | The imported workflow, two credentials, the Settings node filled in, the workflow active. |
| Spun | An API key with `send_message` (plus `manage_conversations` if you want desk-side close, step 8) and a webhook subscription with a signing secret. |

Who is needed: a **project admin** of the service project for the field, the request
type and the transitions (steps 3 and 5), and a **Jira admin** once, for the webhook
(step 6). The integration account itself only needs to be an agent. Verified
(API-NOTES 1.4).

How Jira Service Management differs from the generic template (resolved here, details
below):

- **No customer accounts (shape A).** JSM customers are keyed by email and have no
  phone attribute, and creating one needs a Jira admin. The integration account
  raises every request (it is the reporter) and the WhatsApp number is stored in a
  text field on the request. The contact lane of the generic template is therefore
  left out. Raising requests on behalf of real customers by email (shape B) is not
  built.
- **Every comment is written by the integration account.** The API has no way to
  author a comment as someone else, so inbound WhatsApp messages are public comments
  by the integration account, prefixed with the customer's name and number.
- **The Spun chat id is not stored in Jira.** The WhatsApp number field is enough:
  Spun's send-message accepts the number as `to`, and the workflow keeps its own
  chat id to request map. The field is **required**. A chat whose number WhatsApp
  hides (a privacy ID) has no number; an optional "Spun LID" field covers it (step 3).
- **Text fields cannot be matched exactly in JQL.** The fallback lookup runs a
  contains search on the number field, then compares the value exactly.
- **Resolve and reopen are transitions with ids that differ per workflow.** You record
  the resolve transition id once (and optionally a reopen transition id). Without a
  reopen id, a message after the request is done opens a **new request**.
- **Jira signs its webhooks** (HMAC-SHA256, hex) and retries failed deliveries, so the
  reply path checks a real signature and dedupes on the comment id.

## 1. Site

Your site URL is `https://<site>.atlassian.net`. Put it, without a trailing slash, into
`jira_base`. The workflow calls two API families under it: `/rest/servicedeskapi/...`
(requests, comments) and `/rest/api/3/...` (search, transitions, issue read). Verified.

## 2. Integration account and API token

Create (or pick) one Atlassian account for the integration, named for example
"Spun WhatsApp". Requirements (verified, API-NOTES 1.4):

- An **agent** on the service project. Agents can add internal comments and run
  agent-only transitions; a customer-only account cannot. Whether the agent seat is
  billed depends on your plan.
- A dedicated account keeps the workflow's own comments recognisable in the request
  history and out of the reply path: the workflow never forwards a comment written by
  this account (`desk_integration_user_id`).

API token:

1. Signed in **as the integration account**, open the Atlassian account security page
   (`https://id.atlassian.com/manage-profile/security/api-tokens`) and choose
   **Create API token** (the classic kind, not "with scopes"). Copy it once.
2. In n8n, create a credential of type **Basic Auth**: user = the integration
   account's email, password = the token. Name it as the workflow expects:
   "Jira API token (Basic Auth: integration account email + API token)".
3. Select it on every `JIRA:` node (HTTP Request, Authentication = Generic Credential
   Type, Basic Auth).

The token stays in **your own n8n**. Spun never receives, stores or asks for it, and no
Spun page holds Jira credentials. Atlassian discourages vendors that collect API
tokens; this setup keeps the token with you. Verified wording basis (API-NOTES 1.1).

Verified: `Authorization: Basic base64(email:api_token)` against the site URL, the
same permissions as the account has in the web UI, works with two-step verification
and SAML, sent without a challenge (n8n's Basic Auth sends it up front). Repeated failed
logins can lock the account out of the API behind a CAPTCHA (response header
`X-Seraph-LoginReason: AUTHENTICATION_DENIED`); sign in once in the browser to clear it.

**Assumed:** scoped API tokens (used against `api.atlassian.com`) were not covered by
the documentation read, so the workflow uses a classic token against the site URL.
Atlassian asks for an expiry date when a token is created; set a reminder to replace
the token before it.

Find the account id: add a temporary HTTP Request node `GET {jira_base}/rest/api/3/myself`
with the credential, run it once, and copy `accountId` into `desk_integration_user_id`.
Verified.

## 3. The "WhatsApp number" field and the request type

1. Jira settings, **Work items** (called Issues on older sites), **Fields**, create a
   field of type **Short text (plain text only)** named "WhatsApp number". Give it a
   context that includes the service project.
2. Pick the request type WhatsApp conversations should use. A dedicated request type
   (for example "WhatsApp conversation") keeps portal forms unchanged. Project
   settings, Request types, open it, and add "WhatsApp number" to its fields. Summary
   and description must be on it as well.
3. Add the field to the project's work item view as well, so agents see the number on
   the request.
4. Find the field id: `GET {jira_base}/rest/api/3/field` lists every field; copy the
   `id` (`customfield_NNNNN`) of "WhatsApp number" into `jira_phone_field_id`.
5. Check the request type accepts it:
   `GET {jira_base}/rest/servicedeskapi/servicedesk/{serviceDeskId}/requesttype/{requestTypeId}/field?expand=hiddenFields`
   must list `customfield_NNNNN` under `requestTypeFields`, and no field other than
   summary and description may be `required: true` (the workflow sends only those, the
   number and the optional Spun LID). Leave "WhatsApp number" itself **not required**
   on the request type: a privacy-ID chat (below) has no number.
6. Optional third field "Spun LID": another **Short text (plain text only)** field, on
   the same request type and work item view, not required. Copy its id into
   `jira_lid_field_id` and check it in the step 5 list too.

The field holds the number in E.164 form (`+15551234567`). The workflow writes it when
it opens a request and reads it in two places: the fallback request lookup (step 4) and
replies on a request the workflow's map does not know (section 6). Verified: single-line
text value format, the field list endpoint and `expand=hiddenFields` (admins only).

**Assumed:**

- *A field missing from the request type.* The documentation does not say whether the
  create call rejects it or drops it silently. Step 5 rules this out before the first
  message.
- *A hidden field.* The field may be hidden on the request form (the documentation says
  so), but whether a value sent through the API is then kept was not confirmed. Keep it
  visible on the WhatsApp request type until the live run checks it.
- *A request type outside every portal group* (not shown on the portal) can still be
  raised through the API. If the create call answers 400 or 403, put the request type
  in a portal group.

**Privacy-ID chats.** WhatsApp hides some customers' numbers behind a privacy ID (an
"LID", chat id `<digits>@lid`); Spun reports such a chat with `peer_phone_e164: null`,
`peer_lid` and `identity_state: lid_unlinked` (`../README.md`, "WhatsApp privacy IDs
(LID)"). The digits are not a phone, so the workflow leaves the WhatsApp number field
empty and the comment prefix reads `(privacy ID <digits>)`. With `jira_lid_field_id`
set, the request carries the digits in Spun LID, the fallback lookup (step 4) searches
that field instead, and an agent reply on a request the map does not know goes to
`<digits>@lid`. Without it, such a chat still gets a request and replies work while
the map knows it, but a reset map opens a new request and an agent reply on an
unmapped request fails with a clear message. When Spun later learns the phone
(`identity_state: lid_linked`), the next request the chat opens carries the number;
the workflow does not edit existing requests.

## 4. How a WhatsApp chat finds its request

In this order, for every inbound message:

1. The workflow's own map (chat id to issue key, in n8n workflow data). A known request
   is read once (`GET /rest/servicedeskapi/request/{key}`): when its status category is
   not done, the message is added as a comment.
2. When the mapped request is done: with `jira_reopen_transition_id` set, the workflow
   runs that transition and adds the message to the same request; without it (or when
   the transition is refused) it moves on to step 3. A deleted request (404) also moves
   on to step 3.
3. `POST /rest/api/3/search/jql` with
   `project = "<key>" AND cf[NNNNN] ~ "\"+15551234567\"" AND statusCategory != Done ORDER BY created DESC`,
   passing `reconcileIssues` with the issue id the map last saw (for a privacy-ID chat
   with no number: the same search on the Spun LID field, skipped when
   `jira_lid_field_id` is not set). The search is a
   tokenised contains match, so the workflow accepts a hit only when the returned field
   equals the number exactly; the newest open match wins.
4. `POST /rest/servicedeskapi/request` creates the request: summary "WhatsApp: " plus
   the first line (80 characters), description = the first message with the
   `[WhatsApp] Name (+number):` prefix, and the number in the field.

A failed read (other than 404) or a failed search fails the execution instead of
opening a duplicate request. Verified: the search endpoint, text fields supporting `~`
only, `reconcileIssues` (at most 50 ids), the search lag of seconds without it, the
create body and its 201 response `{issueId, issueKey, currentStatus, _links}`, the
status categories `UNDEFINED`, `NEW`, `INDETERMINATE`, `DONE` (compared
case-insensitively against done, never on status names). The old `/rest/api/3/search`
is deprecated; the workflow uses `/search/jql` only.

## 5. Resolve and reopen transitions

Transition ids differ per workflow and project. Find them once on a test request
created by the workflow (or by hand with the WhatsApp request type):

1. `GET {jira_base}/rest/api/3/issue/{key}/transitions?expand=transitions.fields`
   lists the transitions available from the request's current status, each with
   `to.statusCategory.key`.
2. **Resolve:** the transition whose `to.statusCategory.key` is `done`. Copy its `id`
   into `jira_resolve_transition_id`. If its `fields` contain `resolution` with
   `required: true`, put a resolution name (for example `Done`) into
   `jira_resolution_name`; otherwise leave that setting on its placeholder, because
   sending a resolution the screen does not have can fail with 400.
3. **Reopen (optional):** resolve the test request, call the list again, and pick a
   transition whose target category is not `done`. Copy its id into
   `jira_reopen_transition_id`, or leave the placeholder to open a new request after a
   request is done.

The transition must be available from every status the request can be in when Spun
closes it. A global transition ("from any status") is the simplest. When Jira refuses
(400: not available from the current status, or a required field on the screen), the
request stays open in Jira and in the workflow's map, and the execution shows Jira's
answer. Verified: the list and do endpoints, 204 on success, the 400, 404, 409, 413 and
422 errors. **Assumed:** none beyond the per-site ids.

## 6. The Jira webhook (agent replies to n8n)

1. Replace `PASTE-A-LONG-RANDOM-SUFFIX` in the **Helpdesk reply** node's path with a
   long random string, keeping the `jira-reply-` prefix. The router matches the prefix,
   so no other node changes.
2. Activate the workflow and copy the production URL of **Helpdesk reply**
   (`.../webhook/jira-reply-<your suffix>`). It must be HTTPS with a publicly trusted
   certificate on an allowed port (443 works; port 80 is not allowed).
3. As a Jira admin: Jira settings, **System**, **WebHooks**, **Create a WebHook**.
   - Name: "Spun WhatsApp replies"; status Enabled; URL from step 2.
   - **Secret:** generate one and paste it into `jira_webhook_secret`. It cannot be
     read back later.
   - **Issue related events**, JQL: `project = <your project key>`. Never leave it
     empty: an empty filter sends comments from every project on the site to n8n.
   - Events: **Comment: created**. Add **Issue: updated** only for desk-side close
     (step 8).
   - Leave "Exclude body" unticked.

The same webhook through the API, for reference (basic auth as a Jira admin):

```json
POST {jira_base}/rest/webhooks/1.0/webhook
{"name": "Spun WhatsApp replies", "url": "https://n8n.example.com/webhook/jira-reply-<suffix>",
 "events": ["comment_created"],
 "filters": {"issue-related-events-section": "project = SUP"},
 "excludeBody": false, "secret": "<random>"}
```

**Authenticity (verified).** Jira signs every delivery: `X-Hub-Signature` =
`sha256=` plus the lowercase hex HMAC-SHA256 of the raw body with the secret. The
**HMAC of reply body** node computes it, the adapter compares it in constant time and
drops anything that does not match. While `jira_webhook_secret` or
`desk_integration_user_id` is still a placeholder, everything is dropped. Atlassian's
test vector (secret `It's a Secret to Everybody`, body `Hello World!`, result
`a4771c39fbe90f317c7824e83ddef3caae9cb3d976c214ace1f2937e133263c9`) was checked
against the same computation.

**Delivery (verified).** A 2xx answer is success; the Helpdesk reply node answers 200
on receipt. Jira retries 408, 409, 425, 429, 5xx, timeouts and connection failures up
to 5 times, 5 to 15 minutes apart, with the same `X-Atlassian-Webhook-Identifier`. The
workflow passes `"jsm-" + comment id` as the idempotency key to Spun, so a retried
delivery is never sent to WhatsApp twice. Webhooks that failed for good are listed by
`GET /rest/api/3/webhook/failed`. The workflow does not poll for missed replies.

**What gets forwarded.** The filter cannot be limited to public comments in Jira, so
the workflow drops, in this order: comments from another project, comments that are
not public (`jsdPublic` false), comments not written by a licensed Atlassian account
(`accountType` `customer` is a portal or email reply by a customer, `app` is an app or
automation), comments by the integration account, comments that start with the inbound
prefix, empty comments and comments already forwarded.

**Assumed:**

- *Comment body format.* The payload's `comment.body` may be a plain string or an
  Atlassian Document Format object; `jsdPublic` is part of the comment shape but not
  shown in a webhook example. When the body is not a string or `jsdPublic` is missing,
  the workflow reads the comment through
  `GET /rest/servicedeskapi/request/{key}/comment/{id}` (string body, authoritative
  `public` flag) before deciding. A comment whose visibility stays unknown is never
  forwarded.
- *Markup.* Agent comments arrive as Jira text markup. Bold (`*text*`) and italic
  (`_text_`) happen to match WhatsApp's own; a link with link text arrives as
  `[text|https://...]` and a mention as `[~accountid:...]`.

### Not built: Automation rule or polling

Jira Automation's "Send web request" action can deliver the same signal without a Jira
admin (a project admin can build the rule, and it can filter `{{comment.internal}}` =
false inside Jira). It is not used because:

- it is **unsigned** and has no documented retry or delivery id; the workflow's adapter
  rejects unsigned calls, so using it means replacing the signature check with a
  hidden shared-secret header check;
- it **costs Automation steps** on every comment, internal ones included (about 3 to 4
  steps per comment), from the organization's monthly allowance (Free 1,250 per
  subscription, Standard 3,000 per user, Premium 6,500 per user, Enterprise 9,500 per
  user); flows stop at the allowance unless extra usage is enabled (charged from
  December 3, 2026).

A polling loop (`updated >= -5m` search plus a comment list per hit) is the last
resort and is not built either. Verified figures (API-NOTES 8.1, 8.4).

## 7. Customer notifications (check once)

The integration account is the reporter of every WhatsApp request, so Jira's customer
notifications for those requests (public comments, status changes) go to that
account's email, not to the customer. Point the account at a mailbox nobody needs, or
turn those notifications off for the WhatsApp request type in Project settings,
Customer notifications. **Assumed** (the notification rules differ per project and
were not part of the API documentation); check it in the live run.

## 8. Desk-side close and reopen (optional)

When `close_in_spun_when_desk_closes` is true, resolving a request in Jira archives the
conversation in Spun through `POST /api/integrations/conversations/close` (reason
`closed_in_jira`), and moving it out of a done status calls `/reopen` (reason
`reopened_in_jira`). Add the **Issue: updated** event to the webhook from step 6.

The adapter acts only on updates whose changelog contains a status change, and reads
the new status category from `issue.fields.status.statusCategory.key`: `done` closes,
anything else reopens. Every other update (field edits, comments) is dropped. The
event name `jira:issue_updated` and its `changelog` are **assumed** from the platform's
webhook model; API-NOTES did not read that payload, so turn this on after the live run.
Issue: updated fires on every change to every request in the project, so it multiplies
webhook deliveries (each runs one short n8n execution; Jira deliveries cost nothing on
the Spun side).

Requirements and caveats:

- The Spun API key needs the `manage_conversations` scope ("Close & Reopen
  Conversations" under Manage API Keys). A 403 from Spun means the key lacks it; a 404
  means no conversation matched.
- The workflow finds the conversation through its own request map. A request resolved
  after the workflow's data was reset is not closed in Spun until a new message for it
  has passed through.
- The workflow's own transitions (from a Spun close or reopen) also fire Issue: updated;
  the map already holds the new status by then, so nothing is sent back.
- Spun to Jira always works once `jira_resolve_transition_id` is set:
  `conversation.closed` runs the resolve transition, `conversation.reopened` runs the
  reopen transition when one is set. The map changes only when Jira answers 2xx.

## 9. Spun side

1. Webhooks page, Manage API Keys: create a key with `send_message` (and
   `manage_conversations` for step 8). Copy it once; create the n8n Header Auth
   credential `Authorization` = `Bearer wap_...` and select it on the three `SPUN:`
   nodes.
2. Webhooks page: create a subscription to the production URL of the **Spun events**
   node with the events `message.inbound`, `message.outbound`, `conversation.closed`,
   `conversation.reopened`, payload template **raw**, and a signing secret. Paste the
   secret into `spun_webhook_secret`.
3. Send a WhatsApp message to the line from another phone and follow the Delivery Log
   (Webhooks page) and the n8n executions list. The live-test checklist in
   `../README.md` lists the expected result of every step.

## 10. Settings node, all values

| Key | Value |
|---|---|
| `spun_api_base` | `https://api.spun.com/api/integrations` |
| `spun_webhook_secret` | the Spun subscription's signing secret (step 9) |
| `jira_base` | step 1 |
| `jira_service_desk_id` | `GET {jira_base}/rest/servicedeskapi/servicedesk` lists `{id, projectKey, projectName}`; the `id` of your service project |
| `jira_request_type_id` | `GET {jira_base}/rest/servicedeskapi/servicedesk/{serviceDeskId}/requesttype`; the `id` of the request type from step 3 |
| `jira_project_key` | the `projectKey` from the same list, for example `SUP` (bounds the search and the reply path) |
| `jira_phone_field_id` | step 3, `customfield_NNNNN`; required in Settings (the field itself is not required on the request type) |
| `jira_lid_field_id` | step 3.6, optional `customfield_NNNNN` of the Spun LID field |
| `jira_resolve_transition_id` | step 5; without it, closing in Spun leaves the request alone |
| `jira_resolution_name` | step 5, optional |
| `jira_reopen_transition_id` | step 5, optional |
| `jira_webhook_secret` | step 6 |
| `desk_integration_user_id` | step 2 (`GET /rest/api/3/myself`, `accountId`) |
| `skip_groups` | `true`. Groups and newsletters always end at "Skipped (group or newsletter)"; privacy-ID chats are handled (step 3) |
| `inbound_prefix` | `[WhatsApp]` (also the echo filter) |
| `outbound_prefix` | `[Sent from Spun]` (internal comments) |
| `desk_close_reason`, `desk_reopen_reason` | `closed_in_jira`, `reopened_in_jira` (loop guard for step 8) |
| `close_in_spun_when_desk_closes` | `false` until step 8 is set up and its first close is checked |
| `dedupe_hours` | `24` |

## 11. Message mapping

| Spun | Jira Service Management |
|---|---|
| First `message.inbound` of a conversation | `POST /rest/servicedeskapi/request`: summary "WhatsApp: " plus the first line (80 characters), description `[WhatsApp] Name (+number): text` (`(privacy ID <digits>)` for a privacy-ID chat with no known phone), the number in the WhatsApp number field (or the digits in the optional Spun LID field). Reporter = the integration account. |
| Later `message.inbound` | Public comment by the integration account on the open request: `[WhatsApp] Name (+number): text`. |
| Message after the request is done | With a reopen transition: the request is reopened and gets the comment. Without one: a new request. |
| Media | The same comment with the file type, size and a link valid for 24 hours. Not uploaded as a Jira attachment. |
| `message.outbound` sent from the Spun inbox | Internal comment `[Sent from Spun] Agent name: text`. |
| `conversation.closed` / `reopened` | Resolve transition / reopen transition. |
| Public comment by an agent in Jira | WhatsApp text message to the customer; the comment id (`jsm-<id>`) is the idempotency key. |
| Internal comment, comment by a customer, an app or the integration account | Not sent. |

Comment bodies are sent as plain text through the JSM API (`{"body": "...", "public":
true|false}`; `public` is required). Jira may render text that looks like its markup.
Verified (API-NOTES 5.1); which characters render is **assumed** harmless until the
live run.

## 12. Jira API usage and limits (verified figures)

- A new conversation costs 2 requests (search, create); a follow-up message 2 (status
  read, comment); a message after the request is done 3; a message sent from the Spun
  inbox 1; a close or reopen 1. An agent reply costs nothing in Jira when the payload
  carries a plain body and `jsdPublic`, plus 1 comment read when it does not and 1
  issue read when the workflow's map lost the request.
- **Per-request writes: 20 per 2 seconds and 100 per 30 seconds.** Every comment is a
  write on the request. The two comment nodes retry twice, 2.5 seconds apart, so a
  short burst (an album of photos, fast typing) gets through; a longer burst fails its
  n8n execution after the third try (visible in the execution list) and the message
  stays in Spun only. Re-run the execution in n8n. Messages are not serialized or
  combined per request. A retry after a Jira 5xx can, rarely, add the same comment
  twice.
- **Burst limits per site:** 100 GET and 100 POST per second. API-token traffic is not
  subject to the points-based hourly quota that applies to apps (from March 2, 2026).
- A 429 carries `Retry-After` and `RateLimit-Reason` (`jira-per-issue-on-write` for the
  per-request limit).
- Comments longer than 32,000 characters are cut.

## 13. What was verified and what was assumed

| Item | Status |
|---|---|
| Basic auth `email:api_token` against the site URL, same permissions as the web UI | Verified |
| Scoped API tokens | Not covered; classic token used |
| Integration account must be an agent; Jira admin only for the webhook | Verified (seat billing is a plan matter) |
| `POST /rest/servicedeskapi/request` body and 201 response; single-line text value format | Verified |
| A field missing from the request type is rejected or dropped | Ambiguous; setup step 3.5 checks it |
| Privacy-ID chats: create without the number, Spun LID field written, searched with `~` and read back | Built on the verified create, search and field formats; not live-tested |
| A hidden field keeps a value sent through the API | Ambiguous; keep it visible |
| A request type outside every portal group can be raised through the API | Assumed |
| `POST /rest/api/3/search/jql`, `~` only on text fields, `reconcileIssues` (50), search lag | Verified |
| `GET /rest/servicedeskapi/request/{key}` `currentStatus.statusCategory` enum; compared case-insensitively | Verified; case ambiguous on the platform side |
| `POST /rest/servicedeskapi/request/{key}/comment` with required `public`; author is always the token's account | Verified |
| Platform transitions list (`expand=transitions.fields`) and do (204), resolution only when on the screen | Verified |
| Reopen transition exists on your workflow | Workflow-specific; optional setting |
| Admin webhook creation, JQL filter, `X-Hub-Signature` hex HMAC, test vector, retries, `X-Atlassian-Webhook-Identifier`, allowed ports | Verified |
| Webhook `comment.body` is a string and carries `jsdPublic` | Ambiguous; comment read when either is missing |
| `accountType` `atlassian` / `app` / `customer` on the comment author | Verified |
| `jira:issue_updated` payload with `changelog.items[].field = status` | Assumed (step 8, off by default) |
| Customer notifications for WhatsApp requests reach only the integration account | Assumed (step 7) |
| Rendering of Jira markup in comment text both ways | Assumed |
| Per-request write limit 20 per 2 s and 100 per 30 s; API tokens outside the points quota; 429 headers | Verified |
| API token expiry chosen at creation | Not in API-NOTES; check when creating the token |
