Skip to content
HILLS API v1 Early access

Guides

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.

  1. Start. POST /ddex/uploads with name and size in bytes (up to 20 GB), and member_id if needed.
  2. Upload. Up to 64 MB, you get one upload.url: PUT the whole file to it. Above 64 MB, you get upload.part_size and one URL per part. PUT each slice of part_size bytes to its URL (the last slice is shorter). Parts can go in any order, and in parallel. These URLs are signed: send no Authorization header to them.
  3. Commit. POST /ddex/uploads/{id}/commit. It checks every part arrived with the right size, then queues the batch like POST /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.

Ready to integrate?

Request API access and we get back to you within 1–3 business days.