{"openapi":"3.1.0","info":{"title":"Myelin Partner Ingestion API","version":"1.0.0-phase4","description":"Programmatic ingestion for invited partners. Identity, discovery, and reads plus the full write path: create/resume a draft, declare files, upload bytes direct to staging over TUS (resumable, 6 MB chunks, `x-upsert: true`), confirm (server-verifies the object landed with the declared size), submit, recall, delete. All requests: `Authorization: Bearer myl_live_…`. Errors are RFC 9457 problem+json with a stable `code`. Timestamps are ISO-8601 UTC. No CORS — server-to-server only."},"servers":[{"url":"/api/v1"}],"security":[{"apiKey":[]}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"Bridge-scoped API key (myl_live_…), created in Bridge → API."}},"schemas":{"Problem":{"type":"object","properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string","description":"Stable machine-readable error code, e.g. invalid_api_key, bridge_paused, api_disabled, rate_limited, not_found."},"detail":{"type":"string"},"request_id":{"type":"string"}}},"DatasetSummary":{"type":"object","description":"A dataset as returned by the list endpoint. This object is the DELIVERY CONTRACT and nothing else: which dataset you are delivering to (name, slug), what it should contain (description), the bar it must clear (quality_check_version + /datasets/{datasetId}/quality-checks), the structure it must follow (sample_depth), and whether it accepts deliveries at all (lifecycle_status). Datasets and projects are created and described by the client in the portal — the API has no write path for either, by design. Three fields you may have seen earlier were removed on purpose, none of them actionable by a partner: `data_type` (2026-07-29 — expected file formats are quality checks, not a dataset field), `data_domain` (2026-07-31 — the client's own free-text filing label, which no partner sets, needs, or is gated by), and `metadata_mode` (2026-07-31 — a dormant switch for the AI metadata-extraction feature; it will return, described properly, when that feature ships).","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":"string","description":"Uppercase, unique per project. Feeds batch display names ({project_code}-{slug}-{NNN})."},"description":{"type":["string","null"]},"lifecycle_status":{"type":"string","enum":["draft","active","archived"],"description":"Only 'active' datasets accept batches. 'draft' means no quality-check version has been published yet."},"sample_depth":{"type":"integer","minimum":0,"maximum":5,"description":"Folder depth at which a sample is defined (1 = top-level folders are samples). Drives sample-scoped quality checks. The one dataset field a PARTNER declares — you own your output structure — via POST /datasets/{datasetId}/sample-depth. Frozen once any batch in the dataset leaves draft, so sample-scoped verdicts stay comparable across deliveries."},"quality_check_version":{"type":["integer","null"],"description":"Current published quality-check version number, or null if none published."},"open_draft_batch_id":{"type":["string","null"],"format":"uuid","description":"The dataset's open draft batch, if one exists (one draft per dataset)."},"created_at":{"type":"string","format":"date-time"}}},"Dataset":{"allOf":[{"$ref":"#/components/schemas/DatasetSummary"},{"type":"object","description":"Detail endpoint adds the parent project and the publication timestamp.","properties":{"project_id":{"type":"string","format":"uuid"},"quality_checks_published_at":{"type":["string","null"],"format":"date-time"}}}]},"Batch":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"project_id":{"type":"string","format":"uuid"},"dataset_id":{"type":"string","format":"uuid"},"display_name":{"type":"string"},"sequence_number":{"type":"integer"},"status":{"type":"string","enum":["draft","submitted","in_review","changes_requested","validated","transferred","transfer_failed","rejected"]},"court":{"type":"string","enum":["partner","reviewer","system","none"],"description":"Whose move it is."},"submitted_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}},"paths":{"/ping":{"get":{"summary":"Is my key alive? Returns key, bridge, and scoped project ids.","responses":{"200":{"description":"Key is valid."},"401":{"description":"invalid_api_key"},"403":{"description":"bridge_paused / bridge_decommissioned / api_disabled"}}}},"/me":{"get":{"summary":"Key identity, bridge (lifecycle, connection mode), scoped projects.","responses":{"200":{"description":"Identity payload."}}}},"/projects":{"get":{"summary":"Projects in the key's scope.","responses":{"200":{"description":"{ projects: [...] }"}}}},"/projects/{projectId}/datasets":{"get":{"summary":"Datasets of a scoped project: id, name, slug, description, lifecycle_status, sample_depth, current quality-check version, open-draft pointer.","parameters":[{"name":"projectId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ datasets: [...] }","content":{"application/json":{"schema":{"type":"object","properties":{"datasets":{"type":"array","items":{"$ref":"#/components/schemas/DatasetSummary"}}}}}}},"404":{"description":"not_found (outside key scope)"}}}},"/datasets/{datasetId}":{"get":{"summary":"Dataset detail: the list fields plus project_id and quality_checks_published_at. The object is the delivery contract only — what a delivery must satisfy comes from the quality-check version, never from a descriptive label (`data_type`, `data_domain` and `metadata_mode` are all gone — see the Dataset schema).","parameters":[{"name":"datasetId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ dataset: {...} }","content":{"application/json":{"schema":{"type":"object","properties":{"dataset":{"$ref":"#/components/schemas/Dataset"}}}}}},"404":{"description":"not_found"}}}},"/datasets/{datasetId}/sample-depth":{"post":{"summary":"Declare at which folder depth a sample sits (PRD §4.3) — the only dataset write a partner has. Body: { sample_depth: 0-5 }. You own your output structure, so you declare the depth your deliveries use; everything else about a dataset is created and described by the client in the portal. Idempotent: re-declaring the current value succeeds with changed:false, so a pipeline can assert its depth on every run. Locks once any batch in the dataset leaves draft — after that the value is frozen so sample-scoped quality verdicts stay comparable across deliveries. Set it BEFORE your first submit.","parameters":[{"name":"datasetId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sample_depth"],"properties":{"sample_depth":{"type":"integer","minimum":0,"maximum":5,"description":"0 = the batch root is the sample; 1 = top-level folders are samples (default); 2 = one level deeper, and so on."}}}}}},"responses":{"200":{"description":"{ dataset_id, sample_depth, changed }","content":{"application/json":{"schema":{"type":"object","properties":{"dataset_id":{"type":"string","format":"uuid"},"sample_depth":{"type":"integer"},"changed":{"type":"boolean","description":"false when the value already matched (no audit row written)."}}}}}},"400":{"description":"invalid_body (missing / non-integer / outside 0-5)"},"404":{"description":"not_found (outside key scope)"},"409":{"description":"sample_depth_locked — a batch in this dataset has already left draft"}}}},"/datasets/{datasetId}/quality-checks":{"get":{"summary":"The delivery contract: the published quality-check snapshot preflight/submit will evaluate, each check carrying a plain-language assertion, its quality dimension, whether it can be checked BEFORE upload, and a pass/fail example.","parameters":[{"name":"datasetId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ version, published_at, summary: { total, blocking, checkable_before_upload, needs_reviewer, dimensions[] }, checks: [{ rule_key, name, slug, assertion, description, remediation, check_type, parameters, severity, category, kind, dimension, runs_at, inspects, example }] }"},"404":{"description":"not_found"}},"description":"Use this to learn what a dataset expects before delivering. Each check adds: assertion (one sentence derived from the check parameters), dimension (completeness | structure | validity | consistency | integrity | privacy), runs_at (preflight = answerable by POST /datasets/{id}/preflight with no bytes uploaded; submission = needs file content; review = a person votes it), inspects, and example. The response summary reports totals plus which dimensions the contract covers. The snapshot is frozen at publish, so this always describes the version that will actually run."}},"/batches":{"get":{"summary":"List scoped batches. Filter by dataset_id/status; cursor pagination.","parameters":[{"name":"dataset_id","in":"query","schema":{"type":"string","format":"uuid"}},{"name":"status","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":50}},{"name":"cursor","in":"query","schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"{ batches: [Batch], next_cursor }"}}}},"/batches/{batchId}":{"get":{"summary":"Batch detail: status, whose-move, round number, files with upload state, submit-readiness.","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ batch: {...} }"},"404":{"description":"not_found"}}},"delete":{"summary":"Delete a draft batch (files, rows, and staging blobs). Draft only.","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ deleted: true }"},"404":{"description":"not_found"},"409":{"description":"delete_failed"}}}},"/batches/{batchId}/findings":{"get":{"summary":"Quality findings; in changes_requested also per-file reviewer verdicts + request-changes comment.","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ status, quality_findings: [{ kind, rule_key, name, assertion, dimension, hint, severity, verdict, details, evaluated_at }], ... } — assertion states what the failing check required."},"404":{"description":"not_found"}}}},"/batches/{batchId}/events":{"get":{"summary":"Batch timeline (submitted, review_started, changes_requested, …).","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ events: [...] }"},"404":{"description":"not_found"}}}},"/datasets/{datasetId}/batches":{"post":{"summary":"Create OR resume the dataset's draft batch (one draft per dataset).","parameters":[{"name":"datasetId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Existing draft resumed: { batch_id, resumed: true }"},"201":{"description":"Draft created: { batch_id, resumed: false }"},"403":{"description":"dataset_not_active / project frozen"},"404":{"description":"not_found"}}}},"/batches/{batchId}/files":{"post":{"summary":"Declare files (single {path, size_bytes, checksum?} or {files: [...]} ≤500). Mints storage paths, creates 'queued' rows, returns the S3-multipart upload descriptor (protocol, part_size, max_parts_per_sign). Get presigned part URLs per file from /files/{id}/upload-parts. Re-declaring identical files is idempotent; a different size at an existing path needs replace=true.","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ files: [{path, file_id, storage_path, upload_state, replacement, error}], upload: {protocol: 's3_multipart', part_size, max_parts_per_sign} }"},"404":{"description":"not_found"},"409":{"description":"path_conflict on ≥1 file (per-file errors in body)"}}}},"/files/{fileId}/upload-parts":{"post":{"summary":"Mint presigned S3 multipart UploadPart URLs for a declared file (SEC-TOKEN-01: no bearer token — each URL authorizes exactly one part of one object for a few minutes). Body: { part_numbers: number[] } (1-based, ≤ max_parts_per_sign). Returns the upload_id, the part_size, which parts already landed (resume), and a presigned url per still-missing requested part. PUT each part to its url; the ETag response header is what confirm needs. Gated to draft/changes_requested batches.","parameters":[{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ file_id, upload_id, part_size, uploaded_parts: [{part_number, etag}], urls: [{part_number, url}] }"},"400":{"description":"invalid_body (part_numbers range)"},"404":{"description":"not_found"},"409":{"description":"delivery_locked (batch not draft/changes_requested)"}}}},"/files/{fileId}/confirm":{"post":{"summary":"Finalize the upload: completes the S3 multipart upload from its parts, then verifies the staging object exists with the declared size, then flips the row to uploaded (audit batch.file_uploaded / file_reuploaded). Body: { upload_id, parts: [{part_number, etag}], checksum?, checksum_algorithm? }.","parameters":[{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ file_id, upload_state: 'uploaded', size_bytes }"},"400":{"description":"invalid_body (missing upload_id/parts)"},"404":{"description":"not_found"},"409":{"description":"upload_incomplete / size_mismatch / confirm_failed / delivery_locked"}}}},"/files/{fileId}":{"delete":{"summary":"Remove a file (row + staging blob) from a draft / changes_requested batch.","parameters":[{"name":"fileId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ deleted: true }"},"404":{"description":"not_found"},"409":{"description":"delete_failed (status guard)"}}}},"/batches/{batchId}/submit":{"post":{"summary":"Submit (or resubmit from changes_requested): round snapshot, frozen fields, synchronous auto-checks, status flip, reviewer notification. Blocks while declared files are still un-uploaded.","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ batch_id, status: 'submitted', auto_checks: {passed, flagged, failed, findings[]} }"},"404":{"description":"not_found"},"422":{"description":"submit_blocked — submit rolled back; detail explains (pending uploads, file cap, status guard)"}}}},"/batches/{batchId}/recall":{"post":{"summary":"Recall a submitted batch (only before review starts). Restores draft or changes_requested and invalidates the submit snapshots.","parameters":[{"name":"batchId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ batch_id, status }"},"404":{"description":"not_found"},"409":{"description":"recall_failed"}}}},"/datasets/{datasetId}/preflight":{"post":{"summary":"Evaluate a file list ({files: [{path, size_bytes}]}, ≤10k) against the dataset's published quality checks BEFORE uploading. Read-only, no audit. Returns per-check verdicts with remediation hints; content-dependent checks come back in `deferred` (they run at submit), manual checks in `manual`.","parameters":[{"name":"datasetId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ version, files_evaluated, evaluated: [{rule_key, name, severity, verdict, details, remediation}], deferred, manual, blocking_failures }"},"400":{"description":"invalid_body / too_many_files"},"404":{"description":"not_found"}}}},"/bridges/{bridgeId}/sandbox":{"post":{"summary":"Partner-side test transfer, API mode: multipart `file` ≤10 MB (hosting platform may cap request bodies earlier, ~4.5 MB on Vercel — a small sample file is the point). Satisfies the same activation checklist item as the portal sandbox (same tracking row, same audit event). Works while the bridge is still configuring.","parameters":[{"name":"bridgeId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ ok, bridge_id, file_name, size_bytes, partner_test: 'succeeded' }"},"400":{"description":"invalid_body"},"404":{"description":"not_found (key scoped to another bridge)"},"413":{"description":"file_too_large (10 MB cap)"}}}},"/webhook-endpoints":{"get":{"summary":"List this bridge's webhook endpoints (url, events, active, failure_count, last delivery) plus the available event_types.","responses":{"200":{"description":"{ event_types, endpoints: [...] }"}}},"post":{"summary":"Create an endpoint: {url (HTTPS, public host), events?} — empty events = all partner events. The HMAC signing secret is returned ONCE. Deliveries carry `Myelin-Signature: t=<unix>,v1=<hmac_sha256(secret, t + \".\" + body)>` (5-min replay window recommended) and `Myelin-Event-Id` for dedup. Retries at +5 min/+30 min/+24 h, terminal after 4 attempts; endpoints auto-disable after 30 consecutive terminal failures.","responses":{"201":{"description":"{ endpoint_id, secret }"},"422":{"description":"endpoint_invalid (non-HTTPS, private host, unknown event type)"}}}},"/webhook-endpoints/{endpointId}":{"delete":{"summary":"Remove an endpoint (pending deliveries to it are dropped).","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ deleted: true }"},"404":{"description":"not_found"}}}},"/webhook-endpoints/{endpointId}/test":{"post":{"summary":"Queue and immediately attempt a synthetic test.ping delivery (signed like a real event).","parameters":[{"name":"endpointId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"{ ok, delivered: true }"},"502":{"description":"test_delivery_failed with the receiver's error"}}}},"/events":{"get":{"summary":"Polling fallback for shops without an HTTPS receiver: ascending cursor over batch timeline events across the key's projects. Pass next_cursor back VERBATIM (it carries microsecond precision).","parameters":[{"name":"cursor","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","maximum":100,"default":50}}],"responses":{"200":{"description":"{ events: [{id, type, batch_id, batch_name, project_id, dataset_id, comment, created_at}], next_cursor }"}}}}}}