DCE Export Format
This page documents the DiscordChatExporter (DCE) JSON format as it matters to Discord Ferry. It is a reference for developers working on the parser and for admins troubleshooting export problems.
Required Export Flags
DiscordChatExporter export \
--format Json \
--markdown false \
--media \
--include-threads All \
--output /path/to/export/
| Flag | Why it is required |
|---|---|
--format Json |
Ferry's parser reads JSON only |
--markdown false |
Without this flag DCE renders mention syntax (<@123>) as display names (@Username), destroying the IDs needed for remapping |
--media |
Downloads all attachments, avatars, and emoji locally. Discord CDN URLs expire within ~24 hours |
--include-threads All |
Exports threads and forum posts as separate files alongside their parent channels |
--markdown false is critical
If an export was made without --markdown false, Ferry's VALIDATE phase will detect rendered
mentions and warn. The migration can proceed, but all mentions will appear as plain text rather
than being remapped to Stoat users.
--media is critical
If --media was omitted, attachment URLs in the export begin with https://cdn.discordapp.com/
rather than a relative local path. Ferry cannot migrate these — Discord CDN URLs expire and Autumn
cannot fetch URLs directly. Ferry's VALIDATE phase detects this condition and reports it as an error.
File Naming Convention
DCE writes one JSON file per channel or thread, using a deterministic naming pattern:
| Export type | File name pattern |
|---|---|
| Text channel | {Guild} - {Channel} [{channel_id}].json |
| Forum thread | {Guild} - {Forum Name} - {Thread Name} [{thread_id}].json |
| Thread in channel | {Guild} - {Channel} - {Thread Name} [{thread_id}].json |
Thread-to-parent relationship is not in the JSON. Ferry infers it from the filename: a file with three dash-separated segments (Guild, Parent, Thread) is a thread or forum post. The ID in brackets is always the thread's own ID, not the parent channel's ID.
The ID in brackets is always at the end of the stem before the .json extension. Ferry extracts it
with a regex on the filename.
Top-Level JSON Schema
{
"guild": { "id": "...", "name": "...", "iconUrl": "..." },
"channel": { "id": "...", "type": "GuildTextChat", "name": "...", "topic": "..." },
"dateRange": { "after": null, "before": null },
"exportedAt": "2024-01-15T12:00:00+00:00",
"messages": [ ... ],
"messageCount": 1234
}
The channel object at the top level describes the channel this file represents. messages is an
array of message objects. messageCount matches messages.length and is used as a sanity check
during validation.
Channel Types
DCE 2.47 writes the channel type as its own PascalCase enum name, which is not the
SCREAMING_SNAKE constant the Discord API documentation uses. Ferry normalises both to Discord's
canonical integer on parse, in _coerce_channel_type, because every downstream branch works on the
integer.
Exports written before 2.47 carry the integer directly, and the parser still accepts that shape: users hold old exports.
| What DCE writes | Type ID | Stoat target | Notes |
|---|---|---|---|
GuildTextChat |
0 | TextChannel | Direct mapping |
GuildVoiceChat |
2 | VoiceChannel | May create a text channel on some instances (bug #194) |
GuildCategory |
4 | Category | Two-step creation, see Stoat API Notes |
GuildNews |
5 | TextChannel | An announcement channel, treated as text |
GuildNewsThread |
10 | TextChannel (flatten) | Becomes a standalone text channel |
GuildPublicThread |
11 | TextChannel (flatten) | Becomes a standalone text channel |
GuildPrivateThread |
12 | TextChannel (flatten) | Becomes a standalone text channel |
GuildStageVoice |
13 | Not migrated | Stoat has no stage channel |
GuildDirectory |
14 | Not migrated | No Stoat equivalent |
GuildForum |
15 | TextChannel(s) per thread | One text channel per thread, grouped in a category named after the forum |
GuildMedia |
16 | TextChannel(s) per thread | One text channel per thread, grouped in a category named after the media channel |
Stoat has exactly five channel types: SavedMessages, DirectMessage, Group, TextChannel, VoiceChannel. There are no native threads or forums, so Discord threads are flattened into regular text channels. Forum and media channel threads are grouped into dedicated Stoat categories named after the parent forum, preserving the organisational structure.
Message Types
DCE uses string names for message types, not the numeric IDs that the Discord API returns. Ferry matches on these strings:
| Type string | Ferry action |
|---|---|
"Default" |
Import normally |
"Reply" |
Import with reply reference to the target message |
"RecipientAdd" |
Skip |
"RecipientRemove" |
Skip |
"ChannelNameChange" |
Skip |
"ChannelPinnedMessage" |
Import and mark for re-pinning in the PINS phase |
"GuildMemberJoin" |
Skip (system noise, no useful content) |
"UserPremiumGuildSubscription" |
Skip (boost notification) |
"ThreadCreated" |
Skip (thread header injected by Ferry instead) |
"ThreadStarterMessage" |
Import as the first message in the thread |
"Call" |
Skip |
"ChannelIconChange" |
Skip |
Unknown type strings are logged as warnings and the message is skipped.
Message Object Schema (abridged)
{
"id": "1234567890",
"type": "Default",
"timestamp": "2024-01-15T10:30:00+00:00",
"timestampEdited": null,
"content": "Hello, world!",
"author": {
"id": "987654321",
"name": "Alice",
"discriminator": "0001",
"nickname": "ali",
"isBot": false,
"avatarUrl": "media/avatars/alice.png"
},
"attachments": [
{ "id": "...", "url": "media/attachments/image.png", "fileName": "image.png", "fileSizeBytes": 204800 }
],
"embeds": [],
"reactions": [
{ "emoji": { "id": null, "name": "👍" }, "count": 3 }
],
"mentions": [
{ "id": "...", "name": "Bob" }
],
"stickers": [
{ "name": "wave", "sourceUrl": "media/stickers/wave.png" }
],
"poll": {
"question": { "text": "Favourite colour?" },
"answers": [
{ "text": "Red", "votes": 12 },
{ "text": "Blue", "votes": 8 }
]
},
"reference": null,
"isPinned": false
}
When --media is used, avatarUrl and attachment.url are relative paths within the export
directory (e.g. media/attachments/image.png). Ferry resolves these relative to the export root.
timestampEdited: If present (non-null), indicates the message was edited after original send.
Ferry prepends *(edited)* to the migrated message content, after the timestamp prefix. Example:
*[2024-01-15 12:00 UTC]* *(edited)* Hello everyone!
Stickers: If sourceUrl is a local relative path (downloaded via --media), Ferry uploads the
image as a message attachment. Lottie stickers and missing files fall back to a text placeholder
like [Sticker: wave].
Polls: Ferry renders poll data as formatted text in the message body (Stoat has no native poll
support). The output looks like: **Poll: Favourite colour?** followed by bullet-pointed options
with vote counts.
Edge Cases
Webhook and Bot Messages
Both webhook-originated and bot-authored messages have author.isBot = true. DCE does not include a
webhook_id field. Ferry treats both identically — they are imported using masquerade with the
bot/webhook's display name and avatar.
Forwarded Messages
Since DCE 2.47 (February 2026, PR #1451) a forwarded message exports its full payload in a
forwardedMessage object, and reference.type distinguishes a forward from a reply:
"reference": {
"type": "Forward", // MessageReferenceKind: "Default" (a reply) or "Forward"
"messageId": "...", "channelId": "...", "guildId": "..."
},
"forwardedMessage": {
"timestamp": "...",
"timestampEdited": "..." | null,
"content": "...",
"attachments": [ /* same shape as a top-level attachment */ ],
"embeds": [ /* same shape as a top-level embed */ ],
"stickers": [ /* same shape as a top-level sticker */ ]
}
The message's own content is usually empty — the payload lives entirely in the block.
There is no author field. Ferry therefore posts recovered content under whoever forwarded it; the original writer is not present in the export.
Ferry merges the block into the message and marks it [forwarded], so the ordinary attachment,
embed and sticker paths handle it.
Exports older than DCE 2.47 wrote neither forwardedMessage nor reference.type. Those
messages carry no recoverable content and are still skipped with a warning naming the cause.
An empty reference.type is the signal for "this export predates 2.47" and is meaningfully
different from "Default" — Ferry falls back to the old empty-content heuristic only in that case,
because the heuristic alone also matches an ordinary reply carrying just a sticker or an embed.
System Messages with Empty Content
System message types (GuildMemberJoin, ChannelPinnedMessage, etc.) often have content: "". Ferry
always checks the type field first, never skipping a message solely because content is empty.
Reply References
When type is "Reply", the reference object contains only the original message's ID:
"reference": { "messageId": "1122334455", "channelId": "...", "guildId": "..." }
It does not embed the referenced message's content. Ferry cross-references this ID against the Discord→Stoat message ID map built during the MESSAGES phase. If the referenced message was not migrated (e.g. it predates the export date range), the reply is imported as a regular message without a reply reference, and a warning is logged.