Myelin API · v1 · 1.7.9

Push a delivery from your pipeline

Six commands, or a handful of REST calls. Check a folder against your sponsor’s rules before uploading a byte, upload with resume, hand over for review. Your files land in the sponsor’s own cloud bucket. No SDK to adopt: plain REST plus presigned S3 PUTs.

bash
export MYELIN_API_KEY=myl_live_…      # your sponsor creates it for you and sends it

npx @myelinbridge/cli ping                                          # 1. the key works, and what it can reach
npx @myelinbridge/cli datasets                                      # 2. what you can deliver to — prints your real slug
npx @myelinbridge/cli contract --dataset onco1-wes                  # 3. what the sponsor expects  (onco1-wes: placeholder)
npx @myelinbridge/cli check ./run_042 --dataset onco1-wes           # 4. their checks on your folder, nothing uploaded
npx @myelinbridge/cli push  ./run_042 --dataset onco1-wes --submit  # 5. upload, then hand over — prints the delivery name
npx @myelinbridge/cli status ONCO1-WES-001 --watch                  # 6. follow the review         (that name)

That is the whole path. check uploads nothing: it sends your file list, and the same engine that runs at submit answers. Exit code 2 means something blocks the delivery, and the output says what. push is safe to re-run: a crashed upload resumes with the same command. status tells you what to fix when the reviewer asks for changes. myelin help <command> explains any of them, and works before you have a key.

What you will see

An example session, human mode. Your names, rules and counts will differ; the shape will not. Every command also takes --json.

$ myelin ping
✓ key "pipeline" (myl_live_Yxwfeh…) · bridge Acme ↔ Genos CRO · 1 project(s)
Next: myelin datasets

$ myelin datasets
PROJECT  DATASET    STATUS  YOUR MOVE?
ONCO1    onco1-wes  active  —
Next: myelin contract --dataset onco1-wes

$ myelin contract --dataset onco1-wes
WES cohort — delivery contract v3
6 checks · 3 block validation · 5 checkable before upload · 1 reviewed by a person

COMPLETENESS — is everything here?
  ! a samplesheet is present at the delivery root
  ! every sample in the samplesheet has R1 and R2 reads
STRUCTURE — is it laid out as agreed?
  · file names follow {sample}_{lane}_{R1|R2}.fastq.gz
INTEGRITY — are the bytes what you say they are?
  ! every file matches your checksum manifest (checked at submission)
REVIEWER JUDGEMENT — decided by a person, not the engine
  · the consent scope covers this cohort

Run "myelin check <dir> --dataset onco1-wes" to test 5 of these locally.

$ myelin check ./run_042 --dataset onco1-wes
Evaluating 129 files (26.1 GB) against quality checks v3…
✓ samplesheet present          passed
✗ paired reads complete        failed — BLOCKING
    hint: S07 has R1 but no R2 — add S07_L001_R2.fastq.gz or drop S07 from the samplesheet
✓ file naming                  passed
… checksum manifest matches    reads file contents; runs at submit
○ consent scope                a reviewer decides this
1 blocking issue(s). Fix before pushing to avoid a review round-trip.
$ echo $?
2

$ myelin push ./run_042 --dataset onco1-wes --submit        # after the fix
Draft ONCO1-WES-004 created.
Uploading 130/130 files (26.1 GB) · resumable
  ✓ /samplesheet.csv (1/130)
  ✓ /S01/S01_L001_R1.fastq.gz (2/130)
  …
All files confirmed.
Submitting… auto-checks: 5 passed, 0 flagged, 0 failed of 6.
✓ Batch submitted for review. Track: myelin status ONCO1-WES-004 --watch

$ myelin status ONCO1-WES-004 --watch
ONCO1-WES-004: submitted — reviewer's move.
ONCO1-WES-004: in_review — reviewer's move.
ONCO1-WES-004: transferred — delivered to your sponsor’s bucket.

Before you start

  • Your key is created by your sponsor’s bridge owner, in Bridge → API. It starts with myl_live_ and is shown once.
  • Three things exist on the sponsor’s side before you can deliver, all created by them: an active bridge (their organisation and yours), a project your key is scoped to, and an activated dataset inside it, the thing you deliver to. If datasets prints nothing, one of them is missing; ask your sponsor contact, naming which.
  • A delivery (a batch in the API) is one run of files against one dataset. It starts as a draft, one per dataset, and leaves your hands at --submit. From then on it is the reviewer’s move: a person on your sponsor’s side opens it in their portal and either validates it, after which Myelin copies the files into their bucket, or asks for changes. The turnaround is theirs; Myelin notifies them.
  • One delivery at a time per dataset. While a delivery is submitted or in review, push to that dataset exits 2 with delivery_locked until the reviewer validates it or sends it back; recall takes it back while it is still submitted. A draft you did not submit stays open, and a later push of a different folder joins it. If you deliver more often than your sponsor reviews, agree a turnaround with them, or ask for one dataset per run.

Where your files are, and for how long

Uploads land in Myelin’s holding area, Supabase Storage in the EU (Ireland), never in your sponsor’s bucket. There they can be read by you, by your sponsor’s reviewers on that project, and by the transfer worker’s service identity; Myelin’s own operator role has no customer-data access. Once the reviewer validates, the files are copied into the sponsor’s bucket and the copy is verified. The holding copy is purged after that verified delivery, or after a rejection, plus a grace window of seven days by default, and each purge writes an audit row. An empty draft with no activity for 24 hours is purged; a draft holding uploaded files is kept, and its owner reminded. The full position, including what is never sent to the bucket, is on /security.

There is no self-serve test environment. To rehearse without real data, ask your sponsor for a dataset created for that purpose, or ask us for a sandbox. Questions about the setup on the sponsor’s side go to your sponsor contact; questions about the API go to hello@myelinbridge.com.

Two optional declarations

sample-depth is the folder depth a sample sits at. The default, 1, means each top-level folder is one sample; set it only if your layout differs. It locks once your first delivery leaves draft, so if you are not sure which depth your sponsor expects, ask before the first submit. roles says which file is the samplesheet, the checksum manifest, the QC report. Nothing is required to deliver; a role you declare turns on the checks that read that file, so more of the contract is verified before review instead of after.

The CLI

bash
npx @myelinbridge/cli version      # no install: npx fetches it on each run
npm i -g @myelinbridge/cli           # or install once; the binary is `myelin`
myelin help

@myelinbridge/cli is the reference client for the API on this page: one file, no dependencies, Node 20 or later, version 0.16.0 on npm. Every command is one or more of the calls in the delivery flow below, so anything it does, your own code can do. <ds> is a dataset slug or id, as datasets prints them.

pingPartnerChecks the key and prints the bridge and the projects it can reach.
datasetsPartnerLists the datasets you can deliver to, and whose move it is.
contract --dataset <ds>PartnerEvery check the dataset expects, one sentence each: ! blocks validation, ~ is confirmed by the sponsor’s reviewer rather than fixed by you. Says which checks check can run locally.
check <dir> --dataset <ds>PartnerRuns the dataset’s checks on your local file list, server-side, without uploading. Exit 2 when a blocking check fails or could not be evaluated.
push <dir> --dataset <ds> [--submit] [--replace]PartnerCreates or resumes the dataset’s draft and uploads what has not landed yet. --submit hands it to review. --replace sends the new version of a path already on the draft, a different size or the same size with different content: files already uploaded are compared by MD5 and only the changed ones are sent. Use it after changes_requested; without it a different size exits 2 and a same-size file already uploaded is skipped as unchanged.
status <batch> [--watch]PartnerWhere the delivery stands and whose move it is; on changes_requested, the flagged files, the reviewer’s comments and a hint per failed rule. Takes an id or a display name. --watch polls every 20 s and returns at the first final state: exit 0 transferred, 2 changes_requested, 1 rejected or transfer_failed.
recall <batch>PartnerTakes a submitted delivery back before the review starts; it becomes a draft again. Once a reviewer has opened it, recall_failed.
projectsPartnerThe projects this key is scoped to, with their status.
sample-depth <0-5> --dataset <ds>PartnerThe folder depth a sample sits at: 0 = the delivery root is one sample, 1 = each top-level folder (default), 2 = one level deeper. Optional; locks once a delivery leaves draft.
roles [set <role> <path> | none <role> | clear <role>] --dataset <ds>PartnerShows, declares or clears which file plays which role: samplesheet, checksum_manifest, qc_report, subject_roster, capture_bed. Declaring one turns on the checks that read it. Optional; never locks.
sandbox <file>PartnerUploads one file under 4 MB as the partner-side test transfer your sponsor’s bridge-activation checklist asks for. Not a test environment, and not a step before push.
deliveriesSponsorWhat landed in your bucket, newest first. Filters: --project, --dataset, --since <iso>, --limit; page with --cursor.
resolve <path|prefix|id>SponsorA gs:// or s3:// path, a bare prefix or an id, back to the project, dataset, delivery and file it belongs to.
versionPrints the CLI version.
help [<command>]This list, or one command in detail. Works without a key.

From a script

bash
# every line is safe to repeat
myelin check "$OUT" --dataset onco1-wes || exit $?   # 2: something blocks the delivery; the output says what
myelin push  "$OUT" --dataset onco1-wes --submit
myelin status ONCO1-WES-004 --watch                  # or a webhook, below
# on changes_requested (--watch exits 2): fix, then re-run the push with --replace —
# without it, a corrected file of the same size is taken as unchanged

Every command takes --json: one JSON object on stdout, no prose. Exit codes: 0 ok · 1 error · 2 blocked, meaning a blocking preflight rule failed or could not be evaluated, a declared path conflicts, or the API refused on the delivery’s state or the sponsor’s setup (delivery_locked, submit_blocked, dataset_not_active, api_disabled, bridge_paused, sample_depth_locked). An API error in --json mode is { "error", "code", "status", "request_id" }: branch on code, quote request_id. The id is printed in human mode too.

What a script should know. On Windows, setx MYELIN_API_KEY … then open a new terminal, or $env:MYELIN_API_KEY = "…" in PowerShell. An unknown option is an error, exit 1, before a byte moves. Pin the version you tested, npx @myelinbridge/cli@0.16.0; bare npx runs whatever is latest today. There is no client-side timeout on API calls, by design (a preflight of 10 000 files takes minutes): bound the command with your runner’s timeout if you need one; a connection refused or a DNS failure is reported at once and not retried, a 429, a 503 rate_limit_unavailable and a failed part upload are. status --watch exits 0 at transferred, 2 at changes_requested, 1 at rejected or transfer_failed; if the runner dies, re-run it, the state is server-side. The --json shape of each command is in the README on npm.

What push does between two runs

Re-run to resumeA file already uploaded at the same path and size is skipped (under --replace, at the same path with the same MD5), and inside a large file, parts that already landed are skipped too.
A rate limitA 429, or a 503 rate_limit_unavailable, waits out Retry-After (capped at 60 s) and retries, on every command, four times before the error surfaces.
A part that failsOn a 5xx, an edge timeout or an expired URL, the part is re-signed and retried with backoff, four attempts, before the error surfaces.
A slow uplinkWhen the same part dies twice on a timeout, the file restarts with smaller parts, 64 → 16 → 5 MiB, instead of retrying the same slice into the same wall.
ChecksumsAn MD5 of each file is computed while it uploads and recorded with the delivery, so a checksum-manifest check verifies against your own md5_manifest.csv rather than reading as unverifiable.
A retried --submitIf the first attempt actually landed, the retry is answered with already_submitted: true, not a failure.
Without --submitThe draft stays open, and there is one draft per dataset. Re-run with --submit once it is complete.

Environment

MYELIN_API_KEYRequired. A partner key for the partner commands (Bridge → API), a sponsor key for deliveries and resolve (Organisation → API keys).
MYELIN_API_URLOptional. Defaults to https://myelinbridge.com/api/v1.
MYELIN_API_HEADEROptional. Extra headers on every call, one Name: value per line, for a deployment fronted by something that authenticates before Myelin does (a corporate gateway, an SSO-protected preview). It can never override Authorization.

From your code, without an SDK

There is no client library to adopt, and you do not need one. The whole delivery is plain REST plus presigned S3 PUTs. Here it is end to end in Python, with nothing but requests.

python
import os, pathlib, requests

API  = "https://myelinbridge.com/api/v1"
ROOT = pathlib.Path("run_042")

s = requests.Session()
s.headers["Authorization"] = "Bearer " + os.environ["MYELIN_API_KEY"]

def api(method, path, **kw):
    r = s.request(method, API + path, **kw)
    if not r.ok:                                   # RFC 9457 problem+json
        p = r.json()
        raise SystemExit(f"{p['code']}: {p['detail']}  (request {p['request_id']})")
    return r.json()

# 0 — find the dataset you were asked to deliver to, by its slug.
DATASET = next(
    d["id"]
    for project_id in api("GET", "/ping")["projects"]
    for d in api("GET", f"/projects/{project_id}/datasets")["datasets"]
    if d["slug"] == "onco1-wes"
)

manifest = [
    {"path": str(p.relative_to(ROOT)).replace("\\", "/"), "size_bytes": p.stat().st_size}
    for p in sorted(ROOT.rglob("*")) if p.is_file()
]

# 1 — hold the delivery to the sponsor's rules. Nothing has been uploaded yet.
#     Two things stop a reviewer validating, not one: a blocking rule that
#     FAILED, and a blocking rule the engine could not evaluate at all.
pf = api("POST", f"/datasets/{DATASET}/preflight", json={"files": manifest})
if pf["blocking_failures"] + pf["blocking_not_evaluated"]:
    for c in pf["evaluated"]:
        if c["verdict"] == "failed":
            print(c["name"], "->", c["remediation"])
        elif c["verdict"] == "not_evaluated" and c["severity"] == "blocking":
            # not a pass: nobody looked. 'rule' = the sponsor's rule is broken.
            why = c["details"].get("abstained")
            print(c["name"], "-> not checked;",
                  "their rule, not your delivery" if why == "rule"
                  else "supply what it needs and it runs next time")
    raise SystemExit(2)

# 2 — declare the folder depth a sample sits at. Once, before your first
#     submit: idempotent (assert it every run), locks when a batch leaves draft.
api("POST", f"/datasets/{DATASET}/sample-depth", json={"sample_depth": 1})

# 3 — create or resume THE draft. Re-running this script resumes it.
batch = api("POST", f"/datasets/{DATASET}/batches")["batch_id"]

# 4 — declare what you are sending. Idempotent: same path + size = no-op.
declared = api("POST", f"/batches/{batch}/files", json={"files": manifest})
part_size = declared["upload"]["part_size"]

# 5 — push the bytes straight to storage over presigned URLs.
#     URLs live minutes: for a file with many parts, sign in windows and
#     re-request any URL that expires — upload-parts is idempotent and
#     reports parts that already landed, so a crashed run resumes here.
for f in declared["files"]:
    src = ROOT / f["path"]
    parts = max(1, -(-src.stat().st_size // part_size))
    signed = api("POST", f"/files/{f['file_id']}/upload-parts",
                 json={"part_numbers": list(range(1, parts + 1))})
    etags = {p["part_number"]: p["etag"] for p in signed["uploaded_parts"]}   # resume
    with src.open("rb") as fh:
        for u in signed["urls"]:
            fh.seek((u["part_number"] - 1) * signed["part_size"])
            r = requests.put(u["url"], data=fh.read(signed["part_size"]))
            r.raise_for_status()
            etags[u["part_number"]] = r.headers["ETag"]
    api("POST", f"/files/{f['file_id']}/confirm", json={
        "upload_id": signed["upload_id"],
        "parts": [{"part_number": n, "etag": e} for n, e in sorted(etags.items())],
    })

# 6 — hand it over. The sponsor's auto-checks run here and come back inline.
#     Safe to retry: a lost response re-answers 200 with already_submitted=true.
print(api("POST", f"/batches/{batch}/submit")["auto_checks"])

The same flow in shell:

bash
KEY="Authorization: Bearer $MYELIN_API_KEY"
B="https://myelinbridge.com/api/v1"

# what can this key reach?
curl -sH "$KEY" "$B/ping"

# find the dataset you deliver to — list each project's datasets, pick by slug
P=$(curl -sH "$KEY" "$B/ping" | jq -r '.projects[0]')
DATASET=$(curl -sH "$KEY" "$B/projects/$P/datasets" \
  | jq -r '.datasets[] | select(.slug=="onco1-wes") | .id')

# what is expected of the delivery?
curl -sH "$KEY" "$B/datasets/$DATASET/quality-checks" | jq '.checks[] | {name, runs_at, severity}'

# declare your sample depth once — idempotent, locks at first submit
curl -sH "$KEY" -H 'Content-Type: application/json' "$B/datasets/$DATASET/sample-depth" \
  -d '{"sample_depth":1}'

# would it pass? (nothing uploaded)
curl -sH "$KEY" -H 'Content-Type: application/json' "$B/datasets/$DATASET/preflight" \
  -d '{"files":[{"path":"samplesheet.csv","size_bytes":2048}]}' \
  | jq '{blocking_failures, blocking_not_evaluated, counts}'

# create or resume the draft, declare a file
BATCH=$(curl -sH "$KEY" -X POST "$B/datasets/$DATASET/batches" | jq -r .batch_id)
curl -sH "$KEY" -H 'Content-Type: application/json' "$B/batches/$BATCH/files" \
  -d '{"files":[{"path":"samplesheet.csv","size_bytes":2048}]}'

# ... upload_parts -> PUT each presigned url -> confirm ...

curl -sH "$KEY" -X POST "$B/batches/$BATCH/submit"

Prefer types? The OpenAPI 3.1 spec carries request and response schemas for every operation, so a typed client is one command away in most languages:

bash
npx openapi-typescript https://myelinbridge.com/api/v1/openapi.json -o myelin.d.ts
uvx openapi-python-client generate --url https://myelinbridge.com/api/v1/openapi.json

The delivery flow

Every call carries Authorization: Bearer myl_live_…. A key is scoped to one bridge and a chosen set of projects, and behind it stands a named service identity that appears in member lists and in the tamper-evident audit trail. Keys are shown once and rotate in one click.

http
POST /v1/datasets/{id}/preflight     0. hold a file list to the rules. No upload, exit early
POST /v1/datasets/{id}/batches       1. create or resume THE draft (one per dataset)
POST /v1/batches/{id}/files          2. declare files (≤500/call) → storage paths + part size
POST /v1/files/{id}/upload-parts     3. presigned URLs for the parts you still need
PUT  {presigned url}                 4. push each part to Myelin's EU holding area. Resumable
POST /v1/files/{id}/confirm          5. finalize; the server verifies the declared size
POST /v1/batches/{id}/submit         6. runs the auto-checks, enters review
GET  /v1/batches/{id}/findings       7. on changes_requested: every rule and how it landed

The fix loop is machine-readable. A finding resolves its rule name, the assertion the rule actually made, and a remediation hint, so a pipeline can print something a human can act on instead of a UUID and the word failed. Fix, re-declare the changed files with replace: true, upload every file that does not come back uploaded, re-submit. A file re-declared that way is re-queued unless its checksum proves it unchanged, so a correction that kept the size is never taken for the old file, and the reviewer’s earlier vote on it is marked stale. Files you did not re-send keep their review votes. A file re-sent without a checksum always counts as changed, so send its MD5 (at declare or at confirm) and an identical re-send keeps its votes too.

A check has five answers, and an abstention is not a pass. Beside passed, flagged and failed, a rule can come back not_evaluated — the engine could not look — or not_applicable — the rule matched nothing you sent. failed means one thing only: we looked, and the answer is bad. A pipeline that keeps the rules where verdict == "failed" and treats the rest as clean reads “nobody checked this” as “this is fine”. Branch on all five, and read details.abstained on an abstention: data means declare the file it needs with roles set and it runs next time; rule means the sponsor’s own rule is broken and there is nothing on your side to fix.

A delivery that is submitted or in review is locked (409 delivery_locked). Take it back with POST /v1/batches/{id}/recall (myelin recall) while it is still submitted, or wait for the reviewer.

Errors and limits

Errors are RFC 9457 application/problem+json with a stable code. Branch on code, never on detail: the prose may change, the code will not. Every response echoes Myelin-Request-Id; quote it if you ask us anything.

json
{
  "type": "https://myelinbridge.com/developers#errors",
  "title": "submit_blocked",
  "status": 422,
  "code": "submit_blocked",
  "detail": "3 files have not finished uploading, so the delivery cannot be submitted yet. Upload each one again, or delete them: in the portal, drop them back into the file list (unfinished files are marked \"not uploaded\"); over the API or CLI, upload their parts and confirm each.",
  "request_id": "2f0e5b1c-6a44-4f3e-9c17-8d5b0a2e7f31"
}
invalid_api_key401Missing, revoked or expired. Not retryable.
wrong_key_side403A partner key hit a sponsor endpoint, or the reverse.
api_disabled403The sponsor switched this bridge’s API access off.
project_out_of_scope403A project_id filter names a project this key is not scoped to.
rate_limited429Over 120 req/min. Honour the Retry-After header.
rate_limit_unavailable503The limiter could not count the call, so nothing was done. Wait out Retry-After and resend.
not_found404Outside this key’s scope. Never 403, so ids cannot be probed.
invalid_body400detail names the offending field.
too_many_files400Over 500 per declare, or 10 000 per preflight. Chunk it.
path_conflict409Same path, different size or checksum. Re-send with replace: true.
delivery_locked409The delivery left your court. Recall it, or wait.
dataset_not_active403The dataset does not accept deliveries yet. A sponsor-side switch — see below.
size_mismatch409What landed is not the size you declared.
submit_blocked422Rolled back. Usually files still queued.
sample_depth_locked409A batch already left draft, so the depth is frozen.

Limits

Rate limit120 requests/min per key. Part uploads go direct to storage and do not count.
Declare500 files per call.
Preflight10 000 files per call. Read-only, no audit event.
Part size64 MiB, and at most 1 000 URLs signed per call. Read part_size, do not hardcode it.
Presigned URL lifeMinutes. Re-sign the parts you have not pushed yet.
Per-file sizeUp to 200 GiB delivered to a Google Cloud Storage destination; declare accepts up to 250 GiB there and refuses a larger file. 20 GiB to Amazon S3. The web portal stops at 20 GiB: above that, use the CLI or the API, and send an md5 on confirm.
Files per delivery25 000. Submit refuses above that.
Sandbox test upload4 MB in practice (the platform caps request bodies below the nominal 10 MB). It proves bridge connectivity during activation — it is not a test environment.

Sizing a job: parts are 64 MiB and four upload in parallel, and a clean link sustains on the order of 100 MB/s, so a 100 GB run takes around twenty minutes. Your uplink is the limit.

Webhooks, or polling if you cannot expose a receiver

Register an HTTPS endpoint in the portal or with POST /v1/webhook-endpoints, and Myelin pushes signed events: batch.review_started, batch.changes_requested, batch.validated, batch.transferred, batch.rejected, batch.transfer_failed, dataset.activated, dataset.declaration_requested, comment.posted.

javascript
// header: Myelin-Signature: t=<unix>,v1=<hex>
import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(secret, header, rawBody, toleranceSec = 300) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return v1.length === expected.length &&
         timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}
python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=") for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Deliveries retry at +5 min, +30 min and +24 h. Deduplicate on Myelin-Event-Id. POST /v1/webhook-endpoints/{id}/test sends a synthetic test.ping so you can prove your signature check before a real delivery depends on it.

No public receiver? Poll GET /v1/events?cursor=… instead, and pass next_cursor back verbatim. It carries microsecond precision that a millisecond round-trip would truncate, which re-delivers the last row of the previous page.

If you are the sponsor, not the partner

Everything above is the partner side. If you are the sponsor, the data lands in your bucket under Myelin’s ids, and a sponsor key turns those ids back into what they mean. It is read-only and organisation-wide: one key covers every partner you work with, so “what arrived?” is one call rather than one per bridge. Your organisation admin creates it in Organisation → API keys. To narrow it to one partner, scope it to that partner’s projects.

http
GET /v1/deliveries                     what has landed. Filter by project, dataset, since
GET /v1/deliveries/{batchId}           the full record: reviewer, rules that ran, verdicts, files
GET /v1/deliveries/resolve?path=…      a path from your bucket → project / dataset / batch / file
bash
npx @myelinbridge/cli resolve gs://acme-landing/inbox/acme-cro/4319…/de99…/7aa5…/data/SAMPLE_01/reads.fastq.gz

file · gs://…/reads.fastq.gz
  project   ONCO1 — Oncology discovery
  dataset   WES cohort (Genomics)
  batch     ONCO1-WES-007 · #7
  validated 2026-08-07T18:05:12Z · delivered 2026-08-07T18:06:20Z
  manifest  gs://…/7aa5…/_myelin/manifest.json
  file      reads.fastq.gz · 4096 bytes

Every delivery also carries that record as a file, written next to the data at _myelin/manifest.json, with _myelin/files.csv beside it as a flat table you can load directly. Reach for the file when it has to be auditable. It is frozen at delivery time, needs no credential, and does not depend on Myelin being reachable. Reach for the API when you want it live.

The SHA-256 of both files is written into the delivery’s batch.transferred audit row (manifest_sha256, files_csv_sha256), and manifest.json carries the digest of files.csv in integrity.files_csv_checksum. The audit trail is hash-chained and refuses UPDATE and DELETE, so a receipt edited in your bucket no longer matches it; export the trail for the batch to compare. That comparison goes through this API today — there is no offline verification yet.

manifest_version is 5. The project and dataset blocks carry metadata, the fields the sponsor’s organisation defined on its own records, frozen at submit so the receipt describes the delivery as reviewed. quality.summary counts every rule the document lists, including the two abstention counts and no_verdict, and its seven numbers sum to total, so failed == 0 does not mean “everything passed”. Per-file checksums are {algorithm, encoding, value}, hex, directly comparable with the checksum declared at ingestion. Read algorithm rather than assuming it: it is md5 today and will not always be. On the live API the per-file checksum is null: that endpoint does not re-read your bytes and will not report a digest it did not compute. Each file also carries declared_checksum_verification: verified means Myelin recomputed your declared checksum against the delivered bytes, at every file size, and it matched; declared_only means it was not recomputed — you declared a checksum in an algorithm Myelin does not verify (only md5 and sha256today), or the transfer resumed from a checkpoint recorded before this check existed — so the value is your word, unconfirmed; not_declared means no checksum was declared for the file at all. Read this field rather than assuming every checksum in the receipt was checked — a delivered receipt never carries null here; only the live endpoint does, for the reason above.

Each file also says whether your bucket itself confirmed the delivered checksum. delivered_checksum_confirmation is destination when the bucket’s own digest of the object was read back after the write and matched: on Google Cloud Storage for every object, on AWS for an object of 32 MiB or less that is not under SSE-KMS. It is in_transit when the bucket returned no digest to compare, which on AWS is every object above 32 MiB and anything under SSE-KMS: the checksum is then the one Myelin computed over the bytes it sent, the object was matched on exact size, and every upload request carried a CRC32 that AWS validated on arrival. The field is in manifest.json only; files.csv has no column for it. An older receipt may not carry it: read its absence as unknown, never as destination. The live endpoint returns null here, for the reason above.

A key from one side calling the other gets 403 wrong_key_side. Disabling a bridge’s API access stops partner keys only, so your own read keys keep working.

Endpoint reference

Generated from the spec served at /api/v1/openapi.json (1.7.9). Open any row for its parameters, request body, a real response and the error codes it can return. The Partner and Sponsor chips say which key side the endpoint serves; the spec’s x-key-side names them partner and client.

Identity

Prove the key works and see what it reaches.

get/v1/pingPartner+

Verify the key and list the projects it reaches.

The first call to make. Answers in one round trip whether the key is live, which bridge it stands on, and which project ids it is scoped to. Costs nothing and writes nothing.

200 response

{
  "ok": true,
  "key": {
    "name": "nightly-pipeline",
    "prefix": "myl_live_9f2a"
  },
  "bridge": {
    "id": "0b7d1c33-4e58-4a90-8f21-77d0b9e4a512",
    "name": "Acme ⇄ Acme CRO"
  },
  "projects": [
    "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88"
  ]
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

get/v1/mePartner+

Key identity, its bridge, and the scoped projects with their titles.

Like /ping but with names attached, plus the key's expiry and the bridge's lifecycle and connection mode.

200 response

{
  "key": {
    "name": "nightly-pipeline",
    "prefix": "myl_live_9f2a",
    "expires_at": null
  },
  "bridge": {
    "id": "0b7d1c33-4e58-4a90-8f21-77d0b9e4a512",
    "name": "Acme ⇄ Acme CRO",
    "lifecycle": "active",
    "connection_mode": "api"
  },
  "projects": [
    {
      "id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
      "title": "Oncology discovery",
      "code": "ONCO1",
      "status": "active"
    }
  ]
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

Discovery

Turn slugs and names into the ids the rest of the API takes.

get/v1/projectsPartner+

The projects inside this key's scope.

Oldest first. A key scoped to nothing returns an empty array rather than an error.

200 response

{
  "projects": [
    {
      "id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
      "title": "Oncology discovery",
      "code": "ONCO1",
      "description": "First-in-human dose escalation support.",
      "status": "active",
      "start_date": "2026-03-01",
      "created_at": "2026-02-24T09:12:44.108Z"
    }
  ]
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

get/v1/projects/{projectId}/datasetsPartner+

The datasets you can deliver to, with the open draft pointer.

open_draft_batch_id is the draft already waiting on this dataset, if any. Reuse it instead of creating a second one. quality_check_version is the bar your next delivery will be held to; null means the sponsor has not published checks yet.

Parameters

  • projectId (path, required) From GET /projects.

200 response

{
  "datasets": [
    {
      "id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
      "name": "WES cohort",
      "slug": "onco1-wes",
      "description": "Paired tumour/normal exomes, one folder per sample.",
      "lifecycle_status": "active",
      "sample_depth": 1,
      "quality_check_version": 3,
      "open_draft_batch_id": null,
      "metadata": {
        "work_order": {
          "label": "Work order",
          "type": "text",
          "value": "WO-2214"
        },
        "therapeutic_area": {
          "label": "Therapeutic area",
          "type": "select",
          "value": "Oncology"
        }
      },
      "created_at": "2026-03-04T14:00:00.000Z"
    }
  ]
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

get/v1/datasets/{datasetId}Partner+

One dataset, with its current quality-check version and open draft.

The payload carries only what a partner needs in order to deliver: which dataset, what it should contain, the bar, the structure, and whether it accepts deliveries. The sponsor's own filing Label is deliberately not exposed here; metadata carries the fields the sponsor deliberately defined on its own records — read-only, their team writes them in Myelin.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

200 response

{
  "dataset": {
    "id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
    "project_id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
    "name": "WES cohort",
    "slug": "onco1-wes",
    "description": "Paired tumour/normal exomes, one folder per sample.",
    "lifecycle_status": "active",
    "sample_depth": 1,
    "quality_check_version": 3,
    "quality_checks_published_at": "2026-06-18T08:30:00.000Z",
    "open_draft_batch_id": null,
    "metadata": {
      "work_order": {
        "label": "Work order",
        "type": "text",
        "value": "WO-2214"
      },
      "therapeutic_area": {
        "label": "Therapeutic area",
        "type": "select",
        "value": "Oncology"
      }
    },
    "created_at": "2026-03-04T14:00:00.000Z"
  }
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

Contract

What the sponsor expects of a delivery, before you build one.

post/v1/datasets/{datasetId}/sample-depthPartner+

Declare at which folder depth a sample sits.

The one dataset field a partner may write, because the partner owns its own output structure. 0 = the delivery root is one sample, 1 = each top-level folder is a sample, 2 = one level deeper. Sample-scoped quality checks group by it. Idempotent, so a pipeline can assert it on every run: re-declaring the current value returns changed: false. It freezes once any batch in the dataset leaves draft, so that per-sample verdicts stay comparable across deliveries. After that it answers 409 sample_depth_locked.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

Request body

{
  "sample_depth": 1
}

200 response

{
  "dataset_id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
  "sample_depth": 1,
  "changed": false
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 sample_depth_locked · 429 rate_limited · 503 rate_limit_unavailable

get/v1/datasets/{datasetId}/file-rolesPartner+

Which delivered file plays each role, plus detected candidates.

Quality checks read files by ROLE (samplesheet, checksum_manifest, qc_report, subject_roster, capture_bed) instead of by literal path. This returns the dataset's declarations and, for each undeclared role, up to three detected candidates. Detection proposes and never decides — nothing here is ever required to deliver; an undeclared role simply makes the checks that read it abstain.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

200 response

{
  "dataset_id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
  "declarations": [
    {
      "role": "samplesheet",
      "path": "/metadata/samplesheet.csv",
      "declared_none": false,
      "updated_at": "2026-08-31T10:00:00Z"
    }
  ],
  "candidates": [
    {
      "role": "checksum_manifest",
      "path": "/metadata/md5_manifest.csv"
    }
  ]
}

Errors: 401 invalid_api_key · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

put/v1/datasets/{datasetId}/file-rolesPartner+

Declare which file plays a role — or that no such file exists.

The partner declares, and only the partner (they are the one who knows what they are sending). Unlike sample-depth this NEVER locks: a declaration applies forward, frozen findings record the resolution they used, and re-declaring is allowed for the life of the dataset. Idempotent: re-asserting the current declaration answers changed: false, so a pipeline can declare on every run. Three shapes: { "role": ..., "path": "/metadata/samplesheet.csv" } declares a file; { "role": ..., "none": true } states the dataset has no such file (checks answer does not apply instead of waiting); { "role": ..., "clear": true } removes the declaration.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

Request body

{
  "role": "samplesheet",
  "path": "/metadata/samplesheet.csv"
}

200 response

{
  "dataset_id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
  "role": "samplesheet",
  "changed": true
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

get/v1/datasets/{datasetId}/quality-checksPartner+

The delivery contract: every rule your delivery will be held to.

Read this before you build the delivery, not after it is rejected. Each check carries a plain-sentence assertion derived from its own parameters, the dimension it answers (completeness, structure, validity, consistency, integrity, privacy), a concrete pass/fail example, and the remediation hint. runs_at is the field that saves round trips. preflight means POST /datasets/{id}/preflight can answer it from a file list alone, before a byte moves. submission means the evaluator reads file content, so it can only run once the files are uploaded. review means a human reviewer votes it. The snapshot is frozen at publish, so this always describes the version that will actually evaluate.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

200 response

{
  "version": 3,
  "published_at": "2026-06-18T08:30:00.000Z",
  "summary": {
    "total": 9,
    "blocking": 4,
    "must_acknowledge": 2,
    "flagged": 6,
    "checkable_before_upload": 7,
    "needs_reviewer": 1,
    "dimensions": [
      {
        "key": "completeness",
        "label": "Completeness",
        "question": "Is everything here?"
      },
      {
        "key": "consistency",
        "label": "Consistency",
        "question": "Does what was promised match what arrived?"
      }
    ]
  },
  "checks": [
    {
      "rule_key": "c8f1a0b2-9d34-4e57-8a16-3b7c2e6f9d05",
      "name": "Samplesheet covers every sample folder",
      "slug": "samplesheet-covers-folders",
      "assertion": "Every value in column sample_id of samplesheet.csv has a matching top-level folder, and vice versa.",
      "description": null,
      "remediation": "Add the missing folder, or remove the row from samplesheet.csv.",
      "check_type": "csv_column_covers_folders",
      "parameters": {
        "file": "samplesheet.csv",
        "column": "sample_id"
      },
      "severity": "blocking",
      "category": "consistency",
      "kind": "automatic",
      "dimension": "consistency",
      "runs_at": "submission",
      "inspects": "content",
      "example": {
        "pass": "samplesheet.csv lists S01–S12; the delivery holds folders S01–S12.",
        "fail": "samplesheet.csv lists S07; no S07 folder in the delivery."
      }
    }
  ]
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

post/v1/datasets/{datasetId}/preflightPartner+

Evaluate a file list against the contract before uploading anything.

Send the paths and sizes you are about to deliver, at most 10 000. Runs the same evaluators the submit engine runs, so a verdict here cannot disagree with the verdict you get at submit. Read-only: no rows are written, no audit event is recorded, nothing is uploaded. Nothing is silently skipped. Checks whose evaluator reads file content come back in deferred (they run at submit), and manual checks come back in manual (a reviewer votes them). Branch on blocking_failures + blocking_not_evaluated, not on blocking_failures alone. There are two ways for a delivery to be un-validatable: a blocking rule that failed, and a blocking rule the engine could not evaluate. The second stops the reviewer just as surely, and reading blocking_failures === 0 as "clear to upload" is how a pipeline gets a green here and a blocked review afterwards. must_acknowledge_failures is reported for the log and is deliberately not part of that sum: it asks the reviewer for a decision and the partner for nothing.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

Request body

{
  "files": [
    {
      "path": "samplesheet.csv",
      "size_bytes": 2048
    },
    {
      "path": "SAMPLE_01/reads_R1.fastq.gz",
      "size_bytes": 4294967296
    },
    {
      "path": "SAMPLE_01/reads_R2.fastq.gz",
      "size_bytes": 4294967296
    }
  ]
}

200 response

{
  "version": 3,
  "files_evaluated": 3,
  "evaluated": [
    {
      "rule_key": "a1d20c73-5e88-4b16-9f04-7c3a6e2b1d59",
      "name": "Samplesheet present",
      "slug": "samplesheet-present",
      "check_type": "manifest_present",
      "severity": "blocking",
      "verdict": "passed",
      "details": {
        "matched": "samplesheet.csv"
      },
      "remediation": "Include samplesheet.csv at the delivery root."
    },
    {
      "rule_key": "e4b9107f-2c65-4d38-a07e-51b8f3c9a2d6",
      "name": "No empty or truncated files",
      "slug": "no-empty-files",
      "check_type": "file_size_min",
      "severity": "blocking",
      "verdict": "failed",
      "details": {
        "offending": [
          "SAMPLE_01/reads_R2.fastq.gz"
        ],
        "min_bytes": 1024
      },
      "remediation": "Re-export the file; it is below the minimum size."
    },
    {
      "rule_key": "7f0c9b31-4a2e-4d86-b5c7-08e1f4a3d962",
      "name": "Filenames follow the run convention",
      "slug": "filename-convention",
      "check_type": "filename_pattern",
      "severity": "blocking",
      "verdict": "not_evaluated",
      "details": {
        "reason": "invalid_regex",
        "abstained": "rule",
        "pattern": "^[A-Z{2}_"
      },
      "remediation": "Rename the files to match the run convention."
    }
  ],
  "deferred": [
    {
      "rule_key": "c8f1a0b2-9d34-4e57-8a16-3b7c2e6f9d05",
      "name": "Samplesheet covers every sample folder",
      "check_type": "csv_column_covers_folders",
      "reason": "reads file content — evaluated at submit"
    }
  ],
  "manual": [],
  "blocking_failures": 1,
  "blocking_not_evaluated": 1,
  "must_acknowledge_failures": 0,
  "counts": {
    "passed": 1,
    "flagged": 0,
    "failed": 1,
    "not_evaluated": 1,
    "not_applicable": 0,
    "evaluated": 3,
    "deferred": 1,
    "manual": 0,
    "checks": 4
  },
  "metadata": {
    "missing_required": [
      {
        "key": "work_order",
        "label": "Work order"
      }
    ]
  }
}

Errors: 400 invalid_body · too_many_files · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

Delivery

The write path: draft → declare → upload → confirm → submit.

post/v1/datasets/{datasetId}/batchesPartner+

Create or resume THE draft delivery for this dataset.

One draft per dataset, by design. 201 with resumed: false when a fresh draft was created; 200 with resumed: true when the existing draft is handed back. A crashed pipeline re-runs this call and continues where it stopped. Fails fast with 409 delivery_locked while a delivery is already submitted or in_review, echoing the blocking batch_id so you can recall it or wait.

Parameters

  • datasetId (path, required) From GET /projects/{projectId}/datasets.

Request body

{}

200 response

{
  "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
  "resumed": true
}

Errors: 401 invalid_api_key · 403 dataset_not_active · 404 not_found · 409 delivery_locked · 422 batch_create_failed · 429 rate_limited · 503 rate_limit_unavailable

delete/v1/batches/{batchId}Partner+

Discard a draft delivery. Drafts only.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

200 response

{
  "deleted": true,
  "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23"
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 delete_failed · 429 rate_limited · 503 rate_limit_unavailable

post/v1/batches/{batchId}/filesPartner+

Declare the files you are about to upload, up to 500 per call.

Send one {path, size_bytes} or {files: [...]}. Mints a storage path per file, creates queued rows, and returns the multipart upload descriptor: the part geometry your uploader must match. Declaring is idempotent. Without replace, same path and same size (and no conflicting checksum) means the same row, untouched, so a re-run only moves the delta. A size or a checksum that differs at an existing path is refused per-file with path_conflict; re-send that file with replace: true when you mean to overwrite it. replace: true means new bytes. A path already on the draft is re-queued (upload_state: queued) unless the checksum you send equals the one on record at the same size: size alone cannot tell a corrected file from the one already there, so a same-size file sent without a checksum is always uploaded again. To re-send only what changed, send each file’s checksum — the unchanged ones come back uploaded, the changed ones queued. Upload every file that does not come back uploaded. At the next submit, the reviewer’s earlier votes on a re-sent file are marked stale unless its checksum proves the bytes the same, so a file re-sent with no checksum always counts as changed. Read the status, then read the per-file error. 200 means every entry landed. 409 means at least one did not — the rest still did, with their file_id, so a per-file fault never costs you the call. A path sent twice in ONE call is refused as duplicate_path on the repeat only. A path already declared by another call in flight is adopted rather than failed: you get back that row's file_id and storage_path, which is the object key you must upload to. A single file may be at most 250 GiB on a Google Cloud Storage destination (files up to 200 GiB are the delivery commitment there) and at most 20 GiB on an Amazon S3 destination. A larger one is refused in its own error field, quoting both the limit and the size you declared, so the rest of the call still lands and you learn it before a byte moves. A file above 20 GiB is refused per-file unless your client is recent enough to use the per-file part_size this response returns (an older myelin-cli/ agent is refused by name, with an upgrade command; a non-CLI integrator is trusted to read part_size and is never refused for its agent). That file's confirm call must then carry an md5 checksum — checked there, not here, because the digest is only known once the file has been read, which happens during upload; no checksum, or any other algorithm, is refused by name at confirm. A path may nest at most 64 folders deep, refused the same per-file way. No credential is handed out here. Presigned URLs are minted per file by POST /files/{fileId}/upload-parts.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

Request body

{
  "files": [
    {
      "path": "samplesheet.csv",
      "size_bytes": 2048
    },
    {
      "path": "SAMPLE_01/reads_R1.fastq.gz",
      "size_bytes": 4294967296,
      "checksum": "9b2c1f0a7d3e4568a1b0c9d8e7f60524",
      "checksum_algorithm": "md5"
    }
  ]
}

200 response

{
  "files": [
    {
      "path": "samplesheet.csv",
      "file_id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41",
      "storage_path": "4319542d/de991fe4/7aa515ee/samplesheet.csv",
      "upload_state": "queued",
      "replacement": false,
      "error": null,
      "part_size": 67108864
    }
  ],
  "upload": {
    "protocol": "s3_multipart",
    "part_size": 67108864,
    "max_parts_per_sign": 1000
  }
}

Errors: 400 invalid_body · too_many_files · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 path_conflict · duplicate_path · delivery_locked · delivery_locked · 422 declare_failed · 429 rate_limited · 500 upload_unconfigured · 502 storage_unavailable · 503 rate_limit_unavailable

post/v1/files/{fileId}/upload-partsPartner+

Mint presigned S3 URLs for the parts you still need.

Ask for 1-based part numbers, at most 1 000 per call. The response tells you which parts already landed (uploaded_parts) so a resumed upload skips them, and returns one presigned URL per part you asked for — landed parts included, because PUTting a landed part number again replaces it. A landed part's etag is the MD5 of its bytes. Before skipping it on resume, check that the MD5 of your local slice matches — a part left behind by a different part geometry (e.g. after shrinking parts on a slow connection), or by an older version of the file, must be re-uploaded under the same number. PUT each part body to its URL. The ETag response header is what confirm needs — collect them. part_size (optional): the part size you are using for this file. Below this file's floor — max(5 MiB, ceil(size / 9,500) rounded up to a whole MiB): 5 MiB up to about 46 GiB, about 22 MiB for a 200 GiB file, so no file can ever need more than 10,000 parts however far a slow link downshifts — it is refused 400 part_size_too_small before any storage call. Send it on every sign and confirm call so the server can pin exactly the geometry you used; an older integrator that omits it keeps today's behaviour. restart_upload (optional, default false): send true when you are abandoning the part size you started this file with (a slow-link downshift) and starting over at a smaller one. It aborts every open multipart session under this file's exact storage key — never a different file that merely shares a prefix — before minting a fresh one, so the storage project's in-progress byte counter stops charging the geometry you are leaving behind. Omit it on an ordinary resume (continuing the same geometry after a restart of your own process). No bearer token is involved: each URL authorizes exactly one part of one object for a few minutes, so a leaked URL is a single stale part rather than an access path. Revoking the API key cuts off signing immediately.

Parameters

  • fileId (path, required) From the declare response.

Request body

{
  "part_numbers": [
    1,
    2,
    3,
    4
  ],
  "part_size": 67108864
}

200 response

{
  "file_id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41",
  "upload_id": "2~ZmFrZS11cGxvYWQtaWQtZXhhbXBsZQ",
  "part_size": 67108864,
  "uploaded_parts": [
    {
      "part_number": 1,
      "etag": "\"5d41402abc4b2a76b9719d911017c592\""
    }
  ],
  "urls": [
    {
      "part_number": 1,
      "url": "https://storage.eu.myelin.example/staging/4319542d/de991fe4/7aa515ee/reads_R1.fastq.gz?partNumber=1&uploadId=2~ZmFrZS11cGxvYWQtaWQ&X-Amz-Expires=1800&X-Amz-Signature=3c9d02e1a7f84b60d5c2e918fa4b07c6d13e85a2907f4cb8e6a1d0f52c493b7e"
    },
    {
      "part_number": 2,
      "url": "https://storage.eu.myelin.example/staging/4319542d/de991fe4/7aa515ee/reads_R1.fastq.gz?partNumber=2&uploadId=2~ZmFrZS11cGxvYWQtaWQ&X-Amz-Expires=1800&X-Amz-Signature=1f0c7a9e2b5d43068c1af95e7d20b6438e5c0197a2fd6b84c3e0912ab7d5f460"
    },
    {
      "part_number": 3,
      "url": "https://storage.eu.myelin.example/staging/4319542d/de991fe4/7aa515ee/reads_R1.fastq.gz?partNumber=3&uploadId=2~ZmFrZS11cGxvYWQtaWQ&X-Amz-Expires=1800&X-Amz-Signature=8b3a41d067ce295b8f2d31a6e04c97b52e18a0f6c74d3b9e5182ac"
    }
  ]
}

Errors: 400 invalid_body · part_numbers · part_size · part_size_too_small · part_size · detail · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 delivery_locked · 429 rate_limited · 500 upload_unconfigured · 502 storage_unavailable · 503 rate_limit_unavailable

post/v1/files/{fileId}/confirmPartner+

Complete the multipart upload and flip the file to `uploaded`.

Send the upload_id and every {part_number, etag} you collected. The server completes the multipart upload, then verifies the object landed with the declared size before flipping the row. A mismatch answers 409 size_mismatch rather than accepting a short file. The parts list is required: a client that lost its ETags gets an explicit error instead of a silent server-side listing, because losing them means the upload loop is broken. Optionally record a checksum here; if the sponsor's contract includes the checksum-manifest check, this is what it verifies against. The value is validated before it is stored: checksum_algorithm must be md5 (32 lowercase hex chars) or sha256 (64 lowercase hex chars) — anything else, or a checksum with no algorithm, answers 400 invalid_body. When the upload landed as a single S3 part, an md5 checksum is additionally cross-checked against that part's ETag (which IS the plain MD5 of the landed bytes) and a mismatch answers 409 checksum_mismatch. A multipart upload's completed-object ETag is not a usable MD5, so its checksum is still stored as partner-declared only — verified-against-landed-bytes only for the single-part case. A file above 20 GiB must carry an md5 checksum here (only a bridge whose destination is Google Cloud Storage accepts such a file at declare) — any other algorithm, or none, answers 400 checksum_required, naming what is missing, and the object just completed is kept, not discarded: send the same confirm again with the md5 added and nothing is uploaded again. This is checked here rather than at declare because the digest is only known once the file has been read, which happens during upload — and it is checked by size alone, not by the bridge's destination today, since the destination can change between your declare and your confirm. Safe to retry. If your first confirm succeeded but the response was lost, the retry returns 200 with already_confirmed: true — an echo of the success you never saw, not an error and not a second upload. Nothing is re-completed and nothing is audited a second time. The echo requires the staged object to still be there at the declared size, and a checksum that does not contradict the one already recorded; a genuinely DIFFERENT upload of the same file is still refused (409 checksum_mismatch), and so is a confirm for which parts are still sitting in an open multipart session — those bytes get materialised, as they should. The same confirm is the retry. Joining the parts of a very large file (thousands of parts) normally takes seconds; when storage is slow the server waits up to four minutes for it, then answers 502 storage_unavailable while storage may well finish on its own. A gateway 504, a dropped connection or that 502 all mean the same thing: send the same request again, with the same upload_id and the same parts. If the first join finished, the retry finds no open upload, sees the object in staging at the declared size and confirms it — the parts are not uploaded again. Back off (2 s, 4 s, 8 s, up to a minute) and keep trying for about ten minutes before giving up; the myelin CLI (from 0.16) and the web portal do exactly this. Do not retry a 4xx: 409 size_mismatch, delivery_locked and the other refusals describe the request, and a second identical call gets the same answer.

Parameters

  • fileId (path, required) From the declare response.

Request body

{
  "upload_id": "2~ZmFrZS11cGxvYWQtaWQtZXhhbXBsZQ",
  "parts": [
    {
      "part_number": 1,
      "etag": "\"5d41402abc4b2a76b9719d911017c592\""
    },
    {
      "part_number": 2,
      "etag": "\"7d793037a0760186574b0282f2f435e7\""
    }
  ],
  "checksum": "9b2c1f0a7d3e4568a1b0c9d8e7f60524",
  "checksum_algorithm": "md5",
  "part_size": 67108864
}

200 response

{
  "file_id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41",
  "upload_state": "uploaded",
  "size_bytes": 4294967296,
  "already_confirmed": false
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 delivery_locked · size_mismatch · upload_incomplete · checksum_mismatch · confirm_failed · 429 rate_limited · 500 upload_unconfigured · 502 storage_unavailable · upload_id · 503 rate_limit_unavailable

delete/v1/files/{fileId}Partner+

Remove a file from a draft or changes_requested delivery.

Parameters

  • fileId (path, required) From the declare response.

200 response

{
  "deleted": true,
  "file_id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41"
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 delete_failed · 429 rate_limited · 503 rate_limit_unavailable

post/v1/batches/{batchId}/submitPartner+

Hand the delivery over: run the sponsor's auto-checks and enter review.

Freezes the round snapshot, runs the automatic checks synchronously, flips the status and notifies the sponsor. The response carries the verdicts, so a pipeline knows immediately what the reviewer will see. Safe to retry. If your first call succeeded but the response was lost, the retry returns 200 with already_submitted: true and the recorded verdicts — an echo of the success you never saw, not an error and not a second submission. The echo applies while the delivery is submitted or in_review; once a review has concluded (validated, rejected, …) a submit call returns 422 again, because by then "cannot submit" is the informative answer. A delivery with files still queued is refused with 422 submit_blocked and rolled back. Confirm every upload first. One submit at a time. Two submits of the same delivery in flight together are separated at the door: one runs, the other answers 409 submit_in_flight having changed nothing.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

Request body

{}

200 response

{
  "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
  "status": "submitted",
  "already_submitted": false,
  "auto_checks": {
    "passed": 7,
    "flagged": 1,
    "failed": 0,
    "not_evaluated": 2,
    "not_applicable": 0,
    "total": 10,
    "blocking_failures": 0,
    "blocking_not_evaluated": 1,
    "must_acknowledge_failures": 0,
    "findings": [
      {
        "rule_key": "a1d20c73-5e88-4b16-9f04-7c3a6e2b1d59",
        "severity": "blocking",
        "verdict": "passed",
        "details": {
          "matched": "samplesheet.csv"
        }
      }
    ]
  }
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 submit_in_flight · 422 submit_blocked · detail · queued · 429 rate_limited · 503 rate_limit_unavailable

post/v1/batches/{batchId}/recallPartner+

Take a submitted delivery back before the review starts.

Only while the status is submitted. Once a reviewer has opened it the delivery is in_review and this answers 409 recall_failed.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

Request body

{}

200 response

{
  "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
  "status": "draft"
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 recall_failed · 429 rate_limited · 503 rate_limit_unavailable

Review

What came back: status, findings, the machine-readable fix loop.

get/v1/batchesPartner+

Deliveries in this key's scope, newest first.

court says whose move it is — partner (yours), reviewer (the sponsor's reviewer), system (validated, being delivered to the sponsor's bucket) or none (final) — and is usually the field a pipeline branches on rather than raw status.

Parameters

  • dataset_id (query)
  • status (query)
  • limit (query)
  • cursor (query) The previous response's next_cursor, passed back VERBATIM.

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

get/v1/batches/{batchId}Partner+

One delivery: status, whose move, round number, and every file.

The files array is complete and unpaginated on purpose: it is what a partner reconciles against its own manifest, and a silently truncated list is worse than no list. submit_ready is true when POST /batches/{id}/submit will be accepted: the delivery is in your court, it holds at least one file, and every declared file has finished uploading. pending_upload_count says how many are still outstanding when it is false.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

200 response

{
  "batch": {
    "id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
    "project_id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
    "dataset_id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
    "display_name": "ONCO1-WES-007",
    "sequence_number": 7,
    "status": "changes_requested",
    "court": "partner",
    "round": 2,
    "submitted_at": "2026-08-14T09:41:02.517Z",
    "request_changes_comment": "S07 is missing from the delivery.",
    "submit_ready": true,
    "files": [
      {
        "id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41",
        "path": "/SAMPLE_01/reads_R1.fastq.gz",
        "size_bytes": 4294967296,
        "format": "fastq.gz",
        "upload_status": "uploaded",
        "checksum": "9b2c1f0a7d3e4568a1b0c9d8e7f60524",
        "checksum_algorithm": "md5",
        "uploaded_at": "2026-08-14T09:37:55.204Z"
      }
    ],
    "created_at": "2026-08-14T08:02:11.006Z",
    "updated_at": "2026-08-14T10:15:39.882Z"
  }
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

get/v1/batches/{batchId}/findingsPartner+

Why a delivery was sent back, in machine-readable form.

Each finding resolves its rule name, the assertion the rule actually made, its dimension, and the remediation hint, so a pipeline can print something actionable rather than "check 4a1d…: failed". An abstention is not a pass, and it is not a failure either. A finding may come back not_evaluated or not_applicable — see Verdict. Filtering this array to verdict == "failed" drops every rule nobody managed to check and reports the delivery as clean. Walk all five, and read details.abstained before you print anything: data and nothing_to_evaluate are yours to act on, rule is the sponsor's rule and there is nothing on your side to fix. In changes_requested the response also carries the per-file reviewer verdicts and the request-changes comment. Those are partner-visible only in that state, mirroring the portal. Their vocabulary is FileReviewVerdict, three values — a person judging one file never abstains.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

200 response

{
  "status": "changes_requested",
  "quality_findings": [
    {
      "kind": "quality_check",
      "rule_key": "c8f1a0b2-9d34-4e57-8a16-3b7c2e6f9d05",
      "name": "Samplesheet covers every sample folder",
      "assertion": "Every value in column sample_id of samplesheet.csv has a matching top-level folder, and vice versa.",
      "dimension": "consistency",
      "hint": "Add the missing folder, or remove the row from samplesheet.csv.",
      "severity": "blocking",
      "verdict": "failed",
      "details": {
        "reason": "sample_folder_mismatch",
        "missing_folders": [
          "S07"
        ]
      },
      "evaluated_at": "2026-08-14T09:41:04.882Z"
    },
    {
      "kind": "quality_check",
      "rule_key": "7f0c9b31-4a2e-4d86-b5c7-08e1f4a3d962",
      "name": "Checksums match what was declared",
      "assertion": "Every delivered file matches the checksum declared for it at ingestion.",
      "dimension": "integrity",
      "hint": "Declare a checksum for each file when you create it.",
      "severity": "blocking",
      "verdict": "not_evaluated",
      "details": {
        "reason": "some_files_could_not_be_verified",
        "abstained": "data",
        "unverified": 3
      },
      "evaluated_at": "2026-08-14T09:41:04.902Z"
    }
  ],
  "file_reviews": [
    {
      "kind": "file_review",
      "file_id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41",
      "path": "/SAMPLE_01/reads_R1.fastq.gz",
      "verdict": "failed",
      "reviewer": "Marta Ruiz",
      "comment": "Truncated — re-export.",
      "updated_at": "2026-08-14T10:14:02.115Z"
    }
  ],
  "request_changes_comment": "S07 is missing from the delivery."
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

get/v1/batches/{batchId}/eventsPartner+

The timeline of one delivery, oldest first.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 503 rate_limit_unavailable

Events

Webhooks, and the polling fallback for shops with no public receiver.

get/v1/eventsPartner+

Poll every event across the key's projects. The fallback when you cannot expose an HTTPS receiver.

Ascending cursor over the delivery timelines in scope. Pass the previous next_cursor back verbatim. It carries Postgres microsecond precision, and round-tripping it through a millisecond Date re-delivers the last row of the previous page.

Parameters

  • limit (query)
  • cursor (query) The previous response's next_cursor, passed back VERBATIM.

200 response

{
  "events": [
    {
      "id": "5c9e2f10-8a76-4b31-9d02-4e7f1a6b0c53",
      "type": "changes_requested",
      "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
      "batch_name": "ONCO1-WES-007",
      "project_id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
      "dataset_id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
      "comment": "S07 is missing from the delivery.",
      "created_at": "2026-08-14T10:15:39.882471Z"
    }
  ],
  "next_cursor": "2026-08-14T10:15:39.882471Z"
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

get/v1/webhook-endpointsPartner+

This bridge's webhook endpoints, and the full event-type catalogue.

Errors: 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

post/v1/webhook-endpointsPartner+

Register an HTTPS receiver. The signing secret is returned once.

HTTPS only, and the host is resolved and range-checked before every delivery. An empty events array subscribes to all types. The endpoint is bound to the key that registers it and receives exactly what that key can read at the moment of each event: events for the projects in the key's scope, never others. The scope is read live, so a key granted access to more projects later starts receiving their events without registering again, and a key removed from a project stops. A key with no project in its scope yet can register, but its endpoint receives no events until the key is scoped (the response then carries a warning). From the moment the key is revoked or expires, the endpoint receives nothing more and is listed active: false; register the same URL again with the new key. While the sponsor has paused the bridge, the key is refused (bridge_paused) and its endpoints receive nothing: an event that happens during the pause is not sent, and a delivery queued before it is held, not failed, and goes out once the bridge is resumed. The endpoint stays active: true throughout. On a decommissioned bridge nothing more is sent. An endpoint whose key is revoked or expired does not hold its URL, so that registration is not refused as a duplicate. Store the returned secret now. It is not retrievable afterwards.

Request body

{
  "url": "https://pipeline.acme-cro.example/hooks/myelin",
  "events": [
    "batch.changes_requested",
    "batch.validated",
    "batch.transferred"
  ]
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 409 endpoint_duplicate · 422 endpoint_invalid · 429 rate_limited · 503 rate_limit_unavailable

delete/v1/webhook-endpoints/{endpointId}Partner+

Remove a webhook endpoint.

Parameters

  • endpointId (path, required)

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 409 delete_failed · 429 rate_limited · 503 rate_limit_unavailable

post/v1/webhook-endpoints/{endpointId}/testPartner+

Send a synthetic `test.ping` so you can verify your signature check.

Parameters

  • endpointId (path, required)

200 response

{
  "ok": true,
  "delivered": true
}

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 429 rate_limited · 502 test_delivery_failed · 503 rate_limit_unavailable

Sponsor reads

Sponsor-side keys only: what landed in your bucket, and what it is.

get/v1/deliveriesSponsor+

What has landed in your bucket, across every partner.

Sponsor-side keys only. One row per delivered batch, each carrying the destination prefix in your own bucket, the manifest sitting next to it, and enough identity to be useful without a second call. Newest first, keyset-paginated on transferred_at. A project_id outside the key's scope answers 403 project_out_of_scope rather than an empty list, because an empty list would read as "that project has no deliveries".

Parameters

  • project_id (query)
  • dataset_id (query)
  • since (query) Only deliveries transferred at or after this ISO-8601 instant.
  • limit (query)
  • cursor (query) The previous response's next_cursor, passed back VERBATIM.

200 response

{
  "deliveries": [
    {
      "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
      "batch_name": "ONCO1-WES-007",
      "sequence_number": 7,
      "project": {
        "id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
        "code": "ONCO1",
        "title": "Oncology discovery"
      },
      "dataset": {
        "id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
        "name": "WES cohort",
        "slug": "onco1-wes"
      },
      "validated_at": "2026-08-14T18:05:12.000Z",
      "transferred_at": "2026-08-14T18:06:20.442Z",
      "destination_root": "gs://acme-landing/inbox/acme-cro/4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88/de991fe4-7c02-4a15-8b6d-91f3c2074aa1/7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23/",
      "manifest_path": "gs://acme-landing/inbox/acme-cro/4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88/de991fe4-7c02-4a15-8b6d-91f3c2074aa1/7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23/_myelin/manifest.json",
      "file_count": 24
    }
  ],
  "next_cursor": null
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 429 rate_limited · 503 rate_limit_unavailable

get/v1/deliveries/{batchId}Sponsor+

The full delivery record: who sent it, who accepted it, which rules ran.

Sponsor-side keys only. Returns the same document as the _myelin/manifest.json sitting next to the data, built by the same function. One definition of "the metadata for a delivery", served two ways: as a file for pipelines that read the bucket, over HTTP for systems that would rather ask. One difference, and it is the point of having both: the file is frozen at delivery time, this is live. Reach for the file when it has to be auditable, for this endpoint when you want it current.

Parameters

  • batchId (path, required) From POST /datasets/{datasetId}/batches.

Errors: 401 invalid_api_key · 403 wrong_key_side · 404 delivery_not_found · 409 not_delivered · 429 rate_limited · 503 rate_limit_unavailable

get/v1/deliveries/resolveSponsor+

A path in your bucket → the project, dataset, delivery and file it holds.

Sponsor-side keys only. The endpoint the sponsor-side API exists for: your bucket is full of ids, and this turns one back into what it means. Accepts a full gs:// or s3:// URI, a bare prefix, or a single id, and answers at whatever granularity the path supports. Resolution works by extracting ids rather than matching the prefix shape, so a path written under an older destination layout resolves exactly as well as a current one.

Parameters

  • path (query, required) An object key, a prefix, or a bare id.

200 response

{
  "resolved": "file",
  "query": "gs://acme-landing/inbox/acme-cro/4319542d-…/de991fe4-…/7aa515ee-…/data/SAMPLE_01/reads_R1.fastq.gz",
  "project": {
    "id": "4319542d-8f21-4c7a-9d3e-2b6a1f0c5e88",
    "code": "ONCO1",
    "title": "Oncology discovery",
    "description": "First-in-human dose escalation support."
  },
  "dataset": {
    "id": "de991fe4-7c02-4a15-8b6d-91f3c2074aa1",
    "name": "WES cohort",
    "slug": "onco1-wes",
    "description": "Paired tumour/normal exomes, one folder per sample.",
    "label": "Genomics"
  },
  "delivery": {
    "batch_id": "7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23",
    "batch_name": "ONCO1-WES-007",
    "sequence_number": 7,
    "status": "transferred",
    "validated_at": "2026-08-14T18:05:12.000Z",
    "transferred_at": "2026-08-14T18:06:20.442Z",
    "destination_root": "gs://acme-landing/inbox/acme-cro/4319542d-…/de991fe4-…/7aa515ee-…/",
    "manifest_path": "gs://acme-landing/inbox/acme-cro/4319542d-…/de991fe4-…/7aa515ee-…/_myelin/manifest.json",
    "record_url": "/api/v1/deliveries/7aa515ee-3d94-4f60-bc21-0e5a8d7f4b23"
  },
  "file": {
    "id": "b1c40a97-52e8-4d3a-9f17-6c8e2a5b0d41",
    "filename": "reads_R1.fastq.gz",
    "folder_path": "/SAMPLE_01/",
    "size_bytes": 4294967296,
    "format": "fastq.gz",
    "declared_checksum": "9b2c1f0a7d3e4568a1b0c9d8e7f60524",
    "declared_checksum_algorithm": "md5"
  },
  "relative_path": "SAMPLE_01/reads_R1.fastq.gz"
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 404 not_resolved · 429 rate_limited · 503 rate_limit_unavailable

Diagnostics

Connectivity checks that write no delivery data.

post/v1/bridges/{bridgeId}/sandboxPartner+

Prove the connection end to end with a throwaway file. Stay under 4 MB.

Verifies connectivity, not throughput. Records the same activation evidence as the portal's sandbox panel, so a partner can complete the bridge checklist from a pipeline.

Parameters

  • bridgeId (path, required) Must be the key's own bridge.

200 response

{
  "ok": true,
  "bridge_id": "0b7d1c33-4e58-4a90-8f21-77d0b9e4a512",
  "file_name": "hello.txt",
  "size_bytes": 12,
  "partner_test": "succeeded"
}

Errors: 400 invalid_body · 401 invalid_api_key · 403 wrong_key_side · 404 not_found · 413 file_too_large · 429 rate_limited · 502 sandbox_failed · 503 rate_limit_unavailable

API v1 is additive only: new fields may appear, existing ones will not change meaning or disappear. Something missing or wrong here? hello@myelinbridge.com.