DDEX delivery
Send releases as DDEX ERN: test a batch with POST /ddex/validate, deliver with POST /ddex or direct uploads, use SFTP, and read the delivery report.
Updated
Releases come into HILLS Music as DDEX ERN batches. The API doesn’t create or edit single releases field by field. You send the same batch you would send a store, over the API or SFTP. Each release in it becomes a draft on your catalog, and goes through review and delivery to the stores as usual.
ERN versions
| Version | Status |
|---|---|
ERN 4.3 (http://ddex.net/xml/ern/43) |
Recommended |
| 4.2, 4.1 | Accepted |
| 3.8.2, 3.8.1, 3.8, 3.7 | Accepted. The report warns that the version is legacy. |
| Anything else | Rejected |
Every accepted version goes through the same checks and import. A batch is a zip with one or more NewReleaseMessage XML files and the audio, cover art and videos they point at.
Scopes
| Scope | Allows |
|---|---|
ddex:validate |
Tests only. Good for CI. |
ddex:write |
Tests, deliveries, direct uploads and delivery status. |
1. Test first
POST /ddex/validate runs every check a delivery gets: the XML, the HILLS rules, the audio, artwork and video files, UPCs and ISRCs against your catalog, genres, territories and deals. Nothing is created: no release, recording, artist, code or delivery. Send a .zip or a single .xml.
curl -X POST "https://api.hillsmusic.com/v1/ddex/validate?member_id=41" \
-H "Authorization: Bearer $HILLS_API_KEY" \
-H "Content-Type: application/zip" \
--data-binary @batch-0001.zip
import { readFile } from "node:fs/promises";
const res = await fetch("https://api.hillsmusic.com/v1/ddex/validate?member_id=41", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.HILLS_API_KEY}`, "Content-Type": "application/zip" },
body: await readFile("batch-0001.zip"),
});
const { data } = await res.json();
console.log(data.verdict, data.summary);
for (const c of data.checks.filter((c) => c.status === "error")) console.log(c.field, c.message);
member_id is the artist or label the batch is for. Leave it out for your own catalog. For an XML file, send Content-Type: application/xml. You can also send multipart/form-data with the field file (up to 512 MB).
The answer has the ERN version, a summary, every check by stage (pass, warning, error, skipped) and a verdict:
| Verdict | Means |
|---|---|
would_import |
Clean. It would become a draft. |
would_import_with_warnings |
It would import. Read the warnings. |
would_be_flagged |
It would wait for HILLS staff: a takedown, or an update of a release in review or live. |
would_be_rejected |
It has errors. Nothing would be created for that release. |
A batch with errors still answers 200. The verdict says what went wrong. A batch too large to test answers 413 too_large, and many tests at once can answer 429 busy. Every test is also listed on the DDEX page in the web app, under Test reports, for 90 days.
2. Deliver
POST /ddex takes the zip as the raw body (Content-Type: application/zip, up to 20 GB) or as the multipart field file (up to 512 MB). It answers 202 at once:
curl -X POST "https://api.hillsmusic.com/v1/ddex?member_id=41&name=batch-0001.zip" \
-H "Authorization: Bearer $HILLS_API_KEY" \
-H "Content-Type: application/zip" \
--data-binary @batch-0001.zip
# {"data":{"id":311,"status":"queued","environment":"production","status_url":"/v1/ddex/311"}}
For big files, prefer a direct upload.
Direct uploads for large batches
A direct upload sends the file straight to storage instead of through the API.
- Start.
POST /ddex/uploadswithnameandsizein bytes (up to 20 GB), andmember_idif needed. - Upload. Up to 64 MB, you get one
upload.url:PUTthe whole file to it. Above 64 MB, you getupload.part_sizeand one URL per part.PUTeach slice ofpart_sizebytes to its URL (the last slice is shorter). Parts can go in any order, and in parallel. These URLs are signed: send noAuthorizationheader to them. - Commit.
POST /ddex/uploads/{id}/commit. It checks every part arrived with the right size, then queues the batch likePOST /ddex(202).
import { open, stat } from "node:fs/promises";
const H = { Authorization: `Bearer ${process.env.HILLS_API_KEY}`, "Content-Type": "application/json" };
const API = "https://api.hillsmusic.com/v1";
const file = "batch-0001.zip";
const { size } = await stat(file);
const start = await fetch(`${API}/ddex/uploads`, {
method: "POST", headers: H, body: JSON.stringify({ name: file, size, member_id: 41 }),
}).then((r) => r.json());
const { id, upload } = start.data;
const fh = await open(file);
if (upload.url) {
await fetch(upload.url, { method: "PUT", body: await fh.readFile() });
} else {
for (const p of upload.parts) {
const offset = (p.part_number - 1) * upload.part_size;
const buf = Buffer.alloc(Math.min(upload.part_size, size - offset));
await fh.read(buf, 0, buf.length, offset);
const r = await fetch(p.url, { method: "PUT", body: buf });
if (!r.ok) throw new Error(`part ${p.part_number}: ${r.status}`);
}
}
await fh.close();
const commit = await fetch(`${API}/ddex/uploads/${id}/commit`, { method: "POST", headers: H });
console.log(commit.status, await commit.json()); // 202, then poll status_url
Part URLs work for 6 hours. GET /ddex/uploads/{id} gives fresh URLs for the parts still missing, with received_parts and parts_total. A commit with parts missing answers 409 upload_incomplete and lists missing and wrong_size: send those, then commit again. DELETE /ddex/uploads/{id} drops an upload you won’t finish. Uncommitted uploads are removed after 7 days.
Over SFTP
Each DDEX inbox is one SFTP login with two folders. You get the login with your inbox, on the DDEX page in the web app.
| Folder | What happens |
|---|---|
/production/<batch>/ |
Delivered. Once the marker arrives, the batch is imported. |
/test/<batch>/ |
Tested, like POST /ddex/validate. Nothing is created. The report is written next to it: /test/<batch>.report.txt and .report.json. |
Upload the XML and its files into the batch folder, then the marker last: BatchComplete_*.xml, or any *.complete file. A single /test/<file>.xml or .zip is tested too. Any other file in /test, such as ping.txt, is a connection test. A production batch folder that never gets its marker is removed after 7 days. Whatever sits in /test goes after the same time. See Test folder.
3. Read the delivery report
Poll GET /ddex/{id} every 10–30 seconds, or subscribe to the ddex.import.imported webhook. The status moves queued → parsing → validating → importing, then ends as:
done: every release landed;partial: some releases were left out;failed: nothing was imported.
curl "https://api.hillsmusic.com/v1/ddex/311" \
-H "Authorization: Bearer $HILLS_API_KEY"
The answer has counts and one item per release, each with its outcome, release_id and errors (each error names its DDEX field). GET /ddex lists your last 100 deliveries.
What gets created or flagged
| Outcome | When |
|---|---|
created |
A new release. It becomes a draft on member_id’s catalog. |
updated |
The UPC is already a draft (or a release sent back for changes) there. That draft is updated. |
flagged |
A takedown, or an update of a release in review or live. It isn’t applied. HILLS staff look at it. |
left_out |
The release had errors. Nothing was created for it. Fix it and deliver again. |
test |
A message marked as a TestMessage: checked, nothing created. |
An ISRC you already have reuses that recording. An ISRC that belongs to another account leaves the release out. Drafts then go through review as usual: submit them in the web app, or ask HILLS Music to turn on auto-submit for your inbox. Follow them with GET /releases and webhooks such as release.approved and release.live.
Full parameters are in the DDEX deliveries reference.