{"openapi":"3.0.3","info":{"title":"WittGen Bulk to Single Cell API","version":"1.0.0","description":"Programmatic access to WittGen Bulk to Single Cell (API namespace `b2sc`, which does not change): submit a bulk RNA-seq job, poll it to completion, and retrieve per-sample cell-type proportions and the generated single cells (an analysis stage with gene scores and plots exists as a mechanism; no model in the current release has one). 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, cell counts 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. Results, reports and run context move to archival storage after 90 days; an archived file appears in the file listing carrying `archived` instead of a URL, the proportions endpoints answer 409 RESULT_ARCHIVED, and POST /b2sc/jobs/{id}/restore brings the job back (a few hours; readable for 14 days afterwards). 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":["COHORT_DELETE_INCONSISTENT","INPUT_FILE_NOT_FOUND","INPUT_UPLOAD_INCOMPLETE","JOB_OPERATION_BUSY","JOB_WRITERS_NOT_STOPPED","INPUT_OPERATION_BUSY","INPUT_ENVIRONMENT_MISMATCH","INPUT_REFERENCE_CHECK_UNAVAILABLE","ACCOUNT_NOT_PROVISIONED","UPLOAD_REQUIRES_PROVISIONED_PLAN","API_KEY_INVALID","API_KEY_EXPIRED","API_KEY_REVOKED","INVALID_CURSOR","INVALID_MODEL","MODEL_NOT_FOUND","MODEL_NOT_PROVISIONED","INPUT_TOO_LARGE","COHORT_TOO_MANY_SAMPLES","COHORT_SAMPLE_COUNT_UNKNOWN","GEO_SERIES_NOT_VERIFIED_FOR_MODEL","INPUT_UNITS_NOT_COUNTS","INVALID_INPUT_UNITS","INVALID_ANALYSIS_MODE","INVALID_RUN_LABEL","RUN_LABEL_TOO_LONG","GENE_IDS_MUST_BE_SYMBOLS","GEO_SERIES_WRONG_MODEL","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","NOTHING_ARCHIVED","RESTORE_NOT_ENABLED","OBJECTS_IN_ANOTHER_ENVIRONMENT","PHI_CASCADE_INCOMPLETE"],"description":"COHORT_DELETE_INCONSISTENT: 409 — child ownership could not be verified. No data was deleted; contact support.\nINPUT_FILE_NOT_FOUND: 400 — the input no longer exists. Upload the file again.\nINPUT_UPLOAD_INCOMPLETE: 400 — stored bytes differ from the original declared size. Upload the complete file again.\nJOB_OPERATION_BUSY: 409 — another operation owns this analysis. No deletion started; retry shortly.\nJOB_WRITERS_NOT_STOPPED: 409 — stopping an analysis is asynchronous. No data was deleted because its writer has not stopped or could not be verified. Retry deletion shortly.\nINPUT_OPERATION_BUSY: 409 — this input is being submitted or deleted. Retry shortly; contact support if the guard persists.\nINPUT_ENVIRONMENT_MISMATCH: 409 — an input belongs to another deployment. No data was deleted.\nINPUT_REFERENCE_CHECK_UNAVAILABLE: 503 — input references could not be checked. No data was deleted; retry shortly.\nACCOUNT_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 not a model id of the current release (a public id such as breast-tumour-2k-v2, or its model_id). Ids of earlier releases are not accepted. The response lists the valid ids under accepted_models; GET /b2sc/models is authoritative. Not retryable.\nMODEL_NOT_FOUND: 404 — GET /b2sc/models/{id}/required-genes was asked for a model id this release does not serve. The response lists the valid ids under accepted_models.\nMODEL_NOT_PROVISIONED: 503 — the model exists but this deployment has no engine revision registered for its release yet, so the job was refused BEFORE anything was written or charged. Nothing to change on your side; retry once the release is provisioned here. The response carries release_id.\nINPUT_TOO_LARGE: 400 — the input exceeds the size limit. Split the cohort and submit one job per batch.\nCOHORT_TOO_MANY_SAMPLES: 400 — the header declares more sample columns than one submission may carry here: one job takes at most 64, and GET /b2sc/models publishes cohort.enabled, batch_size and max_samples. Where cohort splitting is off a matrix over batch_size is refused; where it is on, one over max_samples is. The response carries n_samples and batches_required. Split the matrix column-wise and submit one job per batch; per-sample proportions concatenate directly.\nCOHORT_SAMPLE_COUNT_UNKNOWN: 400 — the first line could not be read as a header, so the sample count is unknown. It is refused rather than assumed: a wrong count would run on a GPU and return a well-formed wrong answer.\nGEO_SERIES_NOT_VERIFIED_FOR_MODEL: 422 — the accession is not a GEO series we have run and checked against THIS model on the current release. Series are re-verified per release: each model requires its exact gene set, so a series that suited an earlier model says nothing about this one — and one that runs but is the wrong tissue returns confident proportions that mean nothing, so the list is an allow-list, not a validator. The response carries the series available for the model (none yet on the current release). Uploading a matrix directly is never restricted; check it against GET /b2sc/models/{id}/required-genes first.\nINPUT_UNITS_NOT_COUNTS: 400 — input_units declares an already-normalised matrix (TPM, FPKM, CPM, log-normalised, VST). The models normalise internally, so such a matrix is the wrong input rather than a lesser one: it would finish and the proportions would be wrong. Normalisation cannot be reversed on our side. Submit raw counts or a quantifier's expected counts.\nINVALID_INPUT_UNITS: 400 — input_units is not one of the accepted values. The response lists them. Omit the field if unsure: the matrix is classified from its values either way, and the declaration only resolves the one band values cannot.\nINVALID_ANALYSIS_MODE: 400 — `analysis` is not `auto` or `manual`. Omit it for `auto`.\nINVALID_RUN_LABEL: 400 — `name` or `study` is not storable as sent: it is not text, or it contains a line break or a control character. The field is rendered in one row of a run list and written into tab-separated exports, so it has to stay on one line. The response names the offending field. Nothing is silently stripped — what you send back is what you get back.\nRUN_LABEL_TOO_LONG: 400 — `name` or `study` is longer than 120 characters, counted in characters rather than bytes so the limit is the same in every language. The response names the field and repeats the limit.\nGENE_IDS_MUST_BE_SYMBOLS: 400 — gene_id_type asked for identifier conversion, and the current models convert nothing: every identifier in the first column is matched VERBATIM against the model's own required-gene list, exactly as that list spells it. The lists are not symbols-only — the T-ALL and sepsis lists mix HGNC symbols and Ensembl ids (ENSG…) gene by gene, as the reference data spelled them — so a conversion that renamed, dropped or merged an identifier would fail the whole run for what reads as a different reason, and would hide the exact mismatch the required-genes check exists to show. Download the list from GET /b2sc/models/{id}/required-genes, make the first column match it, and omit gene_id_type. Not retryable as sent.\nGEO_SERIES_WRONG_MODEL: 422 — a verified series, but not for the model requested. A model of another tissue would answer it, and the answer would not mean anything. The response lists the series verified for the model you asked for.\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.\nCOHORT_ANALYSIS_NOT_RERUNNABLE: 409 — this job is a cohort whose combined matrix was never prepared (its preparation task failed or never ran), so there is nothing for the analysis stage to read and POST /b2sc/jobs/{id}/report will not launch it. The per-sample proportions are complete and unaffected. Resubmit the cohort, or contact support with the job id. Not retryable.\nUPGRADE_FOR_FULL_FIDELITY: 402 — this output is a paid-tier view; the free tier receives the aggregated one.\nTOO_FEW_SAMPLES: 200 — gene_scores is withheld because the analysis has fewer samples than the gene-score floor; `reason` names the floor and this analysis's count. Proportions and generated cells are unaffected, and no plan change lifts it — the floor is statistical.\nANALYSIS_HELD: 200 — the analysis output for this job is withheld: it was produced under an earlier version of the analysis stage whose results cannot be stood behind. Proportions and generated cells are unaffected. analysis_status reads `failed`; once the corrected analysis stage is deployed, analysis_message names the re-run: POST /b2sc/jobs/{id}/report?force=1.\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.\nNOTHING_ARCHIVED: 409 — a restore was requested for a job whose files are all readable; nothing to do.\nRESTORE_NOT_ENABLED: 503 — this deployment cannot issue archive restores yet; the archive is intact. Contact support.\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.\nOBJECTS_IN_ANOTHER_ENVIRONMENT: 409 — this job record names an object prefix belonging to a different deployment, so this one will not read, list or erase it. The response carries recorded_prefix and this_environment_prefix. Not retryable here: the deployment that owns the objects is the one that can serve them.\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","cell_types","n_common_genes","release_id","model_id","input_gene_contract","seed_policy","n_cells_per_sample","expression_semantics","estimated_runtime_min"],"properties":{"id":{"type":"string","example":"breast-tumour-2k-v2","description":"The public model id. Submit this as disease_model; it is what every Job reads back."},"name":{"type":"string","example":"Breast tumour"},"subtitle":{"type":"string","nullable":true,"description":"Short qualifier shown beside the name: the method and the reference cohort."},"description":{"type":"string","nullable":true,"description":"One line on what the model produces."},"release_id":{"type":"string","example":"iclr27-pbe-bcs-main-20261016","description":"The immutable release a job on this model runs against — engine image, weights (bound by sha256), gene universe and cell-type axis together. Results are comparable only across jobs that share BOTH release_id and model_id, so record both with your results."},"model_id":{"type":"string","example":"iclr27-pbe-bcs-GSE176078","description":"The engine's own name for this model within the release — a plain cohort name. Also accepted as disease_model. A Job carries it from submission so a result can be attributed exactly."},"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."},"n_genes":{"type":"integer","example":2000,"description":"The gene panel the model selects from your matrix after it has been given every common gene. Informational: it is not what your file is checked against — input_gene_contract is."},"n_common_genes":{"type":"integer","example":16864,"description":"The size of the cohort's common gene set, every one of which your matrix must carry. Equals input_gene_contract.required_genes."},"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":"Tissue types this model is not built for. Given one of them it still returns well-formed proportions, so this is worth reading before a first submission."},"n_cell_types":{"type":"integer","description":"Distinct cell types a run returns for this model — the length of cell_types."},"cell_types":{"type":"array","items":{"type":"string"},"description":"The cell-type axis, in the order proportions.csv and cell_counts.csv carry it. Axes differ between models, so proportions from two models are not comparable."},"estimated_runtime_min":{"type":"integer","description":"See Job.estimated_runtime_min: warm-pool compute only, excluding GPU cold start."},"input_gene_contract":{"type":"object","description":"EXACTLY what the engine will demand of your matrix, published so a caller can check a file BEFORE spending a run on it. The contract is exact, not a floor: every one of required_genes must be present exactly once in the first column, under the identifiers the release was trained on (a mix of HGNC symbols and Ensembl ids for the T-ALL and sepsis models, HGNC symbols for breast — each exactly as the list spells it, matched verbatim). A missing gene is a refusal before any model work, with the missing identifiers named — never a zero. There is no percentage and nothing to tune; fetch the list from required_genes_url and take the set difference. The Python SDK and the dashboard both do this for you before uploading.","required":["kind","required_genes","panel_genes","required_genes_url"],"properties":{"kind":{"type":"string","enum":["exact_common_genes"]},"required_genes":{"type":"integer","example":16864,"description":"How many genes the list has — all of them required."},"panel_genes":{"type":"integer","example":2000,"description":"The panel the model then selects from them. Informational."},"required_genes_url":{"type":"string","example":"/api/v1/b2sc/models/breast-tumour-2k-v2/required-genes","description":"Where the list is, on the mount you are talking to."}}},"seed_policy":{"type":"object","description":"Generation is seeded. default_seed is what EVERY run uses — it is fixed per release and there is no request parameter that selects a seed. seeds lists the seeds the release ships weight sets for; the ones other than default_seed are shipped but not selectable yet. Same seed, device, dependencies, solver and batch size reproduce a run bitwise on the same GPU model; cross-device equality is not promised.","required":["default_seed","seeds"],"properties":{"default_seed":{"type":"integer","example":20260904},"seeds":{"type":"array","items":{"type":"integer"},"example":[20260904,20260905,20260906]}}},"n_cells_per_sample":{"type":"integer","example":1000,"description":"Generated single cells per bulk sample, allocated across cell types by the predicted proportions (largest remainder)."},"expression_semantics":{"type":"string","example":"continuous BCS-corrected generated expression; not measured or integer counts","description":"What the values in generated_cells.h5ad / .npz ARE, in the engine's own words. They are continuous generated expression, not counts: do not feed them to a tool that expects integer UMI counts."},"reference_dataset":{"type":"object","nullable":true,"description":"The release's own golden input for this model: what source=reference_dataset runs, and what the engine's end-to-end check runs.","properties":{"filename":{"type":"string","example":"GSE176078_bulk.csv"},"n_samples":{"type":"integer","description":"Sample columns in the reference matrix."},"description":{"type":"string"},"source":{"type":"string"}}}}},"RequiredGenes":{"type":"object","description":"The exact gene list a model requires. 17k–56k identifiers (under 600 KB). Cache it per model: the response carries a strong ETag (the cohort's manifest sha256) and Cache-Control: private, max-age=86400, and answers 304 to If-None-Match.","required":["model","model_id","release_id","n_required","manifest_sha256","genes"],"properties":{"model":{"type":"string","example":"breast-tumour-2k-v2","description":"The public id."},"model_id":{"type":"string","example":"iclr27-pbe-bcs-GSE176078"},"release_id":{"type":"string","example":"iclr27-pbe-bcs-main-20261016"},"n_required":{"type":"integer","example":16864,"description":"genes.length. Equals Model.input_gene_contract.required_genes."},"manifest_sha256":{"type":"string","description":"sha256 of the per-cohort model.json the list was read from, which the release index binds. Also the ETag."},"genes":{"type":"array","items":{"type":"string"},"description":"Every identifier your first column must contain exactly once, exactly as written here."}}},"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 — the public id, or the model_id beside it. Ids of earlier releases are refused with INVALID_MODEL.","example":"breast-tumour-2k-v2"},"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"},"original_filename":{"type":"string","description":"Optional with source=user_upload. The name you know the file by, carried onto the job and back into the results so a run is identifiable by something other than an s3Key. Not used to locate anything — input_file_key does that.","example":"my_cohort.tsv"},"geo_accession":{"type":"string","description":"Required when source=geo. Only accessions we have run and checked against the model ON THE CURRENT RELEASE are accepted; anything else is refused with GEO_SERIES_NOT_VERIFIED_FOR_MODEL and the response lists what IS available for that model (nothing yet on the current release — series are re-verified per release). Requires a provisioned plan, since the run costs GPU time.","example":"GSE149050"},"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":"GSE149050_raw_counts.tsv.gz"},"input_units":{"type":"string","enum":["raw_counts","expected_counts","tpm","fpkm","rpkm","cpm","normalized","log_normalized","vst"],"description":"Optional, and it CONVERTS NOTHING — normalisation cannot be reversed. It exists because the engine names one band it cannot settle from values alone: frequent fractional values in a compressed range fit shallow quantifier expected counts and log-normalised expression equally, and log2(TPM+1) sits in it, so such a matrix RUNS and returns confident, wrong proportions. Only you know which yours is. Declaring a normalised unit is refused in seconds with INPUT_UNITS_NOT_COUNTS; declaring a count scale is recorded on the job. Omitting it changes nothing — the matrix is classified from its values either way.","example":"raw_counts"},"gene_id_type":{"type":"string","enum":["symbol"],"description":"Optional, and `symbol` is the only value — it means \"as the list spells it\", not \"HGNC symbols only\". The current models require every gene of their common gene set to be present exactly once under the identifiers the release was trained on (a mix of HGNC symbols and Ensembl ids for two of the three models), matched verbatim against GET /b2sc/models/{id}/required-genes, so identifiers are NOT converted for an upload: a conversion that drops or merges an identifier would fail the whole run for what reads as a different reason, and renaming ids silently would hide the exact mismatch the preflight exists to show. Anything else is refused with GENE_IDS_MUST_BE_SYMBOLS. Fetch the exact list from GET /b2sc/models/{id}/required-genes and make the first column match it.","example":"symbol"},"name":{"type":"string","maxLength":120,"description":"Optional. Your own name for this run, returned unchanged on every Job. Free text — nothing parses it — so it is for you and your colleagues to read, not for us to key on. Refused with INVALID_RUN_LABEL if it carries a line break or a control character (it is rendered in one row of a list and one field of a tab-separated export) and with RUN_LABEL_TOO_LONG past 120 characters, counted in characters rather than bytes. Leading and trailing whitespace is trimmed; blank is the same as omitting it. Omit it and the run is identified by original_filename.","example":"SLE PBMC cohort, 5k panel"},"study":{"type":"string","maxLength":120,"description":"Optional. The study, grant or protocol this run belongs to. Same rules as `name`, and kept separate from it so a reporting tool can group runs without parsing names.","example":"NRF-2026-0142"},"analysis":{"type":"string","enum":["auto","manual"],"default":"auto","description":"Whether the analysis stage (UMAP, markers, gene scores — the R pipeline) runs on its own when the model finishes. `auto` (the default) launches it as soon as the cells are generated. `manual` leaves the job at analysis_status `not_started` and analysis_message says so; run it later with POST /b2sc/jobs/{id}/report (or the \"Run analysis\" control on the results page). Anything else is refused with INVALID_ANALYSIS_MODE. Ignored by models that have no analysis stage."}}},"Job":{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"status":{"type":"string","description":"PENDING | QUEUED | LAUNCHING | DOWNLOADING_INPUT | LOADING_MODEL | RUNNING_STAGE1 | RUNNING_STAGE2 | UPLOADING_RESULTS | COMPLETED | FAILED — and for a cohort (more than one batch): SPLITTING | RUNNING_BATCHES before COMPLETED. Stop on COMPLETED or FAILED."},"disease_model":{"type":"string"},"refusal_code":{"type":"string","nullable":true,"description":"On a FAILED job: INPUT_REJECTED when the engine refused your matrix (a missing required gene, a duplicate, a negative or non-finite value, a zero library size — message says which). null on a fault of ours, which is not billed and which you cannot fix by changing the file."},"billable":{"type":"boolean","nullable":true,"description":"false when the run was not charged — the engine marks a refused input so; the usage ledger reverses the charge on the next sweep. null while nothing has decided."},"name":{"type":"string","nullable":true,"maxLength":120,"example":"SLE PBMC cohort, 5k panel","description":"Your own name for this run, exactly as you sent it to POST /b2sc/jobs. Optional and free text — nothing here parses it. `null` when you did not give one, and on every job submitted before run names existed; fall back to original_filename rather than to job_id. Refused at submit (400 INVALID_RUN_LABEL) if it contains a line break or a control character, because it is written into one row of a list and one field of a tab-separated export. Leading and trailing whitespace is trimmed; nothing else is changed, so what you read back is what you sent."},"study":{"type":"string","nullable":true,"maxLength":120,"example":"NRF-2026-0142","description":"The study, grant or protocol this run belongs to, exactly as you sent it. Same rules as `name`: optional, free text, nullable, never parsed and never rewritten."},"analysis_mode":{"type":"string","enum":["auto","manual"],"description":"What was asked at submit: `auto` launches the analysis stage on its own; `manual` leaves it at `not_started` until POST /b2sc/jobs/{id}/report is called. Jobs submitted before the option existed read `auto`. Do not poll `not_started` on a manual job — nothing is coming until you ask."},"release_id":{"type":"string","nullable":true,"example":"iclr27-pbe-bcs-main-20261016","description":"The model release this job ran against — the answer to \"which weights produced these numbers\" months later. Stamped at submission from the catalogue and confirmed by the run's own platform_manifest.json. Results from two jobs are comparable only when release_id AND model_id agree; per-run detail (engine commit, seed, device, a sha256 per artifact) is in run_manifest.json, listed by GET /b2sc/jobs/{id}/files."},"model_id":{"type":"string","nullable":true,"example":"iclr27-pbe-bcs-GSE176078","description":"The engine's model within the release (Model.model_id), stamped at submission. With release_id, the exact provenance of the run."},"seed":{"type":"integer","nullable":true,"example":20260904,"description":"The generation seed. The release default until the run records its own."},"n_cells_per_sample":{"type":"integer","nullable":true,"example":1000,"description":"Generated single cells per bulk sample."},"n_cell_types":{"type":"integer","nullable":true,"example":11,"description":"cell_types.length."},"cell_types":{"type":"array","nullable":true,"items":{"type":"string"},"description":"The cell-type axis of this run, in the order the proportions carry it — from the run once it has completed, else from the model."},"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 2026-10-17 on release iclr27-pbe-bcs-main-20261016: the engine takes 8–10 s and the container about 30 s for a one-sample run on an L4; runtime scales with sample count, not with the model."},"created_at":{"type":"string","format":"date-time"}},"required":["job_id","status","disease_model","release_id","model_id","seed","n_cells_per_sample","n_cell_types","cell_types","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 public id, whichever identifier you submitted.","example":"breast-tumour-2k-v2"},"release_id":{"type":"string","description":"The model release this job was accepted for. See Job.release_id.","example":"iclr27-pbe-bcs-main-20261016"},"model_id":{"type":"string","description":"See Job.model_id.","example":"iclr27-pbe-bcs-GSE176078"},"estimated_runtime_min":{"type":"integer"},"created_at":{"type":"string","format":"date-time"}},"required":["job_id","status","disease_model","release_id","model_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":"proportions.csv","description":"A completed run lists proportions.csv, cell_counts.csv, run_manifest.json, platform_manifest.json and verification.json as result files, the generated matrix as generated_cells.h5ad and generated_cells.npz (see include_raw), and your input."},"type":{"type":"string","description":"csv | text (json, txt) | h5ad | binary (npz) | image | pdf | markdown | archive"},"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; `reason` says which: REFERENCE_DATASET (the raw generated matrix of a source=reference_dataset run — the input is WittGen data), RAW_OUTPUT_OWNER_ONLY (you can read this job but do not own it), RAW_NOT_REQUESTED (pass include_raw=1), RAW_DOWNLOAD_BUDGET_EXCEEDED, INPUT_OWNER_ONLY. `url` is an empty string. The proportions, cell counts, manifests and verification always carry a normal download URL."},"category":{"type":"string","enum":["analysis","r_outputs","result","input","report"],"description":"result = proportions, cell counts, run and platform manifests, verification, and the generated matrix · input = what you submitted · analysis (legacy alias r_outputs) = analysis-stage outputs, when a model has that stage · 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","required":["disease_model","model_id","release_id","n_samples","n_cell_types","cell_types","input_contract","created_at"],"properties":{"disease_model":{"type":"string","description":"The public model id."},"model_id":{"type":"string","nullable":true,"description":"From the run's platform manifest once written, else from the job record."},"release_id":{"type":"string","nullable":true},"n_samples":{"type":"integer","description":"From the run's own manifest: the samples the engine actually ran."},"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."},"cell_types":{"type":"array","items":{"type":"string"},"description":"The axis of this run, in column order."},"n_genes":{"type":"integer","nullable":true,"description":"The model's panel size (Model.n_genes). Informational."},"n_cells_per_sample":{"type":"integer","nullable":true},"seed":{"type":"integer","nullable":true,"description":"The seed this run generated with."},"expression_semantics":{"type":"string","nullable":true,"description":"What the generated values are, in the engine's words. Not counts."},"engine_code_version":{"type":"string","nullable":true,"description":"The engine source commit that produced this run."},"input_contract":{"type":"object","nullable":true,"description":"The input contract this run was held to. Exact — every one of required_genes present once — so there is no coverage percentage: a run either had every gene and ran, or was refused before any model work with the missing identifiers named. `satisfied` is true once the run has written results, by construction.","required":["kind","required_genes","satisfied"],"properties":{"kind":{"type":"string","enum":["exact_common_genes"]},"required_genes":{"type":"integer"},"satisfied":{"type":"boolean"}}},"runtime_seconds":{"type":"number","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},"samples_used":{"type":"integer","description":"cumulative samples submitted; this is what the trial gate counts"},"samples_quota":{"type":"integer","nullable":true,"description":"trial sample cap; null = unlimited (paid/enterprise)"},"samples_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, and what one submission may carry on this deployment","content":{"application/json":{"schema":{"type":"object","properties":{"models":{"type":"array","items":{"$ref":"#/components/schemas/Model"}},"analysis_stage_offered":{"type":"boolean","description":"Whether this deployment runs the downstream analysis stage at all. Read it before waiting on report_status."},"cohort":{"type":"object","description":"How many samples one submission may carry HERE, and what happens past the per-job limit. Read this instead of assuming a limit: a client that invents one refuses files this deployment would have accepted.","properties":{"enabled":{"type":"boolean","description":"Whether a matrix over batch_size is SPLIT into batches and accepted, or refused with COHORT_TOO_MANY_SAMPLES."},"batch_size":{"type":"integer","example":64,"description":"Samples per job. A matrix at or below this is always one job."},"max_samples":{"type":"integer","example":4096,"description":"The largest matrix one submission may carry: batch_size when enabled is false, and the fan-out ceiling when it is true. Past this it is refused whichever way the flag is set."}}}}}}}},"401":{"description":"Missing/invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/b2sc/models/{id}/required-genes":{"get":{"summary":"The exact gene list a model requires of your matrix","description":"Every identifier the first column of your matrix must contain exactly once, exactly as written here — HGNC symbols and, for the T-ALL and sepsis models, Ensembl ids (ENSG…), mixed gene by gene as the reference data spelled them; nothing is converted on the way in. The current models have no floor and no percentage, and a missing gene is a refusal with the missing identifiers named. Fetch this once per model, take the set difference against your gene column, and fix the file BEFORE uploading; the dashboard and the Python SDK do exactly that. The list is 17k–56k identifiers (under 600 KB) and is immutable per release: the response carries ETag (the cohort's manifest sha256) and Cache-Control: private, max-age=86400, and answers 304 to If-None-Match.","operationId":"getRequiredGenes","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The public id or the model_id from GET /b2sc/models."},{"name":"If-None-Match","in":"header","required":false,"schema":{"type":"string"},"description":"The ETag from a previous response; 304 when the list is unchanged."}],"responses":{"200":{"description":"The list","headers":{"ETag":{"schema":{"type":"string"}},"Cache-Control":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequiredGenes"}}}},"304":{"description":"Unchanged since the ETag you sent."},"401":{"description":"Missing/invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"MODEL_NOT_FOUND - not a model id of the current release. The body lists the valid ids.","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 }. DEPRECATED FIELD: context_s3_key has been null on every job since the 2026-08-08 engine change — no engine produces context.json any more. It is still sent so that a client reading it does not break. Run provenance lives in run_manifest.json alongside the results.","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,"deprecated":true,"description":"DEPRECATED, and null on every job since 2026-08-08. It pointed at context.json, which no engine produces any more. The field is kept so a client that reads it does not break, and it is documented as always-null rather than quietly repointed at a different artifact — changing what a published field MEANS without changing its name is worse than either leaving it or removing it. Run provenance is in run_manifest.json beside the results."},"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, raw counts, and the\nfirst column holding EVERY identifier of the model's required gene list exactly once —\nGET /b2sc/models/{id}/required-genes, exactly as written there. There is no floor: a missing\ngene is refused with the missing identifiers named, and a duplicated identifier is refused too\n(one gene with two rows has no single value, and we will not choose one on your behalf).\nIdentifiers are not converted for an upload; sum duplicate rows and map ids yourself before\nuploading. 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 Bulk to Single Cell 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, and what one SUBMISSION may carry is published on GET /b2sc/models as cohort: { enabled, batch_size, max_samples }. Where cohort.enabled is true, a wider matrix up to max_samples is accepted and split into batches of batch_size for you (the job answers cohort: true with n_samples). Where it is false, max_samples equals batch_size and a wider matrix is refused with 400 COHORT_TOO_MANY_SAMPLES — 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 contain EVERY gene of the model's common gene set exactly once, under the identifiers GET /b2sc/models/{id}/required-genes lists (16,864 to 55,719 depending on the model; the count is input_gene_contract.required_genes on GET /b2sc/models). There is no floor: a missing gene is refused before any model work with the missing identifiers named, so check the set difference before you upload. A model whose release is not provisioned on this deployment yet is refused with 503 MODEL_NOT_PROVISIONED before anything is written.","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: INVALID_MODEL, GENE_IDS_MUST_BE_SYMBOLS, 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 (code RATE_LIMITED). The count is of EVERY job submitted in the window whatever its source, so reference-dataset runs and your own data share one budget. Set to stop a runaway loop, not to ration an evaluation — wait rather than retrying in a loop. THIS IS NOW THE ONLY 429 FROM THIS ROUTE: TOO_MANY_RUNNING was retired on 2026-10-05, because how many analyses RUN at once is a scheduling question rather than an admission one. A submission past the cap is accepted and QUEUED, and starts on its own as one of your own runs finishes.","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"}}}},"503":{"description":"MODEL_NOT_PROVISIONED - this deployment has no engine revision registered for the model's release yet. Refused before anything was written or charged; retry once it is provisioned.","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."},"409":{"description":"RESULT_ARCHIVED - the per-sample proportions behind this aggregate have moved to archival storage; the body carries the archive state (archived | restoring, with a note or the restore deadline). Nothing to retry until a restore completes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/b2sc/jobs/{id}":{"delete":{"summary":"Delete a job and its artifacts","description":"Removes the job record, outputs and unreferenced inputs (including the original before preparation). Inputs still referenced by another analysis are retained; the response includes shared_input_retained. 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; shared_input_retained is true when another analysis still references an input.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"job_id":{"type":"string"},"shared_input_retained":{"type":"boolean"}}}}}},"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":"A writer has not stopped, the input is busy, belongs to another deployment, or some artifacts could not be erased. The job record is kept; the code identifies the reason.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Input references could not be verified; no data was deleted.","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: proportions.csv and cell_counts.csv (sample_id plus one column per\ncell type, in Model.cell_types order), run_manifest.json and platform_manifest.json (the\nprovenance: release, model, seed, engine commit, image digest, a sha256 per artifact),\nverification.json (the output-contract checks), the generated single-cell matrix (see\ninclude_raw), and your input matrix.\n\nResult entries appear once the run has COMPLETED; before that they are simply absent rather\nthan an error.","operationId":"listJobFiles","parameters":[{"name":"include_raw","in":"query","required":false,"schema":{"type":"boolean"},"description":"Presign the raw generated matrix — generated_cells.h5ad AND generated_cells.npz — in this listing. OFF by default: presigning it spends one of the caller's rolling 24-hour raw downloads (one for both encodings together), and listing your files should not cost a download. Without it both files are still listed, carrying an empty url and reason RAW_NOT_REQUESTED. GET /b2sc/jobs/{id}/result is the endpoint that exists for the h5ad 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}/restore":{"post":{"summary":"Bring an archived job back from archival storage","description":"Results, reports and run context move to archival storage 90 days after they land. This asks\nfor a Standard-tier restore of every archived file of the job (typically 3–5 hours); the files\nthen stay readable for 14 days and the listing/proportions endpoints answer normally again.\nAsking again while a restore is in flight is a no-op (counted under `restoring`), not an error.","operationId":"restoreJob","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"202":{"description":"Restore requested: counts of files requested / already restoring / already restored / readable, plus a note with the expected time.","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"requested":{"type":"integer"},"restoring":{"type":"integer"},"restored":{"type":"integer"},"readable":{"type":"integer"},"note":{"type":"string"}},"required":["job_id","requested","restoring","note"]}}}},"401":{"description":"Missing/invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Job not found (or not yours).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"NOTHING_ARCHIVED - every file of this job is readable; nothing to restore.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"RESTORE_NOT_ENABLED - this deployment cannot issue restores yet; the archive is intact. Contact support.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/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. Also RESULT_ARCHIVED - the file has moved to archival storage; the body carries the archive state (archived | restoring).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"x-error-codes":{"COHORT_DELETE_INCONSISTENT":"409 — child ownership could not be verified. No data was deleted; contact support.","INPUT_FILE_NOT_FOUND":"400 — the input no longer exists. Upload the file again.","INPUT_UPLOAD_INCOMPLETE":"400 — stored bytes differ from the original declared size. Upload the complete file again.","JOB_OPERATION_BUSY":"409 — another operation owns this analysis. No deletion started; retry shortly.","JOB_WRITERS_NOT_STOPPED":"409 — stopping an analysis is asynchronous. No data was deleted because its writer has not stopped or could not be verified. Retry deletion shortly.","INPUT_OPERATION_BUSY":"409 — this input is being submitted or deleted. Retry shortly; contact support if the guard persists.","INPUT_ENVIRONMENT_MISMATCH":"409 — an input belongs to another deployment. No data was deleted.","INPUT_REFERENCE_CHECK_UNAVAILABLE":"503 — input references could not be checked. No data was deleted; retry shortly.","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 not a model id of the current release (a public id such as breast-tumour-2k-v2, or its model_id). Ids of earlier releases are not accepted. The response lists the valid ids under accepted_models; GET /b2sc/models is authoritative. Not retryable.","MODEL_NOT_FOUND":"404 — GET /b2sc/models/{id}/required-genes was asked for a model id this release does not serve. The response lists the valid ids under accepted_models.","MODEL_NOT_PROVISIONED":"503 — the model exists but this deployment has no engine revision registered for its release yet, so the job was refused BEFORE anything was written or charged. Nothing to change on your side; retry once the release is provisioned here. The response carries release_id.","INPUT_TOO_LARGE":"400 — the input exceeds the size limit. Split the cohort and submit one job per batch.","COHORT_TOO_MANY_SAMPLES":"400 — the header declares more sample columns than one submission may carry here: one job takes at most 64, and GET /b2sc/models publishes cohort.enabled, batch_size and max_samples. Where cohort splitting is off a matrix over batch_size is refused; where it is on, one over max_samples is. The response carries n_samples and batches_required. Split the matrix column-wise and submit one job per batch; per-sample proportions concatenate directly.","COHORT_SAMPLE_COUNT_UNKNOWN":"400 — the first line could not be read as a header, so the sample count is unknown. It is refused rather than assumed: a wrong count would run on a GPU and return a well-formed wrong answer.","GEO_SERIES_NOT_VERIFIED_FOR_MODEL":"422 — the accession is not a GEO series we have run and checked against THIS model on the current release. Series are re-verified per release: each model requires its exact gene set, so a series that suited an earlier model says nothing about this one — and one that runs but is the wrong tissue returns confident proportions that mean nothing, so the list is an allow-list, not a validator. The response carries the series available for the model (none yet on the current release). Uploading a matrix directly is never restricted; check it against GET /b2sc/models/{id}/required-genes first.","INPUT_UNITS_NOT_COUNTS":"400 — input_units declares an already-normalised matrix (TPM, FPKM, CPM, log-normalised, VST). The models normalise internally, so such a matrix is the wrong input rather than a lesser one: it would finish and the proportions would be wrong. Normalisation cannot be reversed on our side. Submit raw counts or a quantifier's expected counts.","INVALID_INPUT_UNITS":"400 — input_units is not one of the accepted values. The response lists them. Omit the field if unsure: the matrix is classified from its values either way, and the declaration only resolves the one band values cannot.","INVALID_ANALYSIS_MODE":"400 — `analysis` is not `auto` or `manual`. Omit it for `auto`.","INVALID_RUN_LABEL":"400 — `name` or `study` is not storable as sent: it is not text, or it contains a line break or a control character. The field is rendered in one row of a run list and written into tab-separated exports, so it has to stay on one line. The response names the offending field. Nothing is silently stripped — what you send back is what you get back.","RUN_LABEL_TOO_LONG":"400 — `name` or `study` is longer than 120 characters, counted in characters rather than bytes so the limit is the same in every language. The response names the field and repeats the limit.","GENE_IDS_MUST_BE_SYMBOLS":"400 — gene_id_type asked for identifier conversion, and the current models convert nothing: every identifier in the first column is matched VERBATIM against the model's own required-gene list, exactly as that list spells it. The lists are not symbols-only — the T-ALL and sepsis lists mix HGNC symbols and Ensembl ids (ENSG…) gene by gene, as the reference data spelled them — so a conversion that renamed, dropped or merged an identifier would fail the whole run for what reads as a different reason, and would hide the exact mismatch the required-genes check exists to show. Download the list from GET /b2sc/models/{id}/required-genes, make the first column match it, and omit gene_id_type. Not retryable as sent.","GEO_SERIES_WRONG_MODEL":"422 — a verified series, but not for the model requested. A model of another tissue would answer it, and the answer would not mean anything. The response lists the series verified for the model you asked for.","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.","NOTHING_ARCHIVED":"409 — a restore was requested for a job whose files are all readable; nothing to do.","RESTORE_NOT_ENABLED":"503 — this deployment cannot issue archive restores yet; the archive is intact. Contact support.","OBJECTS_IN_ANOTHER_ENVIRONMENT":"409 — this job record names an object prefix belonging to a different deployment, so this one will not read, list or erase it. The response carries recorded_prefix and this_environment_prefix. Not retryable here: the deployment that owns the objects is the one that can serve them.","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."}}