{"openapi":"3.0.3","info":{"title":"WittGen B2SC API","version":"1.0.0","description":"Programmatic access to WittGen Bulk2SC: submit a bulk RNA-seq job, poll it to completion, and retrieve per-sample single-cell type proportions, gene scores and plots. Authenticate with a per-user API key (Authorization: Bearer wgk_...). Designed for headless use from a Databricks notebook or a pharma analysis pipeline.\n\n**Research Use Only.** This API is not a medical device and is not cleared or approved by any regulator. Every output — cell-type proportions, gene scores, plots and generated matrices — is a computational prediction, not a clinical finding, and must not be used to diagnose, treat, or decide the care of any patient without independent clinical review. Provided without warranty of accuracy or fitness for a particular purpose.\n\n**Data retention.** Uploaded inputs are deleted after 30 days. A job and its results, reports and run context are kept for 365 days from submission, whichever way the input was supplied; after that the job returns 404 and its files are gone, so download anything you need to keep. Atlases are permanent and not subject to this window. Some files from jobs run before mid-2026 sit in archival storage and cannot be downloaded without a restore; those appear in the file listing carrying `archived` instead of a URL, rather than a link that would fail. Full terms: GET /api/v1/terms."},"servers":[{"url":"https://www.wittgenbio.com/api/v1","description":"Production"}],"security":[{"apiKey":[]}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"wgk_ token","description":"A per-user API key created in the dashboard. Send as Authorization: Bearer wgk_..."}},"schemas":{"Error":{"type":"object","description":"Every error carries `error` (human-readable) and, where the condition is one a client should branch on, a stable `code`. Branch on `code`, not on the message text.","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["ACCOUNT_NOT_PROVISIONED","UPLOAD_REQUIRES_PROVISIONED_PLAN","API_KEY_INVALID","API_KEY_EXPIRED","API_KEY_REVOKED","INVALID_CURSOR","INVALID_MODEL","INPUT_TOO_LARGE","FILE_TOO_LARGE","UNSUPPORTED_FILE_TYPE","GEO_NO_CANDIDATE","GEO_AMBIGUOUS","GEO_NO_RAW_COUNTS","INVALID_IDEMPOTENCY_KEY","IDEMPOTENT_REQUEST_IN_FLIGHT","IDEMPOTENT_JOB_GONE","TRIAL_QUOTA_EXCEEDED","RATE_LIMITED","RAW_DOWNLOAD_BUDGET_EXCEEDED","UPGRADE_FOR_FULL_FIDELITY","ANALYSIS_STAGE_NOT_OFFERED","RESULT_ARCHIVED","PHI_CASCADE_INCOMPLETE"],"description":"ACCOUNT_NOT_PROVISIONED: 403 — the account exists but is not enabled for analysis yet. Self-serve signups start here; contact info@wittgenbio.com. Not retryable.\nUPLOAD_REQUIRES_PROVISIONED_PLAN: 403 — uploads need a provisioned plan; the reference datasets and atlases are available without one.\nAPI_KEY_INVALID: 401 — the bearer is not a key we issued, or is malformed. Check for a truncated paste before anything else. Not retryable.\nAPI_KEY_EXPIRED: 401 — the key reached its expiry date. Keys issued for an evaluation carry one; the message names who to contact. Not retryable.\nAPI_KEY_REVOKED: 401 — the key was revoked. Not retryable.\nINVALID_CURSOR: 400 — the cursor did not open. It is sealed and bound to the credential it was issued to, so this means it was altered, it expired with a rotated server secret, or it came from a different API key. Start the listing again without a cursor.\nINVALID_MODEL: 400 — disease_model is neither a product name nor an accepted legacy accession. The message lists the valid names; GET /b2sc/models is authoritative.\nINPUT_TOO_LARGE: 400 — the input exceeds the size limit. Split the cohort and submit one job per batch.\nFILE_TOO_LARGE: 400 — the upload grant was requested for a file over the limit.\nUNSUPPORTED_FILE_TYPE: 400 — the filename does not carry an accepted suffix.\nGEO_NO_CANDIDATE: 422 — nothing in the GEO series looks like a gene-level expression matrix. The response carries the file listing; retry with geo_file.\nGEO_AMBIGUOUS: 422 — the series has more than one file that looks like a gene-level count matrix and they score too closely to choose between. The response carries the listing; retry with geo_file naming the one you want. Guessing here would silently decide which matrix your results describe.\nGEO_NO_RAW_COUNTS: 422 — the series publishes normalized values rather than raw counts. Retrying with a different geo_file will not help.\nINVALID_IDEMPOTENCY_KEY: 400 — the key is malformed. Printable ASCII, no spaces, at most 200 characters.\nIDEMPOTENT_REQUEST_IN_FLIGHT: 202 — a submission with this key is still being accepted. Retry in a moment; do not submit again without the key.\nIDEMPOTENT_JOB_GONE: 409 — the key was used for a job that no longer exists. Use a new key.\nTRIAL_QUOTA_EXCEEDED: 402 — the free-trial job quota is exhausted. Check GET /b2sc/usage.\nRATE_LIMITED: 429 — a ceiling was met. Two answer with this code: 20 SUBMISSIONS per rolling hour, counted per account; and 20 REQUESTS per minute on the metered read routes, counted per API KEY so one key cannot spend another's. Both windows roll; back off rather than retrying in a loop.\nRAW_DOWNLOAD_BUDGET_EXCEEDED: 429 — the rolling 24-hour raw-download cap is reached. GET /b2sc/usage reports used_24h and cap_24h.\nR_ANALYSIS_DAILY_CAP: 429 — the daily cap on analysis-stage runs for this plan is reached. Retry tomorrow or upgrade.\nNO_ANALYSIS_FOR_MODEL: 422 — this model has no analysis stage, so POST /b2sc/jobs/{id}/report has nothing to run. See report_type on GET /b2sc/models. Not retryable.\nREPORT_GENERATION_IN_FLIGHT: 409 — an analysis is already running for this job. Poll analysis_status rather than triggering another.\nREPORT_ALREADY_TRIGGERED: 409 — an analysis has already been generated or is generating for this job. Poll analysis_status; a genuinely stuck one can be re-run with ?force=1.\nUPGRADE_FOR_FULL_FIDELITY: 402 — this output is a paid-tier view; the free tier receives the aggregated one.\nANALYSIS_STAGE_NOT_OFFERED: 404 — this deployment does not run an analysis stage, so GET/POST /b2sc/jobs/{id}/report do not exist. Per-sample proportions and the generated single-cell matrix are the complete result: see GET /b2sc/jobs/{id}/results-data, /proportions and /files. The engine release guarantees inference; downstream analysis is a separate evaluation and is not offered until it has one.\nRESULT_ARCHIVED: 409 — the generated cells have moved to archival storage and cannot be downloaded without a restore. Presigning them anyway would hand you a link that 403s when you follow it, so the refusal is up front and costs you nothing. GET /b2sc/jobs/{id}/files reports the archive state per file.\nANALYSIS_CAPACITY_UNAVAILABLE: 503 — no compute was available to start the analysis stage. Transient: retry POST /b2sc/jobs/{id}/report shortly. Nothing was charged and the job is unchanged.\nANALYSIS_LAUNCH_FAILED: 502 — the analysis stage could not be started and retrying will not change that. Proportions and generated cells are unaffected; contact support with the job id.\nPHI_CASCADE_INCOMPLETE: 409 — the delete could not remove every object, so the job record was KEPT rather than reporting a deletion that did not happen. Retry; if it persists, contact support with the job id."}},"required":["error"]},"GeoRejection":{"type":"object","description":"A GEO series we will not run, returned with the listing so the caller can decide without reading the series by hand. Files use the same shape as the success path.","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["GEO_NO_CANDIDATE","GEO_NO_RAW_COUNTS"]},"accession":{"type":"string","nullable":true,"example":"GSE81538"},"units":{"type":"array","nullable":true,"items":{"type":"string"},"description":"GEO_NO_RAW_COUNTS only — the normalization token(s) that disqualified the series, e.g. [\"transformed\"] or [\"tpm\"]."},"files":{"type":"array","description":"Every supplementary file in the series, eligible or not.","items":{"type":"object","properties":{"name":{"type":"string"},"size_bytes":{"type":"integer","nullable":true},"eligible":{"type":"boolean"},"url":{"type":"string"}}}}}},"Model":{"type":"object","required":["id","name","n_genes","n_cell_types","release_id","estimated_runtime_min"],"properties":{"id":{"type":"string","example":"breast-tumour-2k"},"name":{"type":"string","example":"Breast Cancer"},"subtitle":{"type":"string","nullable":true,"description":"Short qualifier shown beside the name, e.g. which gene panel this release uses."},"description":{"type":"string","nullable":true,"description":"One line on what the model produces."},"release_id":{"type":"string","description":"The immutable release a job on this model runs against — checkpoint, gene axis and cell-type axis together. Results are only comparable across jobs that share it, so record it with your results."},"validation_caveat":{"type":"string","nullable":true,"description":"What this release has and has NOT been evaluated for. Read it before drawing a conclusion from a run."},"accepts_also":{"type":"array","items":{"type":"string"},"description":"Other identifiers disease_model accepts for this model — the engine-side name an older job record may carry. Present only where it differs from id; submit the id."},"n_genes":{"type":"integer"},"applies_to":{"type":"string","nullable":true,"description":"The sample type this model is built for. Read this before submitting your own data."},"not_applicable_to":{"type":"string","nullable":true,"description":"What it must NOT be used on. A deconvolution model given the wrong tissue does not error — it returns well-formed proportions that mean nothing."},"n_cell_types":{"type":"integer","description":"Distinct cell types a run returns for this model. Where a checkpoint carries more internal classes than it resolves, this reports the number you receive, not the internal count."},"estimated_runtime_min":{"type":"integer","description":"See Job.estimated_runtime_min: warm-pool compute only, excluding GPU cold start and the R/report stages."},"reference_dataset":{"type":"object","nullable":true,"properties":{"filename":{"type":"string"},"n_samples":{"type":"integer","description":"Sample columns in the reference matrix."},"description":{"type":"string"},"source":{"type":"string"}}}}},"AtlasList":{"type":"object","properties":{"atlases":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/Job"},{"type":"object","properties":{"atlas":{"type":"boolean","description":"Always true. Read-only; you do not own it."},"title":{"type":"string","description":"Display name for the cohort."},"description":{"type":"string","nullable":true,"description":"What the cohort is and why it is worth looking at."},"source_citation":{"type":"string","nullable":true,"description":"Where the public data came from, e.g. a GEO accession."},"n_samples":{"type":"integer","nullable":true}}}]}},"count":{"type":"integer"}},"required":["atlases","count"]},"UploadRequest":{"type":"object","required":["fileName","fileSize"],"properties":{"fileName":{"type":"string","description":"Base name including extension. Must end in .txt, .csv or .tsv.","example":"my_cohort.tsv"},"fileSize":{"type":"integer","description":"Size in bytes, under 256MB. Advisory at presign time; the true object size is asserted when the job is submitted, so a false value fails later, not here.","example":5242880},"fileType":{"type":"string","description":"Content-Type to sign the PUT with. Defaults to text/plain — whatever you send here is what the PUT must repeat.","example":"text/plain"}}},"UploadGrant":{"type":"object","properties":{"uploadUrl":{"type":"string","description":"Presigned S3 URL. PUT the file bytes here (see the endpoint description for the two required headers)."},"s3Key":{"type":"string","description":"Pass this as input_file_key on POST /b2sc/jobs.","example":"b2sc/uploads/42/9f1c-…_my_cohort.tsv"},"fileId":{"type":"string","format":"uuid"},"expiresIn":{"type":"integer","description":"Seconds the presigned URL stays valid (3600).","example":3600}}},"JobSubmission":{"type":"object","required":["disease_model","source"],"properties":{"disease_model":{"type":"string","description":"A model id from GET /models","example":"breast-tumour-2k"},"source":{"type":"string","enum":["reference_dataset","user_upload","geo"]},"input_file_key":{"type":"string","description":"Required when source=user_upload: the s3Key returned by POST /b2sc/upload.","example":"b2sc/uploads/42/9f1c-…_my_cohort.tsv"},"geo_accession":{"type":"string","description":"Required when source=geo. A GEO SERIES accession; we resolve it to the gene-level expression matrix in that series and fetch it. Requires a provisioned plan, since the run costs GPU time.","example":"GSE81538"},"geo_file":{"type":"string","description":"Optional with source=geo. Overrides our choice of supplementary file — use it when the 422 response lists several candidates, or when our pick is wrong. Must name a file present in that series.","example":"GSE81538_gene_expression_405_transformed.csv.gz"}}},"Job":{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"status":{"type":"string","description":"PENDING | QUEUED | LAUNCHING | RUNNING_STAGE1 | RUNNING_STAGE2 | GENERATING_H5AD | COMPLETED | FAILED"},"disease_model":{"type":"string"},"release_id":{"type":"string","nullable":true,"example":"20260807-bulk-conditioned-v1","description":"The model release this job ran against — the answer to \"which weights produced these numbers\" months later. Ours and versioned, unlike the dataset accession. Releases can differ in gene panel and cell-type axis, so results from two releases are not directly comparable; per-run detail (engine commit, seed, and a sha256 per model artifact) is in run_manifest.json, listed by GET /b2sc/jobs/{id}/files."},"progress":{"type":"integer","minimum":0,"maximum":100},"message":{"type":"string"},"estimated_runtime_min":{"type":"integer","description":"Minutes from submit to COMPLETED on a warm GPU pool, for the reference dataset. EXCLUDES the GPU cold start (the pool scales to zero, so the first job after an idle period pays an instance launch and an image pull — measured at 1m47s for the pull alone) and the downstream analysis stage. Runtime scales with sample count, not with the model: measured 2026-08-08 on release 20260807-bulk-conditioned-v1, a single-sample run takes ~14s of inference inside a ~4 min wall clock on a cold node, while a large cohort can take hours to finish its analysis stage."},"created_at":{"type":"string","format":"date-time"}},"required":["job_id","status","disease_model","release_id","progress","message","estimated_runtime_min","created_at"]},"JobAccepted":{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["QUEUED"]},"disease_model":{"type":"string","description":"The product name, whichever identifier you submitted.","example":"breast-tumour-2k"},"release_id":{"type":"string","nullable":true,"description":"The model release this job was accepted for. See Job.release_id.","example":"20260807-bulk-conditioned-v1"},"estimated_runtime_min":{"type":"integer"},"created_at":{"type":"string","format":"date-time"}},"required":["job_id","status","disease_model","release_id","estimated_runtime_min","created_at"]},"FileList":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string","description":"S3 key. Stable; the URL is not."},"name":{"type":"string","example":"generated_cells.h5ad"},"type":{"type":"string","description":"csv | png | pdf | h5ad | txt"},"url":{"type":"string","description":"Presigned download, valid one hour. Re-call this endpoint for a fresh one. EMPTY STRING when `restricted` is true, and null when `archived` blocks it — check both before fetching."},"restricted":{"type":"boolean","description":"Present and true when the entry is listed but not downloadable by policy rather than by state. The generated h5ad of a source=reference_dataset job is the case that exists today: the reference input is WittGen data, so the run is shown but its raw generated matrix is not handed out. `url` is an empty string. Everything else the job produced — the R analysis outputs — carries a normal download URL."},"category":{"type":"string","enum":["r_outputs","result","input","report"],"description":"r_outputs = gene scores and plots · result = generated matrix · input = what you submitted · report = a PDF from a pre-2026-08-04 job"},"size":{"type":"integer","nullable":true},"last_modified":{"type":"string","format":"date-time","nullable":true},"archived":{"type":"object","nullable":true,"description":"Present only when the object is in archival storage. When it is, `url` is null rather than a link that would return 403 InvalidObjectState. A completed restore sets state=restored and DOES carry a url.","properties":{"state":{"type":"string","enum":["archived","restoring","restored"]},"downloadable":{"type":"boolean","description":"Only set, and only true, for state=restored."},"until":{"type":"string","format":"date-time","description":"When the temporary restored copy expires."},"note":{"type":"string"}}}}}}}},"ProportionsResponse":{"type":"object","required":["job_id","disease_model","n_samples","cell_types","format","proportions","intended_use"],"properties":{"job_id":{"type":"string","format":"uuid"},"disease_model":{"type":"string"},"intended_use":{"type":"string","description":"The Research Use Only notice. Travels with the primary result, not only with the summary — surface it wherever you surface the numbers."},"n_samples":{"type":"integer"},"cell_types":{"type":"array","items":{"type":"string"}},"format":{"type":"string","enum":["long","wide"]},"proportions":{"description":"long: array of {sample, cell_type, proportion}. wide: array of {sample, <cell_type>: proportion, ...}.","type":"array","items":{"type":"object"}},"truncated":{"type":"boolean","description":"true if the matrix exceeded the JSON size limit; use downloads.proportions_csv instead."},"downloads":{"type":"object","properties":{"proportions_csv":{"type":"string","format":"uri","description":"Presigned URL (15 min) for the raw per-sample CSV."}}}}},"ResultsData":{"type":"object","description":"The free aggregated evaluation view for a completed job.","required":["job_id","intended_use","metadata"],"properties":{"job_id":{"type":"string","format":"uuid"},"intended_use":{"type":"string","description":"The Research Use Only notice that applies to every result this API returns. Returned on every call and previously undocumented — which is the one field where that matters most, because its purpose is to be shown. Surface it wherever you surface the numbers."},"metadata":{"type":"object","properties":{"disease_model":{"type":"string"},"n_samples":{"type":"integer"},"n_cell_types":{"type":"integer","description":"The number of cell types you actually receive — counted from the delivered proportions table, so it always agrees with its rows and with GET /b2sc/models. Not the model-internal count taken before disease states are merged."},"n_genes":{"type":"integer"},"gene_coverage_pct":{"type":"string","nullable":true,"description":"Percentage of the model gene panel present in your matrix."},"gene_coverage":{"type":"object","nullable":true,"description":"The same number with enough context to act on. The model states its own verdict: it requires a minimum share of ITS OWN panel to match and refuses to run below that, so a finished job has met the floor by definition. The floor and the panel size belong to the model, not to the API — `floor` below reports the one this run was held to. Every model currently offered declares 70% of its panel, on two independent floors — matched by symbol, and carrying signal. `level` is the model's band. Jobs produced by the retired models instead report level normal/low/critical against the 78-84% those models measured — their results were made by a different model and are described in its terms.","properties":{"pct":{"type":"number"},"level":{"type":"string","enum":["meets_model_floor","below_model_floor","warning_1000_or_fewer","critical_100_or_fewer","normal","low","critical"],"description":"The first four come from the model. The last three appear only on jobs produced by the retired models."},"floor":{"type":"string","nullable":true,"example":"1400 of 2000 model genes must match"},"nonzero_floor":{"type":"string","nullable":true,"example":"1400 of 2000 model genes must carry signal","description":"The second of two independent fail-closed floors, present on releases from 2026-08-10. A matrix can clear the matched floor and fail this one — every panel gene present by name, most of them all-zero."},"nonzero_gene_count":{"type":"integer","nullable":true,"description":"Panel genes that carried signal across the request."},"normal_range":{"type":"string","nullable":true,"example":"78-84% (retired models)","description":"Retired-model jobs only."},"note":{"type":"string","description":"What this level means and what to check."},"caveat":{"type":"string","description":"Coverage is gene-NAME overlap. It cannot tell whether the sample is the right tissue — see Model.applies_to."}}},"runtime_seconds":{"type":"integer","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"proportions":{"description":"Mean proportion per cell type across the cohort: [{name, mean}]. Empty when withheld (small-cohort preview).","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"mean":{"type":"number"}}}},"proportions_suppressed":{"type":"object","nullable":true,"description":"Present when the aggregate is withheld for a preview caller below the cohort floor (code UPGRADE_FOR_FULL_FIDELITY)."}}},"Usage":{"type":"object","description":"The caller's plan + freemium trial usage.","properties":{"plan":{"type":"string","enum":["trial","paid","enterprise"]},"used":{"type":"integer","description":"cumulative jobs submitted"},"quota":{"type":"integer","nullable":true,"description":"trial job cap; null = unlimited (paid/enterprise)"},"remaining":{"type":"integer","nullable":true},"enforced":{"type":"boolean","description":"whether the freemium quota is currently enforced"},"metered_30d":{"type":"array","description":"Billable operations over the last 30 days, one row per op. Returned on every call and previously undocumented — this is the ledger behind an invoice, so it is the number to reconcile against.","items":{"type":"object","properties":{"op":{"type":"string","example":"gpu_job","description":"gpu_job = one GPU inference run; raw_download = one download of a raw artifact."},"unit":{"type":"string","example":"op"},"events":{"type":"integer"},"total":{"type":"number"}}}},"raw_downloads":{"type":"object","description":"The rolling 24-hour cap on raw-artifact downloads. Separate from the job quota above, and reachable independently of it.","properties":{"used_24h":{"type":"integer"},"cap_24h":{"type":"integer","example":200}}}},"required":["plan","used","enforced","metered_30d","raw_downloads"]}}},"paths":{"/b2sc/models":{"get":{"summary":"List available disease models","operationId":"listModels","responses":{"200":{"description":"Available models","content":{"application/json":{"schema":{"type":"object","properties":{"models":{"type":"array","items":{"$ref":"#/components/schemas/Model"}}}}}}},"401":{"description":"Missing/invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/b2sc/jobs/{id}/stream":{"get":{"summary":"Follow a job with server-sent events instead of polling","description":"A `text/event-stream` that emits `event: status` whenever status or progress CHANGES — not on a fixed tick — and closes on a terminal state. Cheaper and more responsive than polling GET /b2sc/jobs/{id}, which matters when a run takes hours. `event: error` carries a message and closes. There is no webhook; this is the push option. Note that EventSource cannot set an Authorization header, so browser clients authenticate by cookie; from a script, prefer a client that can stream with a Bearer header.","operationId":"streamJob","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"SSE stream. `event: status` data is { status, progress, message, result_s3_key, context_s3_key }.","content":{"text/event-stream":{"schema":{"type":"string"}}}},"401":{"description":"Missing/invalid API key"},"404":{"description":"No such job, or not yours. The two are deliberately indistinguishable: a separate 403 would confirm that an id you cannot read exists."}}}},"/b2sc/jobs/{id}/result":{"get":{"summary":"Download the generated single-cell matrix","description":"A presigned link to the job's h5ad, plus its run context. This is the richest artifact the API serves — the full generated matrix, not an aggregate — so it is paid-only (402 on a preview tier) and additionally metered against a rolling 24-hour download budget, reported as `raw_downloads` by GET /b2sc/usage. The link is short-lived: fetch it, do not store or forward it.","operationId":"getJobResult","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Presigned links","content":{"application/json":{"schema":{"type":"object","properties":{"downloadUrl":{"type":"string","description":"The generated matrix (h5ad)."},"contextUrl":{"type":"string","nullable":true,"description":"Run context JSON, when the job produced one."},"expires_in":{"type":"integer","description":"Seconds the links remain valid."},"result_s3_key":{"type":"string","description":"Stable key. The URL is not."},"runtime_seconds":{"type":"integer","nullable":true,"description":"Wall-clock the run took, when recorded."}}}}}},"400":{"description":"The job has not finished, so there is no matrix to download yet. The body carries the current status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid API key"},"402":{"description":"Requires a paid plan.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such job, not yours, or it has not produced a matrix. Atlases are deliberately excluded here — a public job is never an unmetered tap on the raw matrix — and \"not yours\" is not distinguished from \"no such job\"."},"409":{"description":"RESULT_ARCHIVED - the matrix has moved to archival storage and cannot be downloaded until it is restored. Refused before anything is metered, because presigning an archived object yields a link that 403s when followed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rolling 24-hour raw-download budget exhausted. It rolls continuously, so it frees up without an action from you.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/b2sc/usage":{"get":{"summary":"Get the caller's plan + trial usage","operationId":"getUsage","responses":{"200":{"description":"Usage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Usage"}}}},"401":{"description":"Missing/invalid API key"}}}},"/b2sc/atlases":{"get":{"summary":"Pre-computed public atlases you can inspect immediately","description":"Well-known public cohorts we have already run, so you can see real output without\nwaiting out a job. Cohort runs take tens of minutes to hours depending on sample count,\nand the cold start is paid once per idle period rather than per job.\n\nEach entry is an ordinary job id. Everything else works on it unchanged:\nGET /b2sc/jobs/{id}, /proportions, /results-data and /files all accept an atlas id.\n\nThey are read-only and owned by WittGen, not by you: submitting, deleting, re-running or\nediting files on an atlas returns 404, the same as any job you do not own. The raw\ngenerated matrix is not served for atlases — proportions, gene scores and plots are.","operationId":"listAtlases","responses":{"200":{"description":"Available atlases","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AtlasList"}}}},"401":{"description":"Missing/invalid API key"}}}},"/b2sc/upload":{"post":{"summary":"Get a presigned URL to upload your own expression matrix","description":"Step 1 of 2 for analysing your own data. Returns a short-lived presigned S3 URL; you then\nPUT the file bytes directly to that URL and pass the returned `s3Key` as `input_file_key`\non POST /b2sc/jobs with `source=user_upload`.\n\nThe PUT is signed over its headers, so it MUST repeat exactly two of them, or S3 rejects it\nwith SignatureDoesNotMatch:\n  Content-Type: <the same fileType you sent here; text/plain if you omitted it>\n  x-amz-server-side-encryption: AES256\nDo NOT send an Authorization header on the PUT — a presigned URL carries its own credentials\nin the query string and S3 refuses a request that also authenticates.\n\nThe Python SDK wraps both steps in `client.upload_file(path)`, which returns the key.\n\nInput requirements: .txt/.csv/.tsv/.txt.gz/.csv.gz/.tsv.gz, under 256MB, genes x samples, HGNC symbols in the gene\ncolumn. Duplicate symbols (the ordinary outcome of an Ensembl-to-HGNC mapping) are collapsed\nby SUMMING their counts — the standard resolution for one gene measured across several loci —\nand the run reports how many rows were collapsed. Pre-aggregate yourself for different\nsemantics. Uploading requires a provisioned plan; self-serve accounts run the bundled\nreference datasets instead.","operationId":"createUpload","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadRequest"}}}},"responses":{"200":{"description":"Presigned upload grant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadGrant"}}}},"400":{"description":"Missing fileName/fileSize, unsupported extension (code UNSUPPORTED_FILE_TYPE), or file over 256MB (code FILE_TOO_LARGE)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid API key"},"403":{"description":"Plan does not permit uploading your own data (code UPLOAD_REQUIRES_PROVISIONED_PLAN)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/b2sc/jobs":{"get":{"summary":"List your jobs, newest first","description":"Cursor-paginated. Pass the `cursor` from a response to get the next page; its absence means the last page. Atlases are not listed here — they are not yours; use GET /b2sc/atlases.","operationId":"listJobs","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"maximum":50},"description":"Capped at 50."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque and bound to your credential — pass back verbatim. It cannot be decoded, and a cursor issued to one API key is not accepted from another."}],"responses":{"200":{"description":"One page of jobs","content":{"application/json":{"schema":{"type":"object","properties":{"jobs":{"type":"array","items":{"$ref":"#/components/schemas/Job"}},"count":{"type":"integer","description":"Jobs in THIS page, not the total."},"cursor":{"type":"string","nullable":true,"description":"Absent or null on the last page."}}}}}},"400":{"description":"INVALID_CURSOR - the cursor did not open. Restart the listing without one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid API key"}}},"post":{"summary":"Submit a B2SC job","operationId":"submitJob","description":"Starts a GPU run. Send an `Idempotency-Key` header: a job is a multi-hour run against your quota, so a duplicate is a second charge for work you asked for once, and the realistic cause is an ordinary network timeout where the request arrives and the response does not. Replaying a key returns the SAME job with `Idempotent-Replay: true`; replaying while the first attempt is still being accepted returns 202 rather than starting a second run. Keys are scoped to your account and expire after 24 hours. The Python SDK sends one per call. ONE JOB TAKES AT MOST 64 SAMPLES. A wider matrix is refused with \"Bulk input exceeds the 64-sample limit.\" — split the cohort into batches of 64 or fewer and submit one job per batch. Every batch returns the same cell-type columns in the same order, so the per-sample proportion tables concatenate directly; the 1,231-sample TCGA-BRCA atlas was built this way. The matrix must also be under 256 MB (gzip counts), expand to under 512 MB, carry at most 100,000 gene rows — a gene-level human annotation is 40-60k and fine, a transcript-level one is not — and match enough of the model's own gene panel, whose size GET /b2sc/models reports as n_genes, on two floors: 70% matched by symbol AND 70% carrying signal. Coverage below that floor is refused rather than run on partial input; GET /jobs/{id}/results-data reports where a finished run landed.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":200},"description":"Printable ASCII, no spaces. Reuse it to retry safely; use a new one for a deliberate second run of the same input."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobSubmission"}}}},"responses":{"200":{"description":"Job accepted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAccepted"}}}},"202":{"description":"A submission with this Idempotency-Key is still being accepted (code IDEMPOTENT_REQUEST_IN_FLIGHT). Retry in a moment; do not submit again without the key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"400":{"description":"Invalid submission, a GEO file over the 256MB limit, or a malformed Idempotency-Key (code INVALID_IDEMPOTENCY_KEY)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing/invalid API key"},"402":{"description":"Free-trial job quota exhausted (code TRIAL_QUOTA_EXCEEDED) — upgrade to a paid plan. Check GET /b2sc/usage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"UPLOAD_REQUIRES_PROVISIONED_PLAN - running your own upload or a GEO series needs a provisioned account; source=reference_dataset works on any plan. Also returned when input_file_key names an object you do not own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"source=geo: no such GEO series, or geo_file names a file that series does not have","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The Idempotency-Key was used for a job that no longer exists (code IDEMPOTENT_JOB_GONE). Use a new key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"source=geo, two distinct cases distinguished by `code`: GEO_NO_CANDIDATE — nothing in the series looks like a gene-level expression matrix. The response carries the file listing; retry with geo_file naming one of them. GEO_NO_RAW_COUNTS — the series publishes expression as TPM/FPKM/transformed rather than raw counts, and `units` names which. The models are trained on raw counts and apply CPM + log1p themselves, so a pre-normalized matrix is the wrong input, not a lesser one — and TPM is the dangerous case, being non-negative: it would run to completion and report confident, wrong proportions. Retrying with geo_file will not help; use a series that publishes counts, or upload counts directly. GSE81538 and GSE96058 are both this case.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GeoRejection"}}}},"429":{"description":"Rate limit exceeded: 20 submissions per rolling hour, per account. The count is of EVERY job submitted in the window whatever its source, so reference-dataset runs and your own data share one budget (code RATE_LIMITED). Set to stop a runaway loop, not to ration an evaluation — wait rather than retrying in a loop.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"source=geo: GEO could not be reached. Transient — retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/b2sc/jobs/{id}/results-data":{"get":{"summary":"Aggregated evaluation summary (the FREE tier view)","description":"Mean cell-type proportions across the cohort + run metadata — the free evaluation view (no paid plan required). Gene scores and full per-sample resolution are paid-only and appear here as a suppressed marker for a free caller. For a preview (free) caller the aggregate is withheld below a minimum cohort size (a mean over too few samples would reveal per-sample values).","operationId":"getResultsData","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Aggregated results","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResultsData"}}}},"401":{"description":"Missing/invalid API key"},"404":{"description":"Job not found, or not yours — the two are not distinguished."}}}},"/b2sc/jobs/{id}":{"delete":{"summary":"Delete a job and its artifacts","description":"Removes the job record and everything under its result and report prefixes. Not reversible, and refused for an atlas — those belong to a system account.","operationId":"deleteJob","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Deleted"},"401":{"description":"Missing/invalid API key"},"404":{"description":"No such job, or not yours. The two are deliberately indistinguishable: a separate 403 would confirm that an id you cannot read exists."},"409":{"description":"Some artifacts could not be erased, so the job record was deliberately kept rather than orphaning data. Retry the delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"summary":"Get job status","operationId":"getJob","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Job status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"401":{"description":"Missing/invalid API key"},"404":{"description":"Job not found, or not yours — the two are not distinguished."}}}},"/b2sc/jobs/{id}/files":{"get":{"summary":"Every artifact this job produced, as presigned downloads","description":"The primary way to collect a finished run. Returns one entry per artifact with a presigned\nURL valid for one hour: the R stage outputs (gene scores, per-sample CSVs, plots), the\ngenerated single-cell matrix, and the input matrix.\n\nAvailable once `report_status` reaches `R_COMPLETE`; before that the R-output entries are\nsimply absent rather than an error. Gene-score files are withheld for cohorts below the\nsmall-cohort threshold, because a score over too few samples approaches a per-sample value.","operationId":"listJobFiles","parameters":[{"name":"include_raw","in":"query","required":false,"schema":{"type":"boolean"},"description":"Presign the raw .h5ad in this listing. OFF by default: presigning it spends one of the caller's rolling 24-hour raw downloads, and listing your files should not cost a download. Without it the file is still listed, carrying an empty url and reason RAW_NOT_REQUESTED. GET /b2sc/jobs/{id}/result is the endpoint that exists for the raw artifact and meters it there. Raw-artifact links are signed for 15 minutes; every other link in this response for an hour."},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Artifact list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileList"}}}},"401":{"description":"Missing/invalid API key"},"402":{"description":"UPGRADE_FOR_FULL_FIDELITY - raw artifacts require a paid plan. The free tier gets the aggregated summary at GET /b2sc/jobs/{id}/results-data.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not your job"},"404":{"description":"Job not found"}}}},"/b2sc/jobs/{id}/proportions":{"get":{"summary":"Get per-sample cell-type proportions (dataframe-shaped)","operationId":"getProportions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["long","wide"],"default":"long"}}],"responses":{"200":{"description":"Proportions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProportionsResponse"}}}},"401":{"description":"Missing/invalid API key"},"402":{"description":"Full-resolution per-sample results require a paid plan (free tier: aggregated summary at /results-data)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Not your job"},"404":{"description":"Job not found"},"409":{"description":"Proportions not produced yet — poll again","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"x-error-codes":{"ACCOUNT_NOT_PROVISIONED":"403 — the account exists but is not enabled for analysis yet. Self-serve signups start here; contact info@wittgenbio.com. Not retryable.","UPLOAD_REQUIRES_PROVISIONED_PLAN":"403 — uploads need a provisioned plan; the reference datasets and atlases are available without one.","API_KEY_INVALID":"401 — the bearer is not a key we issued, or is malformed. Check for a truncated paste before anything else. Not retryable.","API_KEY_EXPIRED":"401 — the key reached its expiry date. Keys issued for an evaluation carry one; the message names who to contact. Not retryable.","API_KEY_REVOKED":"401 — the key was revoked. Not retryable.","INVALID_CURSOR":"400 — the cursor did not open. It is sealed and bound to the credential it was issued to, so this means it was altered, it expired with a rotated server secret, or it came from a different API key. Start the listing again without a cursor.","INVALID_MODEL":"400 — disease_model is neither a product name nor an accepted legacy accession. The message lists the valid names; GET /b2sc/models is authoritative.","INPUT_TOO_LARGE":"400 — the input exceeds the size limit. Split the cohort and submit one job per batch.","FILE_TOO_LARGE":"400 — the upload grant was requested for a file over the limit.","UNSUPPORTED_FILE_TYPE":"400 — the filename does not carry an accepted suffix.","GEO_NO_CANDIDATE":"422 — nothing in the GEO series looks like a gene-level expression matrix. The response carries the file listing; retry with geo_file.","GEO_AMBIGUOUS":"422 — the series has more than one file that looks like a gene-level count matrix and they score too closely to choose between. The response carries the listing; retry with geo_file naming the one you want. Guessing here would silently decide which matrix your results describe.","GEO_NO_RAW_COUNTS":"422 — the series publishes normalized values rather than raw counts. Retrying with a different geo_file will not help.","INVALID_IDEMPOTENCY_KEY":"400 — the key is malformed. Printable ASCII, no spaces, at most 200 characters.","IDEMPOTENT_REQUEST_IN_FLIGHT":"202 — a submission with this key is still being accepted. Retry in a moment; do not submit again without the key.","IDEMPOTENT_JOB_GONE":"409 — the key was used for a job that no longer exists. Use a new key.","TRIAL_QUOTA_EXCEEDED":"402 — the free-trial job quota is exhausted. Check GET /b2sc/usage.","RATE_LIMITED":"429 — a ceiling was met. Two answer with this code: 20 SUBMISSIONS per rolling hour, counted per account; and 20 REQUESTS per minute on the metered read routes, counted per API KEY so one key cannot spend another's. Both windows roll; back off rather than retrying in a loop.","RAW_DOWNLOAD_BUDGET_EXCEEDED":"429 — the rolling 24-hour raw-download cap is reached. GET /b2sc/usage reports used_24h and cap_24h.","UPGRADE_FOR_FULL_FIDELITY":"402 — this output is a paid-tier view; the free tier receives the aggregated one.","ANALYSIS_STAGE_NOT_OFFERED":"404 — this deployment does not run an analysis stage, so GET/POST /b2sc/jobs/{id}/report do not exist. Per-sample proportions and the generated single-cell matrix are the complete result: see GET /b2sc/jobs/{id}/results-data, /proportions and /files. The engine release guarantees inference; downstream analysis is a separate evaluation and is not offered until it has one.","RESULT_ARCHIVED":"409 — the generated cells have moved to archival storage and cannot be downloaded without a restore. Presigning them anyway would hand you a link that 403s when you follow it, so the refusal is up front and costs you nothing. GET /b2sc/jobs/{id}/files reports the archive state per file.","PHI_CASCADE_INCOMPLETE":"409 — the delete could not remove every object, so the job record was KEPT rather than reporting a deletion that did not happen. Retry; if it persists, contact support with the job id."}}