Deliver DDEX over SFTP
Every organization gets its own SFTP endpoint. Drop a delivery directory into it and //SCHEMA ingests the message and matches its media, in whatever order they arrive.
This is the path a distributor or label already knows how to use: put the ERN XML and its audio and artwork in a directory, upload the directory, done. Nothing calls an API, and nothing has to be sequenced. To deliver the same files from code instead, see Deliver DDEX through the API.
Your organization's endpoint#
Every organization gets its own SFTP endpoint — its own reserved IP
address and its own SSH host key. The address does not change for as long as
you have the endpoint, so partners can allow it through their firewalls. It is not a shared host that partners are
partitioned inside, which matters for two reasons: usernames are yours to
choose (two organizations may both have a lanternfish without colliding), and
the host key a partner pins belongs to you alone.
catalog gives you the address and port to hand out:
query {
catalog { sftpHost sftpPort }
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "query {\n catalog { sftpHost sftpPort }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query {
catalog { sftpHost sftpPort }
}
`,
}),
});
const { data, errors } = await res.json();{ "data": { "catalog": { "sftpHost": "203.0.113.24", "sftpPort": 22 } } }A null sftpHost means the endpoint has not been provisioned yet. It is
created the first time you issue a delivery account, so issue one and check
again in a few minutes.
Get a login#
Deliveries are authenticated as a delivery account — one per partner, not
one per organization — using either an SSH key or a password. Issuing them is
covered in
Manage SFTP delivery accounts; you need
config_write.
Which account uploaded a file decides which catalog the delivery lands in, so an organization with a production and a sandbox catalog gives out separate accounts rather than separate directories.
One directory per delivery#
Every account is chrooted to its own home, with a single writable directory
called upload/. Inside it, each delivery is one directory, holding the
ERN XML and every file it references:
upload/
└── 20260917_0196871402293/ ← the delivery, named by you
├── 0196871402293.xml
├── resources/
│ ├── A1_Signal_Fire.flac
│ └── A2_Nightcall_Ferry.flac
└── images/
└── front.jpgTwo rules about that layout, and both are load-bearing:
The directory name is the delivery namespace. It is what ties a message to its files, so one delivery per directory, and a name you do not reuse — a date and the UPC, a batch id from your own system, anything unique to you. Reusing a name can let a leftover file from the earlier batch satisfy an identically named path in the later one.
Paths inside the directory must match the paths in the XML. A <File>
element giving resources/A1_Signal_Fire.flac is matched against exactly that
path below the delivery directory. Subdirectories are fine and are part of the
path; the delivery directory itself is not.
done is reserved — see below — so a delivery cannot be called that.
Upload it#
sftp -P 22 [email protected]
sftp> put -r 20260917_0196871402293 upload/Or non-interactively, which is what a nightly job usually wants:
rsync -av -e 'ssh -p 22' \
20260917_0196871402293 \
[email protected]:upload/Each file is picked up when your client closes it, not when it appears, so a partly written file is never read and there is no need to upload to a temporary name and rename. There is also no ordering requirement: the XML may go up before its media or after it, in the same session or a day later, and the delivery converges on the same result either way. See Out-of-order delivery for what happens in between.
These file types are ingested:
| Message | .xml |
| Audio | .wav .flac .mp3 .aiff .aif .m4a .ogg .aac |
| Image | .jpg .jpeg .png .gif .webp .tiff .tif |
| Video | .mp4 .mov .avi .mkv .m4v |
Anything else — a checksum manifest, a .DS_Store, a PDF one-sheet — is set
aside rather than ingested, and does not hold the delivery up.
What the directory tells you#
Watch the delivery directory to see how far it has got. Each file is moved to
upload/done/<delivery>/ once it has been handled, and when the last one goes
the now-empty delivery directory is removed.
So:
- The delivery directory is gone. Every file was handled. That is the filesystem's version of "complete".
- Files are still sitting in it. Either they have not been processed yet, or processing failed and they are being retried. A file that keeps failing stays where it is rather than being silently discarded.
- Files appear under
done/. They were handled. Unknown file types land here too — being set aside is not the same as being ingested.
Re-uploading a delivery is always safe. //SCHEMA deduplicates on the message and on file content, so a repeated delivery is recorded as a duplicate and changes nothing.
Check it landed#
messages lists recent deliveries, from SFTP and the API alike, with the status of each:
query {
messages(limit: 5) {
fileName
ddexMessageId
status
failureReason
dateReceived
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "query {\n messages(limit: 5) {\n fileName\n ddexMessageId\n status\n failureReason\n dateReceived\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query {
messages(limit: 5) {
fileName
ddexMessageId
status
failureReason
dateReceived
}
}
`,
}),
});
const { data, errors } = await res.json();{
"data": {
"messages": [
{
"fileName": "0196871402293.xml",
"ddexMessageId": "LANTERNFISH_20260917_0196871402293",
"status": "partial",
"failureReason": null,
"dateReceived": "2026-09-17T02:14:09+00:00"
}
]
}
}partial means the message is in and //SCHEMA is still waiting for files —
the usual state a few seconds into an upload. It becomes ingested when the
last one lands; failed means the XML could not be read, and failureReason
says why.
A delivery stays out of the catalog until every file it references has arrived and it has cover art. blockedReleases says what is still held back and why, and outstandingFiles names the exact paths still being waited on — which is the fastest way to find a path that does not match the XML:
query {
outstandingFiles {
deliveryNamespace
relativeUri
title
}
}curl https://api.schema.fm/graphql \
-H "Authorization: Bearer $SCHEMA_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'EOF'
{
"query": "query {\n outstandingFiles {\n deliveryNamespace\n relativeUri\n title\n }\n}"
}
EOFconst res = await fetch("https://api.schema.fm/graphql", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SCHEMA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: `
query {
outstandingFiles {
deliveryNamespace
relativeUri
title
}
}
`,
}),
});
const { data, errors } = await res.json();When nothing happens at all#
| Symptom | Cause |
|---|---|
| Connection refused, or the host does not resolve | The endpoint is not provisioned. Check sftpHost is not null. |
| Permission denied writing to the login directory | Everything is uploaded inside upload/; the directory above it is not writable, as SSH requires. |
Files stay put and never reach done/ | The account was revoked. Files are left exactly where they landed rather than being guessed into a catalog. |
| The message ingests, the release never appears | A file is missing or its path does not match the XML. Check outstandingFiles. |
| The files vanish but no release appears | Check messages for a parse or mapping error on that delivery. |
See also#
- Manage SFTP delivery accounts — issuing logins, keys and rotation.
- Deliver DDEX through the API — the same pipeline, over HTTPS.
- Out-of-order delivery — how messages and files find each other.