---
title: "Help Desk to Jira Service Management: ADF Conversion & API Guide"
slug: help-desk-to-jira-service-management-adf-conversion-api-guide
date: 2026-10-20
author: Nachi Raman
categories: [Jira Service Management, Migration Guide, Atlassian]
excerpt: "A technical guide to converting HTML and plain text into Atlassian Document Format (ADF) for Jira Service Management migrations, with API examples and edge-case coverage."
tldr: "Jira Cloud's v3 API requires ADF (a JSON tree) for descriptions and comments — not HTML. This guide covers the structure, conversion strategies, API endpoints, and failure modes for help desk to JSM migrations."
canonical: https://clonepartner.com/blog/help-desk-to-jira-service-management-adf-conversion-api-guide
---

# Help Desk to Jira Service Management: ADF Conversion & API Guide


# Help Desk to Jira Service Management: ADF Conversion & API Guide

> [!NOTE]
> **Quick answer:** Every help desk migration into Jira Service Management (JSM) Cloud hits the same wall: the Jira Cloud REST API v3 requires rich-text fields — descriptions, comments, textarea custom fields — in **Atlassian Document Format (ADF)**, a nested JSON structure. Your source platform almost certainly stores those fields as HTML or plain text. Converting that content into valid ADF is the single most common failure point in JSM inbound migrations.

This guide covers the exact ADF structure, which API endpoints to use, conversion strategies with code examples, and the edge cases that cause data loss during help desk migrations. API behavior described here applies to Jira Cloud REST API v3, last verified against Atlassian documentation in June 2025. For the broader execution plan, see our [Jira Service Management Migration Checklist](https://clonepartner.com/blog/blog/jira-service-management-migration-checklist).

## What is Atlassian Document Format (ADF)?

**Atlassian Document Format (ADF)** is a JSON-based schema that Jira Cloud uses to represent rich text in issue descriptions, comments, and textarea custom fields. Unlike HTML — a flat string of tags — ADF is a tree of typed **nodes** (paragraphs, headings, tables) and **marks** (bold, italic, link) built on top of ProseMirror, the open-source collaborative editing framework. The canonical ADF specification is maintained by Atlassian at [https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/).

This matters for migrations because the v3 API expects rich-text fields in ADF — not a plain string, not HTML. Sending a plain string or raw HTML to a rich-text field returns a `400 Bad Request` with a validation error in the response body.

Not every Jira field accepts ADF. Issue descriptions, environment fields, comment bodies, and multiline `textarea` custom fields take ADF objects. Single-line `textfield` custom fields still take plain strings. Pushing a converted ADF object into a single-line field is a schema mistake, not a formatting bug.

You can interactively test and validate ADF documents at the **ADF Playground**: [https://adf.atlassian.com](https://adf.atlassian.com). This is the fastest way to verify that a generated ADF structure will be accepted before running a bulk migration.

### The minimum valid ADF document

Every valid ADF document must have a root node with `type` set to `"doc"`, `version` set to `1`, and a `content` array. Here is the simplest possible ADF document — a single paragraph:

```json
{
  "type": "doc",
  "version": 1,
  "content": [
    {
      "type": "paragraph",
      "content": [
        {
          "type": "text",
          "text": "Hello from the migration script."
        }
      ]
    }
  ]
}
```

An empty `content` array (`[]`) is valid for creating blank descriptions. A missing `version` or a `type` other than `"doc"` at the root will cause a 400. A typical 400 response body for a malformed ADF document looks like this:

```json
{
  "errorMessages": [],
  "errors": {
    "description": "Invalid ADF content: Expected 'doc' node type at root level."
  }
}
```

For a node-level schema violation (e.g., a heading with no content), Jira returns:

```json
{
  "errorMessages": ["The content of 'heading' must contain at least one inline node."],
  "errors": {}
}
```

These error messages are your primary debug signal during bulk migration runs. Log them per record.

### ADF document size limits

The Jira Cloud ADF size constraint is **node-count based and document-size based**, not character-count based. Atlassian's documented limit is **5 MB per ADF document** for the `description` field, though in practice the rendering engine imposes practical limits well below that. Documents with thousands of nodes (e.g., tables with hundreds of rows imported from a CSV export) may be rejected or cause UI performance issues even if they pass the API size check. For very long ticket descriptions from source systems, attach the original content as a file and summarize in the description field.

## Which ADF nodes work in Jira?

Not every ADF node that Confluence supports will render correctly in Jira. Many ADF features are Confluence-specific and will not function in Jira issue fields.

| Category | Node types | Notes |
|---|---|---|
| **Block** | `paragraph`, `heading` (1–6), `bulletList`, `orderedList`, `listItem`, `codeBlock`, `blockquote`, `table`, `tableRow`, `tableHeader`, `tableCell`, `panel`, `rule` | `panel` supports `panelType`: info, note, warning, error, success |
| **Inline** | `text`, `hardBreak`, `mention`, `emoji`, `inlineCard` | `mention` requires a valid Atlassian `accountId` |
| **Marks** | `strong`, `em`, `code`, `strike`, `underline`, `link`, `textColor`, `subsup` | `link` takes `attrs.href` |
| **Unsupported in Jira** | `mediaSingle`, `mediaGroup`, `expand`, `extension`, `bodiedExtension` | Require Atlassian-internal tokens; avoid during migration writes |

`mediaSingle`, `mediaGroup`, `expand`, `extension`, and `bodiedExtension` require Atlassian-internal file tokens or attachment IDs that complicate the migration pipeline. Avoid writing these nodes during migration unless you have a purpose-built attachment workflow (see the media section below).

## Which API endpoint should you use?

JSM Cloud gives you two ways to create tickets, and they behave differently. Most migrations use both.

### Jira core API: `POST /rest/api/3/issue`

This endpoint creates the issue with full ADF support in the `description` field and direct control over every Jira field. The trade-off: it does not set the JSM request type, so the issue won't appear in the customer portal or trigger SLA workflows out of the box.

Reference: [https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/#api-rest-api-3-issue-post)

```bash
curl -u "user@example.com:$API_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  --data '{
    "fields": {
      "project": { "key": "SUPPORT" },
      "issuetype": { "name": "[System] Service Request" },
      "summary": "Cannot access VPN from remote office",
      "description": {
        "type": "doc",
        "version": 1,
        "content": [
          {
            "type": "paragraph",
            "content": [
              { "type": "text", "text": "User reports VPN timeout after 30s. Traceroute attached." }
            ]
          }
        ]
      }
    }
  }' \
  "https://your-domain.atlassian.net/rest/api/3/issue"
```

If you need portal visibility and SLAs, set `customfield_10010` (the request type field) via a PUT after creation.

### JSM service desk API: `POST /rest/servicedeskapi/request`

This endpoint creates a proper service desk request with correct request type, SLA tracking, customer portal visibility, `raiseOnBehalfOf`, and participant handling.

The trade-off: the `description` field in this endpoint accepts a plain-text string, not an ADF object. This ADF support gap is a documented limitation — an open feature request on Atlassian's public issue tracker (JSDSERVER-6788 and related cloud issues) asks for ADF support in JSM REST API request fields. As of June 2025, this remains unresolved.

Reference: [https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/#api-rest-servicedeskapi-request-post](https://developer.atlassian.com/cloud/jira/service-desk/rest/api-group-request/#api-rest-servicedeskapi-request-post)

```bash
curl -u "user@example.com:$API_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  --data '{
    "serviceDeskId": "3",
    "requestTypeId": "25",
    "raiseOnBehalfOf": "customer@example.com",
    "requestFieldValues": {
      "summary": "Cannot access VPN from remote office",
      "description": "User reports VPN timeout after 30s. Traceroute attached."
    }
  }' \
  "https://your-domain.atlassian.net/rest/servicedeskapi/request"
```

**The migration pattern:** Create the issue via `/rest/api/3/issue` with full ADF for the description, then set the request type field via a PUT to `customfield_10010`. Or accept the plain-text trade-off and use `/rest/servicedeskapi/request` for portal correctness when formatting fidelity is not a priority.

### Validate before bulk writes

Before writing thousands of records, call `/rest/servicedeskapi/request/validate`. This endpoint performs the same structural and semantic validation as the create-request endpoint — including form validation — without creating an issue or triggering side effects. It catches missing required fields, bad option IDs, and request-type mismatches before they break a migration run.

Fetch the request-type field schema with `GET /rest/servicedeskapi/servicedesk/{serviceDeskId}/requesttype/{requestTypeId}/field` before mapping custom fields. It returns the actual field IDs, schemas, required flags, and visibility state for the specific service desk and request type you're targeting. This also solves a common migration bug: `requestFieldValues` in a request payload does not reveal hidden fields, but the request-type field endpoint does.

### Endpoint decision summary

| Requirement | Use endpoint |
|---|---|
| Full ADF description fidelity | `POST /rest/api/3/issue` |
| Customer portal visibility + SLA | `POST /rest/servicedeskapi/request` (then PUT request type) or core API + set `customfield_10010` |
| `raiseOnBehalfOf` for customer attribution | `POST /rest/servicedeskapi/request` |
| ADF-formatted comments | `POST /rest/api/3/issue/{key}/comment` |
| Public/internal comment visibility control | `POST /rest/servicedeskapi/request/{key}/comment` (plain text only) |

## How to convert HTML to ADF

This is where most migrations break. Your source help desk — Zendesk, Freshdesk, Intercom, [Deskpro](https://clonepartner.com/blog/blog/deskpro-to-jira-service-management-migration-technical-guide) — stores ticket bodies as HTML. JSM's v3 API wants ADF.

**Lossy conversion is normal.** Atlassian's own ADF Builder documentation is unusually explicit: the HTML and Markdown conversion modules are marked **experimental**, and the wiki-markup converter documentation states conversion is "more of an art than a science" with many known problems. Complex HTML features like forms, SVG, CSS styling, and canvas graphics are difficult or impossible to represent in ADF.

The right strategy: convert into a **supported subset**, not a perfect clone. Preserve business meaning — paragraphs, headings, lists, code blocks, links, basic tables — and flatten or drop presentation-only details like CSS classes, font faces, and inline colors. Treat mentions, media, and issue links as identifier-mapping problems, not text-formatting problems.

### Source-platform HTML patterns and their ADF equivalents

Different help desks produce different HTML patterns. Here are the common constructs and how they map:

| Source HTML | ADF equivalent | Notes |
|---|---|---|
| `<p>text</p>` | `paragraph` node | Direct mapping |
| `<strong>`, `<b>` | `strong` mark | Apply to `text` node |
| `<em>`, `<i>` | `em` mark | Apply to `text` node |
| `<s>`, `<del>` | `strike` mark | Apply to `text` node |
| `<a href="...">` | `link` mark with `attrs.href` | Absolute URLs only |
| `<h1>`–`<h6>` | `heading` node with `attrs.level` 1–6 | Must contain at least one text node |
| `<ul>`, `<li>` | `bulletList` + `listItem` + `paragraph` | listItem must wrap a paragraph |
| `<ol>`, `<li>` | `orderedList` + `listItem` + `paragraph` | Same nesting rule |
| `<code>` (inline) | `code` mark | Apply to `text` node |
| `<pre><code>` | `codeBlock` node | Use `attrs.language` if available |
| `<blockquote>` | `blockquote` node | Contains paragraph nodes |
| `<table>` | `table` → `tableRow` → `tableCell`/`tableHeader` | Must be fully structured; no floating text |
| `<br>` | `hardBreak` node | Inline within paragraph |
| `<img src="...">` | Attachment upload + `mediaSingle`/`media` node | Cannot use external URLs directly (see media section) |
| `<hr>` | `rule` node | Block-level separator |
| Zendesk `@mention` span | `mention` node with `accountId` | Requires pre-built user map |
| Freshdesk `<div class="freshdesk-quote">` | `blockquote` node | Strip the class attribute |
| Inline CSS (`style="color:red"`) | `textColor` mark (if supported) or drop | Presentational CSS is not preserved in ADF |
| `<iframe>`, `<video>`, `<svg>` | No ADF equivalent | Strip and note in migration log |

### Do not use regular expressions

ADF is strictly validated. HTML nesting rules are permissive, and ADF's schema is strict. Regex-based conversion mishandles nested tags, self-closing elements, and malformed source HTML. Instead, parse HTML into a DOM or Abstract Syntax Tree using a proper parser, then recursively walk the tree to build ADF JSON.

### Option 1: Atlaskit libraries (JavaScript/TypeScript)

Atlassian's own editor libraries (`@atlaskit/adf-schema` v35+, `@atlaskit/editor-json-transformer`) can parse HTML into ProseMirror nodes and serialize to ADF:

```javascript
import { defaultSchema } from "@atlaskit/adf-schema";
import { JSONTransformer } from "@atlaskit/editor-json-transformer";
import { DOMParser } from "prosemirror-model";
import { JSDOM } from "jsdom";

function htmlToAdf(html) {
  const dom = new JSDOM(html).window.document.body;
  const pmNode = DOMParser.fromSchema(defaultSchema).parse(dom);
  const transformer = new JSONTransformer();
  return transformer.encode(pmNode);
}
```

Known issues (as of `@atlaskit/adf-schema` v35): empty paragraph nodes inserted between block elements, incorrect table cell handling for complex table structures, and lost `colspan`/`rowspan` attributes. Test the output against your actual source HTML before running a bulk migration.

### Option 2: Python libraries

The `html-to-adf` package (PyPI, [https://pypi.org/project/html-to-adf/](https://pypi.org/project/html-to-adf/)) provides a lightweight option using BeautifulSoup4 as its only dependency:

```python
from html_to_adf import convert_html_to_adf

html = "<p>Please <strong>restart</strong> the service.</p>"
adf_doc = convert_html_to_adf(html)
# Returns a dict ready to POST as the description field
```

Supported tags: `<p>`, `<h1>`–`<h6>`, `<table>`, `<strong>`, `<em>`, `<s>`, `<u>`, `<a>`, `<ul>`, `<ol>`, `<li>`, `<code>`, `<pre>`, `<blockquote>`, `<div>`. Tags outside this set are stripped — `<iframe>`, `<video>`, and `<img>` with non-public URLs will be lost. Log all stripped nodes for post-migration review.

For JavaScript, `@razroo/html-to-adf` and `html-to-adf-converter` on npm provide similar functionality with comparable tag support.

### Option 3: Build ADF manually

For structured content with predictable patterns (form-based ticket fields, CSV exports), building ADF directly is often cleaner than parsing HTML:

```python
def text_to_adf(plain_text):
    """Wrap a plain-text string in minimal valid ADF."""
    paragraphs = [p.strip() for p in plain_text.split("\n\n") if p.strip()]
    content = []
    for para in paragraphs:
        content.append({
            "type": "paragraph",
            "content": [{"type": "text", "text": para}]
        })
    if not content:
        content.append({"type": "paragraph", "content": []})
    return {"type": "doc", "version": 1, "content": content}
```

This handles the 80% case for migrations from platforms that export plain text ([Desk365](https://clonepartner.com/blog/blog/desk365-to-jira-service-management-migration-guide) CSV, older ticketing systems). For platforms with rich HTML (Zendesk, Freshdesk, Intercom), use one of the library-based approaches above.

### Option 4: Fall back to the v2 API

The endpoint `/rest/api/2/issue` accepts wiki markup or plain-text strings for description and comment fields. You lose rich formatting, but you eliminate conversion risk entirely. This is a legitimate choice when source content is mostly plain text or when preserving exact formatting is not a business requirement.

### Wiki markup sources

Some source platforms — older Jira Server instances, Confluence Server — store content in Atlassian Wiki Markup. Conversion between wiki markup and ADF is lossy: they do not have feature parity and content does not reliably round-trip. If you're migrating from Jira Server/Data Center to JSM Cloud, the Atlassian Cloud Migration Assistant handles this automatically. For manual migrations, `adf-builder-java` includes a wiki-markup module, and `@atlaskit/editor-wikimarkup-transformer` handles it in JavaScript (part of the Atlaskit monorepo).

### What the conversion should look like

Here is a concrete HTML-to-ADF example:

```html
<p>Customer says checkout fails on <strong>Safari</strong>.</p>
<ul>
  <li>Started after the iOS update</li>
  <li>Affects guest checkout only</li>
</ul>
```

The ADF equivalent:

```json
{
  "type": "doc",
  "version": 1,
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Customer says checkout fails on " },
        { "type": "text", "text": "Safari", "marks": [{ "type": "strong" }] },
        { "type": "text", "text": "." }
      ]
    },
    {
      "type": "bulletList",
      "content": [
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "content": [{ "type": "text", "text": "Started after the iOS update" }]
            }
          ]
        },
        {
          "type": "listItem",
          "content": [
            {
              "type": "paragraph",
              "content": [{ "type": "text", "text": "Affects guest checkout only" }]
            }
          ]
        }
      ]
    }
  ]
}
```

Key structural rules: `<strong>` becomes a `mark` on the text node, not a wrapper node. List items must contain block-level nodes (usually `paragraph`), not raw text strings. `bulletList` children must be `listItem` nodes only. Getting any of these nesting rules wrong produces a `400 Bad Request` with a schema validation message in the response body.

> [!WARNING]
> Do not promise exact fidelity when the source system stores heavily styled HTML or legacy wiki macros. Atlassian explicitly states that HTML conversion is experimental and wiki markup conversion is lossy. A readable, supportable ticket history is the correct goal; pixel-perfect replay is not achievable with current tooling.

## Handling inline images and the media API

In legacy help desks, inline images are standard `<img>` tags pointing to a URL. ADF handles media differently — you cannot pass an external source URL into an ADF document and have Jira render it. The `mediaSingle` and `media` nodes require Jira-internal attachment IDs, not URLs.

To migrate inline images, follow this sequence:

1. **Create the issue** via API v3 with the description, replacing inline `<img>` tags with placeholder text (e.g., `[image: screenshot.png]`).
2. **Upload the attachment** — download the binary from the source URL and POST it to `/rest/api/3/issue/{issueId}/attachments` with the header `X-Atlassian-Token: no-check`. Jira returns an attachment object containing the new `id`.
3. **Update the description** with the finalized ADF payload, replacing the placeholder with a `mediaSingle` → `media` node referencing the Jira attachment ID, via `PUT /rest/api/3/issue/{issueId}`.

A `mediaSingle` node referencing an uploaded attachment looks like this:

```json
{
  "type": "mediaSingle",
  "attrs": { "layout": "center" },
  "content": [
    {
      "type": "media",
      "attrs": {
        "id": "attachment-id-returned-by-upload",
        "type": "file",
        "collection": ""
      }
    }
  ]
}
```

If you cannot reliably wire the attachment ID into the ADF structure (e.g., because the description is complex), skip the `mediaSingle` node and leave the files as standard issue attachments. Standard attachments are visible in the Jira UI's attachment panel and are far simpler to migrate than inline media.

## How to add comments with correct visibility

Help desk platforms distinguish between public replies (visible to customers) and internal notes (agent-only). JSM has the same concept, but the API mechanics differ by endpoint — and this is the split most migration scripts handle incorrectly.

### Via the service desk API

The JSM-specific comment endpoint at `/rest/servicedeskapi/request/{issueKey}/comment` controls visibility with a `public` boolean:

```bash
curl -u "user@example.com:$API_TOKEN" \
  -H "Content-Type: application/json" \
  -X POST \
  --data '{
    "body": "Escalating to network team. Customer does not need to see this.",
    "public": false
  }' \
  "https://your-domain.atlassian.net/rest/servicedeskapi/request/SUPPORT-142/comment"
```

Agents can create public or internal comments. Customers can only create public comments. **This endpoint accepts a plain-text string for `body`, not ADF.** If you need rich-text formatting in comments with visibility control, you must use the core API and manually set the `visibility` object.

### Via the core Jira API

The core comment endpoint at `/rest/api/3/issue/{issueKey}/comment` accepts full ADF in the `body` field. To make a comment internal (visible to agents only), set `visibility.type` to `"role"` and `visibility.value` to `"Service Desk Team"`. Omit `visibility` entirely for public comments.

```json
{
  "body": {
    "type": "doc",
    "version": 1,
    "content": [
      {
        "type": "paragraph",
        "content": [
          { "type": "text", "text": "Investigating the firewall logs now.", "marks": [{"type": "strong"}] }
        ]
      }
    ]
  },
  "visibility": {
    "type": "role",
    "value": "Service Desk Team"
  }
}
```

**The API fork summarized:** The Jira platform comment API uses an ADF `body` object. The JSM customer-request comment API uses a plain-text `body` string and adds a `public` boolean. If you need both rich-text formatting AND correct public/internal visibility, use the core API with the `visibility` object. If formatting is not critical but portal visibility accuracy is, use the JSM API with plain text.

Map the comment visibility states from your source platform before writing migration code. Zendesk distinguishes `public` and `private` on each comment object. Freshdesk marks notes with `private: true`. Deskpro uses `is_agent_note`. Map these source fields to the `public` boolean or `visibility` object before your migration run — visibility mistakes are harder to unwind after go-live than formatting mistakes.

> [!WARNING]
> **Comment authorship is lost during migration.** Both APIs attribute the comment to the authenticated user making the API call, not the original agent or customer. The standard workaround is to prefix the comment body with the original author's name and timestamp: "*Originally posted by Jane Doe on 2024-03-15 14:22 UTC:*"
>
> True authorship impersonation — where the comment appears as originating from the actual user — requires an Atlassian Forge or Atlassian Connect app with `ACT_AS_USER` permissions. A Forge app can call the API as a specific user by passing `x-actas` headers after obtaining consent. This is significantly more complex to implement and requires publishing an app to the Atlassian Marketplace or sideloading it into your specific site. For most migrations, the prefix-text workaround is the practical choice.

## User mapping and mentions

**`accountId`** is the stable Jira Cloud user identifier. Atlassian deprecated `username` and `userKey` with the move to cloud, and user objects may hide personal fields like email depending on profile-visibility settings controlled by the user.

Reference: [https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-user-search/](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-user-search/)

Three practical consequences for migrations:

- **Do not resolve users at write time by display name.** Build a source-email-to-`accountId` map before you migrate. Use `GET /rest/api/3/user/search?query=user@example.com` to find account IDs. This endpoint returns at most 50 results per page and searches across active users only.
- **Do not assume email is readable.** Atlassian hides email addresses by default for all account types unless you have site-admin access and the correct email-access configuration in the Atlassian admin console.
- **Do not assume user search covers the full directory.** The user-search API returns results from the first 1,000 users found in its internal search index. Large tenants with thousands of users can silently miss matches. Pre-build your identity map from a full user export (`GET /rest/api/3/users/search` with pagination) rather than relying on per-record lookup.

When a legacy ticket contains an `@mention`, the source HTML typically wraps the name in a specific span class (e.g., Zendesk uses `<span class="zd-comment-ref">`). In JSM Cloud, mentions use `accountId`, not display names or email addresses. Insert an ADF mention node:

```json
{
  "type": "mention",
  "attrs": {
    "id": "5b10ac8d82e05b22cc7d4ef5",
    "text": "@John Doe"
  }
}
```

If the user does not exist in the target JSM instance (e.g., a former employee), gracefully degrade the mention to a standard `text` node to prevent a 400 API error. Log all unmapped mentions for post-migration review.

**Customer identities** need separate handling. JSM supports external portal-only customers who can use help centers and send requests without consuming product licenses. When your legacy help desk has a large requester base (thousands of end-users who submitted tickets), those users should be created as JSM customers via `POST /rest/servicedeskapi/servicedesk/{id}/customer` — not as licensed Jira users. This distinction has direct licensing cost implications.

## Rate limits that affect migration throughput

Jira Cloud has layered rate limiting that directly impacts migration speed. The definitive reference is Atlassian's rate-limiting documentation at [https://developer.atlassian.com/cloud/jira/platform/rate-limiting/](https://developer.atlassian.com/cloud/jira/platform/rate-limiting/).

**Points-based hourly quota:** Your entire Jira Cloud site shares a **65,000-point hourly budget** across all authenticated API consumers (apps, integrations, migration scripts, and human users). Read operations (GET) and write operations (POST, PUT, DELETE) both cost points. Write operations are weighted more heavily than reads. If your migration script exhausts this budget, all API calls from any consumer on the site will be rate-limited until the hourly window resets.

**Per-second burst limits:** Per-second, per-endpoint limits protect against traffic spikes. These often matter more than the hourly quota for migrations — a tight loop creating issues and posting comments will trigger `429 Too Many Requests` on burst limits before you get close to exhausting the hourly budget.

Always implement exponential backoff and honor the `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` response headers:

```python
import time
import requests

def post_with_retry(url, payload, auth, max_retries=5):
    for attempt in range(max_retries):
        resp = requests.post(url, json=payload, auth=auth,
                             headers={"Content-Type": "application/json"})
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)
            continue
        resp.raise_for_status()
        return resp.json()
    raise Exception(f"Failed after {max_retries} retries")
```

Additional pacing strategies for migrations:

- Add a 200–500ms delay between each issue creation, not just between retries.
- Avoid replaying dozens of comments onto the same issue in a tight loop — spread comment writes across issues rather than saturating a single key.
- Cache field schema responses and identity mappings rather than re-fetching them on every record. Schema fetches count against the hourly budget.
- Run large migrations outside business hours to avoid contention with your team's normal Jira usage.

## Edge cases that break ADF imports

These are the ADF-related validation failures encountered most frequently in help desk migrations:

**Empty heading content.** ADF allows a `paragraph` node with an empty `content` array, but `heading` nodes require at least one inline content node. A heading with no text nodes inside it is rejected with a schema validation error. Guard against empty headings by converting them to empty paragraphs or skipping them entirely.

**Text nodes without a `text` property.** A `{"type": "text"}` node missing the `text` key causes a validation error. Guard against `None`, `null`, or empty strings — if there is nothing to write, omit the node from the `content` array.

**Nested list structure violations.** ADF represents nested lists as a `bulletList` or `orderedList` inside a `listItem`. The required nesting is `listItem` → `[bulletList or orderedList]` → `listItem`. A `bulletList` directly inside another `bulletList` (without a `listItem` wrapper) is invalid. Source HTML from Zendesk and Freshdesk frequently produces this structure in deeply threaded comment quotes.

**Tables must be fully structured.** A `table` node with an empty `content` array is invalid. Every table needs at least one `tableRow`, and every row needs at least one `tableCell` or `tableHeader`. HTML allows permissive table structures (missing `<tbody>`, mismatched column counts, text floating outside cells) — your parser must sanitize and normalize table structure before ADF conversion. Column count mismatches across rows do not necessarily cause a 400, but they produce garbled rendering.

**Mention nodes require `accountId`.** You cannot use email addresses or display names in `mention.attrs.id`. If you pass a non-accountId string, Jira accepts the node but renders it as a broken mention in the UI. Pre-map every user before the migration run and fall back to plain text for unmapped users.

**`codeBlock` language attribute.** The `attrs.language` value in a `codeBlock` node must be a recognized language identifier (e.g., `"javascript"`, `"python"`, `"bash"`). Passing an unrecognized language string may cause the block to render without syntax highlighting or to be rejected depending on the Jira version. Default to `""` (empty string) for unknown languages rather than passing the source value verbatim.

> [!TIP]
> **Debug tip:** Create a test issue manually in JSM with the exact formatting you want, then fetch it with `GET /rest/api/3/issue/{key}` and inspect the `description` field. The returned ADF shows you exactly the structure Jira expects for that formatting — the fastest way to figure out how a specific layout should be encoded. The ADF Playground at [https://adf.atlassian.com](https://adf.atlassian.com) also lets you validate ADF documents interactively without making API calls.

Your migration script should include a fallback mechanism: if an ADF payload is rejected with a 400, catch the error, log the original payload and the error response, strip complex formatting, convert the source to plain text, wrap it in a basic ADF paragraph, and retry. This ensures data is migrated even when formatting is lost on a malformed edge case.

## A complete migration function

Putting it together, here is a skeleton that creates a JSM issue with an ADF description, adds comments in chronological order, and handles attachments:

```python
import requests
from html_to_adf import convert_html_to_adf

JIRA_URL = "https://your-domain.atlassian.net"
AUTH = ("user@example.com", "API_TOKEN")
PROJECT_KEY = "SUPPORT"
ISSUE_TYPE = "[System] Service Request"

def create_issue(ticket):
    """Create a JSM issue with ADF description."""
    if ticket.get("description_html"):
        description = convert_html_to_adf(ticket["description_html"])
    else:
        description = text_to_adf(ticket.get("description", ""))

    payload = {
        "fields": {
            "project": {"key": PROJECT_KEY},
            "issuetype": {"name": ISSUE_TYPE},
            "summary": ticket["subject"][:255],
            "description": description,
        }
    }
    resp = post_with_retry(f"{JIRA_URL}/rest/api/3/issue", payload, AUTH)
    return resp["key"]

def add_comment(issue_key, comment, is_internal=False):
    """Add a comment via the service desk API for visibility control."""
    url = f"{JIRA_URL}/rest/servicedeskapi/request/{issue_key}/comment"
    body = {
        "body": f"*{comment['author']}* ({comment['created_at']}):\n\n{comment['body_text']}",
        "public": not is_internal
    }
    return post_with_retry(url, body, AUTH)

def attach_file(issue_key, filepath):
    """Upload an attachment to the issue."""
    url = f"{JIRA_URL}/rest/api/3/issue/{issue_key}/attachments"
    headers = {"X-Atlassian-Token": "no-check"}
    with open(filepath, "rb") as f:
        resp = requests.post(url, files={"file": f}, auth=AUTH, headers=headers)
    resp.raise_for_status()
    return resp.json()

def migrate_ticket(ticket, user_map):
    """
    Full migration for a single ticket.
    user_map: dict mapping source email addresses to Jira accountIds
    """
    issue_key = create_issue(ticket)
    for comment in sorted(ticket["comments"], key=lambda c: c["created_at"]):
        add_comment(issue_key, comment, is_internal=comment.get("internal", False))
    for attachment in ticket.get("attachments", []):
        attach_file(issue_key, attachment["local_path"])
    return issue_key
```

A production migration script also requires: user mapping (source email → Jira `accountId`) pre-built before any writes, custom-field mapping with field IDs fetched from the project schema, status transitions after creation to match the source ticket state, request-type assignment via `customfield_10010`, per-record error logging with the original ticket ID and the API error response, and a delta-sync pass to capture records created in the source during the migration window.

## How long does a help desk to JSM migration take?

The data migration itself — extracting from the source, converting, and loading into JSM — typically completes within **1–5 business days**, depending on data volume, API rate limit headroom, and source data complexity. Small datasets under 10,000 records can finish in under 24 hours when the source API is fast and the data is clean. Full migrations with 100,000+ records and complex HTML typically run over a weekend to stay within Jira Cloud's rate limits.

This timeline covers **the data migration only** — moving records from source to target. It does not include the broader replatforming effort: workflow design, automation setup, agent training, and portal customization. Those workstreams typically require 3–4 months of lead time for a team that is new to JSM.

API-first migration is the highest-fidelity approach. JSON export from the source is the second option. CSV export is a last resort — CSV strips conversation threads, attachment relationships, and customer-organization associations, making high-fidelity migration impossible for most help desk data models.

The migration process follows a consistent sequence regardless of source platform: connect accounts → map fields and users → run sample migrations against a staging JSM project → full production migration → delta sync to capture records created in the source during the cutover window. For source-specific walkthroughs covering [Deskpro](https://clonepartner.com/blog/blog/deskpro-to-jira-service-management-migration-technical-guide), [Desk365](https://clonepartner.com/blog/blog/desk365-to-jira-service-management-migration-guide), and other platforms, see the related posts below.

> Migrating to JSM and need HTML or legacy ticket data converted to ADF without data loss? Book a free 30-minute scoping call with a ClonePartner engineer.
>
> [Talk to us](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click)

## Frequently asked questions

### What is Atlassian Document Format (ADF)?

ADF is a JSON-based schema that Jira Cloud uses to represent rich text in descriptions, comments, and textarea custom fields. It structures content as a tree of typed nodes (paragraph, heading, table) and marks (bold, italic, link) rather than flat HTML. The v3 REST API requires it — sending a plain string returns a 400 error.

### Can I send HTML directly to the Jira Cloud REST API?

No. The Jira REST API v3 requires ADF for all rich text fields. Sending HTML results in a 400 error or renders the raw HTML tags as plain text. You must convert HTML into valid ADF JSON before posting, or fall back to the v2 API which accepts plain-text strings.

### Should I use /rest/api/3/issue or /rest/servicedeskapi/request for a JSM migration?

Use /rest/api/3/issue if you need full ADF support in descriptions and direct field control. Use /rest/servicedeskapi/request if SLA tracking, customer portal visibility, and request type assignment are priorities. Many migration scripts create via the core API then set the request type field afterward to get both.

### How do I convert HTML to ADF for Jira?

Use Atlassian's @atlaskit/adf-schema with @atlaskit/editor-json-transformer in JavaScript, the html-to-adf package in Python, or build ADF manually for plain-text sources. Atlassian marks its own HTML converter as experimental and notes that conversion is lossy — aim for a readable supported subset, not pixel-perfect fidelity.

### How long does a help desk to JSM data migration take?

The data migration itself typically completes within 1–5 business days depending on data volume, complexity, and API rate limits. Small datasets under 10,000 records can finish in under 24 hours. This does not include your team's broader replatforming work like workflow design or agent training.
