Importing HTML
Preview HTML conversion, create a draft, and re-import into a known story without losing its identity.
Use HTML import when you have an existing article rather than a Prezly content document. Start by converting it without saving, inspect the result and diagnostics, then create a story or replace the content of a story you name explicitly.
| Task | Endpoint | Result |
|---|---|---|
| Preview conversion | POST /content/convert | 200 with validation, content, and diagnostics; no story is saved. |
| Create from HTML | POST /stories/import-from-html | Normally 201 with the new story and diagnostics. |
| Replace existing content | POST /stories/{story_id}/import-from-html | 200 with the same story UUID and diagnostics. |
Load PREZLY_API_TOKEN as in Authentication. Personal tokens need stories:write even for conversion. Story creation and replacement also require permission to manage the target newsroom. If the converter is unavailable or not configured, these endpoints can return 503; switching credentials does not fix an unavailable converter.
1. Prepare an HTML payload
Replace YOUR_NEWSROOM_UUID. Save as prepare-import.mjs and run it with Node.js 22+:
import { writeFile } from 'node:fs/promises';
const payload = {
newsroom: 'YOUR_NEWSROOM_UUID',
culture: 'en',
visibility: 'private',
html: '<h1>Acme opens its new studio</h1><h2>A new home for the team</h2><p>The studio opens <strong>next month</strong>.</p>',
};
await writeFile('import-story.json', JSON.stringify(payload, null, 2));Use the newsroom UUID returned by List Newsrooms. The import request also accepts numeric newsroom IDs and the documented reference wrappers.
Use article HTML, not an entire page full of navigation and footer elements. A leading headline is promoted to the title; the subtitle heading supplies the subtitle. If the source has no suitable heading, send title and optionally subtitle as converter inputs. These inputs differ from the standalone title fields rejected by direct v6 story creation: the import service builds document nodes from them.
If both the HTML and those fields provide a title/subtitle, the document's headings win and content.header_ignored explains what was unused. If neither provides a title, content.no_title is reported and story import is refused. A synthesized title can repeat a headline already styled as ordinary body text; review content.duplicate_headline rather than assuming the converter removed it.
2. Convert before saving
The same payload can be used for the preview; fields such as newsroom do not cause it to save a story:
curl --fail-with-body --silent --show-error --request POST 'https://api.prezly.com/v2/content/convert' \
--header "Authorization: Bearer $PREZLY_API_TOKEN" \
--header 'Content-Type: application/json' \
--data-binary @import-story.json \
--output conversion.jsonA 200 means conversion ran, not that the article is ready to save. Check both valid and accepted, and read diagnostics. The response's content is a document object; this is different from the serialized story.content returned by story endpoints.
Save this check as check-conversion.mjs and run it before the import:
import { readFile } from 'node:fs/promises';
const result = JSON.parse(await readFile('conversion.json', 'utf8'));
if (!result.valid || !result.accepted) {
throw new Error('Conversion did not produce accepted content. Inspect conversion.json.');
}
for (const diagnostic of result.diagnostics ?? []) {
console.log(diagnostic.level, diagnostic.code, diagnostic.count ?? '');
}Successful validation does not guarantee that every image will upload, that the account may save the story, or that the imported article needs no editorial review. Preview with posted HTML does not fetch its image URLs or upload files: unresolved media remains as placeholders.
3. Create the story
Once you have checked the preview, send the original HTML payload to the import endpoint:
curl --fail-with-body --silent --show-error --request POST 'https://api.prezly.com/v2/stories/import-from-html' \
--header "Authorization: Bearer $PREZLY_API_TOKEN" \
--header 'Content-Type: application/json' \
--header 'Accept: application/json; content-format=v6' \
--data-binary @import-story.jsonThe service converts the HTML, resolves media, and saves v6 content. A new story returns 201; copy story.uuid and keep it in your migration's source-to-story mapping. This example creates a private draft. Inspect its rendered content and diagnostics before any separate publishing action.
A successful HTTP status can still accompany missing media. In particular, image.upload_failed means unresolved placeholders remain in the story. Do not mark a record fully migrated on the strength of 201 alone.
4. Re-import into a known story
Use the existing-story endpoint for a second import. It does not match on title or slug: the UUID in the URL selects the story whose content will be replaced.
Build a content-only payload so the creation defaults do not change the existing story’s visibility or language. Save as prepare-reimport.mjs and run it:
import { readFile, writeFile } from 'node:fs/promises';
const { html, title, subtitle, nodes } = JSON.parse(await readFile('import-story.json', 'utf8'));
const payload = { html, title, subtitle, nodes };
await writeFile('replace-story-content.json', JSON.stringify(payload, null, 2));Replace YOUR_STORY_UUID with the UUID you recorded:
curl --fail-with-body --silent --show-error --request POST 'https://api.prezly.com/v2/stories/YOUR_STORY_UUID/import-from-html' \
--header "Authorization: Bearer $PREZLY_API_TOKEN" \
--header 'Content-Type: application/json' \
--header 'Accept: application/json; content-format=v6' \
--data-binary @replace-story-content.jsonThe response is 200. The story keeps its UUID and translation-group membership. Fields you omit remain unchanged; fields you include, such as culture, visibility, or custom_slug, are still updates. An import into a published story changes that existing story.
This endpoint refuses newsroom, even if null, and refuses on_duplicate. Moving a story is a separate operation. It does not delete old media uploads, which may still be referenced by an earlier revision. Coordinate repeated imports with editing, and use the content-version approach from Creating and updating stories when protecting a content replacement against concurrent changes.
Images and structured blocks
For inline <img> elements, import fetches public HTTP(S) image URLs and uploads the images for use in the story. Conversion-only preview leaves placeholders. A leading image becomes header media; empty blocks before it can be removed, reported as content.leading_blanks_dropped.
For galleries, attachments, contacts, embeds, and buttons, use the import-specific nodes array. These are construction instructions, not necessarily the same shape or type names as the resulting content nodes. This example places a button at an explicit marker:
{
"html": "<h1>Acme opens its new studio</h1><p>Come and visit.</p><p>[[prezly-node:visit]]</p>",
"nodes": [
{
"type": "button",
"ref": "visit",
"href": "https://example.com/visit",
"label": "Book a visit",
"variant": "outline",
"layout": "center",
"new_tab": true
}
]
}The marker must occupy its own paragraph. An unplaced node is appended and reported as sidechannel.ref_not_placed. Unsupported types can be skipped with sidechannel.unsupported; inspect diagnostics rather than assuming every supplied node was inserted.
Gallery images require a caption, even an empty string, and either src or a pre-uploaded file. A failed image can leave the whole gallery unresolved rather than produce a partial gallery. Images need dimensions and effects in their file metadata; attachment files use the simpler file representation. See Images and files and the endpoint reference for supported request fields.
Contact blocks can be linked to or create newsroom contacts during import. For personal tokens, those extra operations require sites:read for lookup and sites:write for creation. Without the necessary access, a block can remain standalone with contact.unmatched; do not assume story-write permission also grants contact lookup or creation.
Review diagnostics and failures
| Signal | What it means for your import |
|---|---|
valid: false or accepted: false | Inspect the converted document before attempting a write. |
content.no_title | Supply a real headline; story import is refused. |
image.placeholders | Expected in conversion preview when media has not been uploaded. |
image.upload_failed | The saved result still has unresolved media; review and repair it. |
sidechannel.* | A supplied block was unsupported, could not be built, or was placed at the end. |
contact.linked, contact.created, contact.unmatched | What happened to the contact references, beyond the visible text. |
422 | Correct the payload or rejected content before trying again. |
503 | Conversion infrastructure is unavailable or disabled; inspect the error code. |
Diagnostics have a level, code, and message, with count where relevant. Keep a per-record migration result and decide which warnings require repair. If a write's response is lost, inspect the destination before repeating it; an uncertain outcome is not proof the story or media was never saved.
Importing from a URL (beta)
The same endpoints also accept url instead of html. Extraction is automatic and can miss article content or include unrelated page content. Every URL import needs editorial review, including when it returns success.
Send exactly one source. nodes cannot be combined with url. Conversion preview with a URL fetches and extracts the page, but still does not save a story or upload its media. The response adds source information and extraction diagnostics; detected publication date and language are reported, not automatically applied to the story.
When creating from a URL, explicitly name the newsroom. Recognized source URLs are deduplicated within that newsroom: a repeated import normally returns 409 url.already_imported. To update the previously imported story, use its UUID or deliberately request on_duplicate: "update" with the newsroom; that path returns 200 and replaces content. Concurrent imports can return url.import_in_progress.
Do not treat a chosen slug or force as a retry strategy. For repeatable migrations, retain the returned UUID and use the existing-story endpoint. A URL rejected with url.forbidden, url.no_content, or url.no_story may need article HTML supplied directly. See the import reference for the full URL contract and error codes.