Help Desk to Jira Service Management: ADF Conversion & API Guide
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.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
Help Desk to Jira Service Management: ADF Conversion & API Guide
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.
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/.
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. 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:
{
"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:
{
"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:
{
"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.
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.
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 — 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:
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/) provides a lightweight option using BeautifulSoup4 as its only dependency:
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 fieldSupported 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:
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 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:
<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:
{
"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.
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:
- Create the issue via API v3 with the description, replacing inline
<img>tags with placeholder text (e.g.,[image: screenshot.png]). - Upload the attachment — download the binary from the source URL and POST it to
/rest/api/3/issue/{issueId}/attachmentswith the headerX-Atlassian-Token: no-check. Jira returns an attachment object containing the newid. - Update the description with the finalized ADF payload, replacing the placeholder with a
mediaSingle→medianode referencing the Jira attachment ID, viaPUT /rest/api/3/issue/{issueId}.
A mediaSingle node referencing an uploaded attachment looks like this:
{
"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:
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.
{
"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.
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/
Three practical consequences for migrations:
- Do not resolve users at write time by display name. Build a source-email-to-
accountIdmap before you migrate. UseGET /rest/api/3/user/search?query=user@example.comto 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/searchwith 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:
{
"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/.
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:
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.
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 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:
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_keyA 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, Desk365, and other platforms, see the related posts below.
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.