Bulk to Single Cell API
BETAOverview
You send a bulk RNA-seq count matrix. A model returns the proportion of each cell type in every sample, and a set of generated single cells consistent with that composition. Bulk sequencing averages over every cell in a sample, so it cannot tell you which cell types were present in what amounts; a real single-cell experiment can, but needs fresh tissue and a much larger budget. This estimates the former from the latter.
https://www.wittgenbio.com/api/v1. Keys work once your account is enabled for Bulk to Single Cell; if a fresh key answers 403 ACCOUNT_NOT_PROVISIONED, contact us and we will enable it. The API namespace is b2sc — the product's code name — and it does not change when the product name does.What you get back
One row per sample per cell type, summing to 1.000 within a sample. Long or wide. This is the primary result and what most integrations consume.
An .h5ad matrix of 1,000 generated cells per sample over the model's 2,000-gene panel, consistent with those proportions — readable by scanpy/anndata in Python or Seurat in R. The values are continuous BCS-corrected generated expression; not measured or integer counts.
Which release and model produced the result, the seed, the cells per sample, the engine code version and image digest, and a verification report — enough to say months later exactly what made these numbers, and to reproduce them on the same hardware.
Quickstart
A real result in about a minute, using the reference input that ships beside each model release. Nothing to upload and nothing to prepare — run this first to prove your key works and to see the shape of what comes back.
pip install 'wittgen-b2sc[pandas]' # the [pandas] extra is what as_dataframe=True below needs
from wittgen_b2sc import B2SCClient
client = B2SCClient(api_key="wgk_…")
job = client.submit_job(disease_model="breast-tumour-2k-v2", source="reference_dataset")
client.wait_for_completion(job["job_id"]) # polls for you
df = client.get_proportions(job["job_id"], as_dataframe=True)
print(df.head())
# sample cell_type proportion
# 0 CID3586 B-cells 0.028853
# 1 CID3586 Cancer Epithelial 0.224694
# 2 CID3586 Cycling T-cells 0.043717Check your key before anything else
curl -H "Authorization: Bearer $WITTGEN_API_KEY" https://www.wittgenbio.com/api/v1/b2sc/models
# 200 -> your key works, and you get the model catalogue
# 401 -> the key is missing, malformed or revokedRead a published cohort
An atlas is a cohort we have already run, owned by a system account and readable by every key — every read endpoint accepts its job id, so anything you learn on one transfers directly to your own jobs.
Atlases are readable by every account and writable by none.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" https://www.wittgenbio.com/api/v1/b2sc/atlasesThe same call you will make on your own job.
df = client.get_proportions("<atlas job id>", as_dataframe=True)
print(df.shape) # (samples x cell types, 3)Sample count, the cell-type axis, the seed and cells per sample, and the input contract the run was held to.
rd = client.get_results_data("<atlas job id>")
print(rd["metadata"]["n_samples"], rd["metadata"]["release_id"])Run your own cohort
Upload is two steps: ask for a presigned S3 URL, then send the bytes straight to S3. Your file never passes through our API, so it is not subject to a request size limit or a gateway timeout. The SDK wraps both steps; the raw form is shown too because the PUT has a detail that will otherwise cost you an afternoon.
Prepare the matrix
| REQUIREMENT | DETAIL |
|---|---|
| Orientation | First column is the gene identifier; every other column is one sample. First row is the sample names. |
| Identifiers | EXACTLY the model's gene list, spelled as the list spells it — download it from GET /b2sc/models/{id}/required-genes. Two of the three lists mix HGNC symbols and Ensembl ids, gene by gene; nothing is converted on the way in, and a gene the list names that your file lacks refuses the run. |
| Values | RAW COUNTS. Not TPM, not FPKM, not log-transformed. See the input contract — this is the one mistake that fails silently. |
| Samples | One job runs at most 64 (cohort.batch_size on GET /b2sc/models). A wider matrix is split into batches for you where cohort.enabled is true, up to cohort.max_samples; where it is false the submission is refused with COHORT_TOO_MANY_SAMPLES and you split it yourself — see the batching tutorial. |
| Format | .tsv .csv .txt, each optionally gzipped. All six run identically — verified. |
key = client.upload_file("my_cohort.tsv.gz") # both steps, both headers
job = client.submit_job("breast-tumour-2k-v2", source="user_upload", input_file_key=key)
client.wait_for_completion(job["job_id"])
df = client.get_proportions(job["job_id"], as_dataframe=True)Check the gene list before you upload
The dashboard does this for you in the browser before a byte is uploaded. From code, fetch the list once per model — it is immutable per release and the response says so with an ETag — and take the set difference yourself:
import pandas as pd, requests
required = requests.get(
"https://www.wittgenbio.com/api/v1/b2sc/models/breast-tumour-2k-v2/required-genes",
headers={"Authorization": f"Bearer {API_KEY}"},
).json()["genes"] # 16,864 identifiers for this model
m = pd.read_csv("my_cohort.tsv.gz", sep="\t", index_col=0)
missing = [g for g in required if g not in m.index]
print(len(missing), missing[:20]) # 0 -> every required gene is presentRun a cohort larger than 64 samples
One job runs at most 64 samples. GET /b2sc/models publishes what a submission may carry as cohort: { enabled, batch_size, max_samples }. With enabled true, a wider matrix up to max_samples is accepted and split into batches of batch_size for you, and the job reports cohort: true with n_samples. With enabled false, max_samples equals batch_size and a wider matrix is refused at submit with COHORT_TOO_MANY_SAMPLES — split it into batches, submit one job each and concatenate. Every batch of one model returns the same cell-type columns in the same order, which is what makes that safe.
import pandas as pd
m = pd.read_csv("cohort_300.tsv", sep="\t", index_col=0) # genes x samples
batches = [m.iloc[:, i:i + 64] for i in range(0, m.shape[1], 64)]
jobs = []
for n, b in enumerate(batches):
path = f"batch{n:02d}.tsv.gz"
b.to_csv(path, sep="\t", compression="gzip")
key = client.upload_file(path)
jobs.append(client.submit_job("breast-tumour-2k-v2",
source="user_upload", input_file_key=key)["job_id"])
frames = []
for jid in jobs:
client.wait_for_completion(jid)
frames.append(client.get_proportions(jid, as_dataframe=True))
# Safe to concatenate: every batch returns the same cell types in the same order.
all_props = pd.concat(frames, ignore_index=True)Run a verified GEO series
Name a verified accession instead of uploading a file and the server fetches the series, picks the count matrix out of its supplementary listing, and runs it. The list is short on purpose: it holds the series we have actually run against the current release, not the ones that look like they would.
The verified series
| ACCESSION | MODEL | SAMPLES | WHAT IT IS | VERIFIED |
|---|---|---|---|---|
| — | — | — | No series is verified against release iclr27-pbe-bcs-main-20261016 yet. | — |
job = client.submit_job("t-all-2k-v2", source="geo", geo_accession="GSE000000")
# -> B2SCError GEO_SERIES_NOT_VERIFIED_FOR_MODEL until the accession is on the list for this model;
# the response carries the series that ARE available for it (none today).What a series has to satisfy before we add it
| MUST BE TRUE | WHY | HOW WE CHECK |
|---|---|---|
| A supplementary gene-level count matrix | We read the series listing, not the SRA runs. No matrix, nothing to run. | GEO_NO_CANDIDATE |
| Every gene the model requires, present once | The model normalises each sample over its whole gene list and refuses a missing gene rather than zero-filling it. The GEO path reads the symbol out of a compound identifier (ENSG…|SYMBOL) and sums duplicate rows; it cannot invent a gene the series does not publish. | The exact list per model, published on GET /b2sc/models/{id}/required-genes; a series is run against it before it is listed. |
| Raw counts, or quantifier expected counts | Integers are the common case, but FRACTIONAL values are accepted: gene-level expected counts from kallisto, salmon or RSEM are ordinary RNA-seq output and are exactly what the models want. What is refused is NORMALISED data — the check is on the values, not on whether they happen to be whole numbers. TPM/FPKM is the wrong input, not a lesser one; it finishes and the numbers are wrong. | GEO_NO_RAW_COUNTS by name; by value in the pipeline, which classifies the matrix before any model work. |
| At most 64 samples per job | One job is 64 samples (cohort.batch_size on GET /b2sc/models). | Where cohort.enabled is true a wider series is split into batches for you; where it is false it is refused at submit with COHORT_TOO_MANY_SAMPLES and the batch count in the message |
| The right tissue, read sample by sample | A model trained on one tissue returns confident proportions for another. Nothing downstream can see that, so a person reads the sample titles before a series goes on the list. | By hand — this is the check no code performs |
When it refuses, and why that is the point
| SITUATION | RESPONSE | WHAT TO DO |
|---|---|---|
| The accession is not on the verified list | GEO_SERIES_NOT_VERIFIED_FOR_MODEL | The response carries the series that ARE available for that model. Upload the matrix directly, or send us the accession and we will check it. |
| A verified series, but the wrong model for it | GEO_SERIES_WRONG_MODEL | A series is verified against one model's gene list and tissue; asking another model for it is refused rather than answered. The model would return proportions, and they would not mean anything. |
| Two plausible count matrices | GEO_AMBIGUOUS | The response carries the file listing. Re-submit with geo_file naming the one you want. |
| The series publishes no raw counts | GEO_NO_RAW_COUNTS | GSE81538 and GSE96058 are both this case: their supplementary files are transformed matrices. Nothing here can use them — upload counts instead. |
Wire it into an unattended pipeline
What changes when nobody is watching: submissions must be safe to retry, polling must have a ceiling, and a failure has to be distinguishable from a delay.
Make the submission safe to retry
A submission is a GPU run and a metered unit, so a duplicate is a second charge for work you already asked for. The realistic cause is not a careless client but an ordinary network timeout: the request arrives, the response does not. Send an Idempotency-Key and a retry returns the SAME job instead of starting another.
curl -X POST https://www.wittgenbio.com/api/v1/b2sc/jobs \
-H "Authorization: Bearer $WITTGEN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"disease_model":"breast-tumour-2k-v2","source":"reference_dataset"}'| REPLAYING A KEY | RESPONSE |
|---|---|
| The job exists | 200 with that job — no second run, no quota consumed |
| Still being accepted | 202 IDEMPOTENT_REQUEST_IN_FLIGHT — retry in a moment, keep the key |
| The job was deleted | 409 IDEMPOTENT_JOB_GONE — use a new key |
| Malformed key | 400 INVALID_IDEMPOTENCY_KEY — printable ASCII, no spaces, ≤200 chars |
Generate the key yourself and keep it with the retry — a UUID is the obvious choice. Its scope is your account, so two accounts may use the same value without colliding, and it is forgotten after 24 hours.
Poll with a ceiling, and stop on terminal states only
import time
def wait(job_id, timeout=10800, interval=15):
deadline = time.monotonic() + timeout
while True:
job = client.get_job(job_id)
if job["status"] == "COMPLETED":
return job
if job["status"] == "FAILED":
# message is written for a person and says what to change
raise RuntimeError(f"{job_id}: {job['message']}")
if time.monotonic() > deadline:
raise TimeoutError(f"{job_id} still {job['status']}")
time.sleep(interval)Know which failures are worth retrying
| KIND | EXAMPLES | RETRY? |
|---|---|---|
| Your input | INVALID_MODEL, GENE_IDS_MUST_BE_SYMBOLS, UNSUPPORTED_FILE_TYPE, FILE_TOO_LARGE, GEO_NO_RAW_COUNTS | No — fix and resubmit |
| Ambiguity we refuse to resolve | GEO_AMBIGUOUS | No — re-submit naming the file |
| Your budget | RATE_LIMITED, RAW_DOWNLOAD_BUDGET_EXCEEDED | Yes — after the window rolls |
| Ours | 5xx, 503 MODEL_NOT_PROVISIONED | Yes — with backoff. Send the job id if it persists |
Before you send anything
Two mistakes produce a result that looks perfect and means nothing, and a third refuses the run after a GPU has been placed. None of them raises an error you would notice in advance. Read this once and you will not make any of them.
It is refused twice where we can see it — by name when resolving a GEO series, and by value in the pipeline, which refuses negative or non-finite values before any model work. Neither check can save you if you upload a normalised matrix that still looks count-like, so the responsibility is yours at the point of export; declaring input_units at submit settles it in advance.
| MODEL | CELL TYPES | APPLIES TO | NOT FOR |
|---|---|---|---|
| breast-tumour-2k-v2 | 11 | Human breast tumour tissue, bulk RNA-seq of the whole tumour including its microenvironment, raw counts. | Blood, normal breast, other tumour types, cell lines or non-human samples. |
| t-all-2k-v2 | 10 | Human T-ALL bone marrow or peripheral blood, bulk RNA-seq, raw counts. | Solid tissue, other leukaemias, cell lines or non-human samples. |
| sepsis-pbmc-2k-v1 | 10 | Human PBMC from sepsis cohorts, bulk RNA-seq, raw counts. | Whole blood with granulocytes, solid tissue, cell lines or non-human samples. The reference is PBMC, so neutrophil-rich whole blood will be described against an axis that under-represents it. |
Each model reports its own ordered cell-type axis, and every run returns that axis as cell_types on GET /b2sc/jobs/{id}/proportions — runs of one model under one release line up column for column; runs of different models do not share a vocabulary at all.
| MODEL | CELL-TYPE AXIS, IN COLUMN ORDER |
|---|---|
| breast-tumour-2k-v2 | B-cells · Cancer Epithelial · Cycling T-cells · Endothelial · Fibroblast · Myeloid · NK · Normal Epithelial · PVL · Plasmablasts · T cells |
| t-all-2k-v2 | ALL blast · B · Classical Monocyte · Cytotoxic T · DC · Erythroid · FCGR3A+ Monocyte · HSC · NKT · naive/Tcm |
| sepsis-pbmc-2k-v1 | T_cell · Monocyte · B_cell · NK_cell · Erythroid · Platelet · Plasma · Neutrophil · DC · HSPC |
3. The gene list is exact
Each model normalises every sample over its whole common-gene list and then scores a 2,000-gene panel chosen from it. EVERY identifier on that list must be present in your matrix, spelled as the list spells it — a missing gene is a refusal, not a zero, because the model cannot stand in for a measurement it was not given. There is no percentage floor and no partial credit. The list is served per model, immutable per release, and the dashboard checks a file against it in the browser before uploading a byte.
| MODEL | MODEL_ID (ENGINE ALIAS) | PANEL | REQUIRED GENES (EVERY ONE) | THE LIST |
|---|---|---|---|---|
| breast-tumour-2k-v2 | iclr27-pbe-bcs-GSE176078 | 2,000 | 16,864 | GET /b2sc/models/breast-tumour-2k-v2/required-genes |
| t-all-2k-v2 | iclr27-pbe-bcs-SCPCP000003 | 2,000 | 55,719 | GET /b2sc/models/t-all-2k-v2/required-genes |
| sepsis-pbmc-2k-v1 | iclr27-pbe-bcs-GSE279448_GSE279452_sepsis_PBMC | 2,000 | 40,200 | GET /b2sc/models/sepsis-pbmc-2k-v1/required-genes |
A job record carries both identifiers: disease_model is the id you submitted, model_id is the engine alias the release knows the model by. Together with release_id they say exactly which weights produced a result.
{
"kind": "exact_common_genes",
"required_genes": 16864,
"satisfied": true
}A completed run satisfied the contract by construction — the engine refuses before any model work otherwise — so results-data states it as a record of what the run was held to rather than as a measurement to interpret.
How a job runs
Submit, poll, collect. A job moves through status until it reaches COMPLETED or FAILED — both terminal, and the only two values a client should stop on.
| STATUS | MEANING | TERMINAL |
|---|---|---|
| PENDING | Accepted, not yet queued. | |
| QUEUED | Waiting for a GPU. | |
| LAUNCHING | A GPU task is starting. Includes image pull on a cold node. | |
| DOWNLOADING_INPUT | Fetching your matrix. | |
| LOADING_MODEL | Verifying the model release by hash and loading its weights. | |
| RUNNING_STAGE1 | Predicting cell-type proportions. | |
| RUNNING_STAGE2 | Generating single cells: PBE (the patient-bulk encoder, a generator conditioned on each sample's own bulk profile), then BCS (a bulk-consistency correction of the generated expression). | |
| UPLOADING_RESULTS | Verifying the output contract and writing the result files. | |
| COMPLETED | Proportions and generated cells are ready. | terminal |
| FAILED | See message — written for a person, and it says what to change. | terminal |
Polling or streaming
GET /b2sc/jobs/{id} every 15 seconds is plenty and is what the SDK does. GET /b2sc/jobs/{id}/stream is server-sent events carrying the same fields every 3 seconds — it suits a browser, while for a script polling is simpler and survives a dropped connection without special handling.
How long it takes
Runtime scales with sample count, not with the model. The engine takes about ten seconds on the one-sample reference input and the container about thirty, measured on the staging fleet; a full 64-sample batch stays comfortably inside the SDK’s three-hour default timeout. The first job after an idle period also pays a GPU cold start of several minutes, which is why a job can sit in LAUNCHING for a while and be perfectly healthy. A timeout in the SDK does NOT cancel the job — it carries .elapsed and .last_status, and you can keep polling.
Python SDK
The supported way to drive Bulk to Single Cell from code — every tutorial above runs through it. It removes the two-step upload and the polling loop, and turns proportions into a DataFrame. Underneath it is plain HTTP: the HTTP reference at the end of this page documents every call, for integrations that do not use Python.
pip install 'wittgen-b2sc[pandas]' # drop [pandas] if you do not want DataFramesfrom wittgen_b2sc import B2SCClient, B2SCError, B2SCTimeout
client = B2SCClient(api_key="wgk_…") # or WITTGEN_API_KEY in the environmentMethod to endpoint
| METHOD | CALLS | NOTES |
|---|---|---|
| list_models() | GET /b2sc/models | Read applies_to and input_gene_contract before your first real submission. |
| list_atlases() | GET /b2sc/atlases | Published cohorts you can read immediately — none for this release yet. |
| upload_file(path) | POST /b2sc/upload + PUT | Both steps, both required headers. Returns the s3Key. |
| submit_job(model, source=…) | POST /b2sc/jobs | Accepts input_file_key or geo_accession; pass idempotency_key to make a retry safe. |
| get_job(job_id) | GET /b2sc/jobs/{id} | One poll. |
| list_jobs() | GET /b2sc/jobs | Follows the cursor for you and returns every job. |
| wait_for_completion(job_id) | GET /b2sc/jobs/{id} | Polls every 15 s; three-hour default timeout. A timeout does NOT cancel the job. |
| get_proportions(job_id, as_dataframe=True) | GET …/proportions | Returns a pandas DataFrame when asked. |
| get_results_data(job_id) | GET …/results-data | The summary and the run record: release, model, seed, cells per sample, the input contract. |
| list_files(job_id, category=…, include_raw=…) | GET …/files | The filter is applied server-side. |
| get_result(job_id) | GET …/result | The metered generated-matrix link. |
| get_usage() | GET /b2sc/usage | Your plan, quota and budgets. |
DELETE /b2sc/jobs/{id} and GET /b2sc/models/{id}/required-genes have no wrapper in this SDK version — call them over HTTP if you need them (the upload tutorial shows the gene-list check).
Errors
B2SCError carries .code — the same code documented above — so you can branch on it. B2SCTimeout carries .elapsed and .last_status and does not mean the job stopped; keep polling get_job() or retry with a larger timeout.
try:
job = client.submit_job("t-all-2k-v2", source="geo", geo_accession="GSE000000")
except B2SCError as e:
if e.code == "GEO_SERIES_NOT_VERIFIED_FOR_MODEL":
print("verified for this model:", e.details.get("available", [])) # [] today
elif e.code == "GEO_AMBIGUOUS":
print("pick one:", [f["name"] for f in e.details["files"] if f["eligible"]])
else:
raiseErrors
Every refusal carries a machine-readable code alongside a message written for a person. Branch on code, show message. The retry column is the one that matters when nobody is watching: a code marked no will fail identically however many times you send it.
| CODE | HTTP | WHAT HAPPENED | WHAT TO DO | RETRY |
|---|---|---|---|---|
| API_KEY_INVALID | 401 | Missing, malformed or revoked key. | Check the header reads Authorization: Bearer wgk_…. A key is shown once at creation; if lost, revoke and mint another. | no |
| ACCOUNT_NOT_PROVISIONED | 403 | The account exists but is not enabled for Bulk to Single Cell. | Contact us to have it enabled. | no |
| TERMS_ACCEPTANCE_REQUIRED | 403 | Terms have not been accepted on this account. | Sign in to the dashboard once and accept. | no |
| UNSUPPORTED_FILE_TYPE | 400 | fileName does not end in .tsv, .csv or .txt, optionally gzipped. | Rename the file to match its contents. The suffix is what is validated. | no |
| FILE_TOO_LARGE | 400 | Over 256 MB at the presign step. | Gzip the file, or split the cohort into batches of 64 samples or fewer. | no |
| INPUT_TOO_LARGE | 400 | The stored object is larger than declared, or than the limit. | Re-check the file you actually uploaded. | no |
| COHORT_TOO_MANY_SAMPLES | 400 | The header declares more sample columns than one submission may carry here: more than cohort.batch_size (64) where cohort splitting is off, or more than cohort.max_samples where it is on. GET /b2sc/models publishes all three. | Split the matrix column-wise into batches of 64 or fewer and submit one job per batch. The response names how many batches. Per-sample proportions concatenate directly, so the merged table is what one job would have produced. | no |
| COHORT_SAMPLE_COUNT_UNKNOWN | 400 | The first line could not be read as a header, so the number of samples is unknown. | The first line must be the header: one gene-identifier column, then one column per sample, tab- or comma-separated. Checked before the job is queued rather than assumed, because guessing here would run the wrong number of samples on a GPU and report a well-formed wrong answer. | no |
| INVALID_MODEL | 400 | disease_model is not one of the ids GET /b2sc/models publishes. | Use a current id: breast-tumour-2k-v2, t-all-2k-v2, sepsis-pbmc-2k-v1. An id from the previous generation is refused rather than redirected, because redirecting would answer with a different cell-type axis. | no |
| MODEL_NOT_PROVISIONED | 503 | The model exists but no engine revision is registered for its release in this environment yet. | Nothing on your side. The submission is refused rather than run on an unpinned engine; retry once the release is registered. | yes |
| GENE_IDS_MUST_BE_SYMBOLS | 400 | gene_id_type asked for identifier conversion on a model that matches its gene list verbatim. | Omit gene_id_type and re-label your gene column to the model's list (GET /b2sc/models/{id}/required-genes). Converting on the way in would hide the mismatch the list exists to show. | no |
| INVALID_IDEMPOTENCY_KEY | 400 | The key is malformed. | Printable ASCII, no spaces, at most 200 characters. | no |
| IDEMPOTENT_REQUEST_IN_FLIGHT | 202 | A submission with this key is still being accepted. | Retry in a moment with the SAME key. Do not submit again without it. | yes |
| IDEMPOTENT_JOB_GONE | 409 | The key was used for a job that no longer exists. | Use a new key. | no |
| 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 would decide which matrix your results describe. | no |
| GEO_NO_RAW_COUNTS | 422 | The series publishes only transformed matrices. | The models need raw counts and normalise themselves. Choose a series with raw counts, or upload counts directly. | no |
| INPUT_UNITS_NOT_COUNTS | 400 | You declared the matrix as already normalised (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. Send raw counts, or a quantifier’s expected counts, from the same pipeline. | no |
| INVALID_INPUT_UNITS | 400 | input_units is not one of the accepted values. | The response lists them. Omit the field if you are unsure — the matrix is classified from its values either way, and the declaration only settles the one band values cannot. | no |
| INVALID_ANALYSIS_MODE | 400 | analysis is not auto or manual. | Omit the field. No model in this release has an analysis stage, so the value changes nothing; it is validated for the day one does. | no |
| GEO_SERIES_NOT_VERIFIED_FOR_MODEL | 422 | The accession is not one of the series we have run and checked against the current release. | The response lists the series available for that model — none yet for this release. Upload the matrix directly — that is never restricted — or send us the accession and we will check it and add it. | no |
| GEO_SERIES_WRONG_MODEL | 422 | A verified series, but not for the model you asked for. | A series is verified against one model's gene list and tissue. Another model would return proportions for it that do not mean anything, so it is refused rather than answered. | no |
| GEO_NO_CANDIDATE | 422 | No gene-level count matrix found in the series. | The listing is returned so you can see what it actually contains. | no |
| RATE_LIMITED | 429 | Two different ceilings answer with this code. SUBMISSIONS: more than 20 in a rolling hour, counted per account across every source. REQUESTS: more than 20 a minute on the metered read routes, counted per API KEY — so another key on the same account cannot spend yours. | Wait. Both windows roll, so they clear on their own. Do not retry in a loop. Read the Retry-After header rather than guessing which one you met. | yes |
| RAW_DOWNLOAD_BUDGET_EXCEEDED | 429 | The rolling 24-hour raw .h5ad download budget is used up. | Only the raw matrix counts against it. Proportions, tables and the summary are unmetered, and the window rolls. | yes |
| RESULT_ARCHIVED | 409 | The matrix has moved to archival storage. | It cannot be downloaded until restored — contact us. Refused before anything is metered, because a presigned link to an archived object fails when followed. | no |
| PHI_CASCADE_INCOMPLETE | 409 | Some artifacts could not be erased, so the job record was deliberately kept rather than orphaning data. | Retry the delete; if it keeps failing, contact us quoting the job id. | yes |
Why a run was refused
The messages a FAILED job carries, quoted as they appear on the job — because the first thing anyone does with a failure message is search for it. The first three are the ones a first submission actually meets.
| THE MESSAGE YOU GOT | WHAT IT MEANS | WHAT TO CHANGE |
|---|---|---|
| Missing N common genes … This model requires every gene of its cohort's common gene set to be present exactly once, under the identifiers the release was trained on (a mix of HGNC symbols and Ensembl ids); a missing gene is not treated as zero. | Your matrix lacks at least one identifier on the model's gene list. Almost always the gene column is spelled differently from the list — Ensembl ids where it has symbols, or symbols where it has Ensembl ids — or the matrix was filtered before export. | Download the list from GET /b2sc/models/{id}/required-genes, re-label your gene column to match it, and submit the unfiltered matrix. Do not pad missing genes with zeros. |
| Missing N common genes … (a GDC export: every ENSG row missing) | A TCGA or TARGET matrix submitted with the annotation-release suffix still on the Ensembl ids (ENSG00000000003.15). The lists spell them without it, so every Ensembl identifier misses at once. | Cut the suffix off the Ensembl ids only (a gene symbol can contain its own dot), keep the case as it is, and sum the pseudoautosomal (…_PAR_Y) rows that then share an id. Keep the original ids and the GENCODE version with your records. |
| … duplicate … Every gene identifier must appear once. Sum the rows that share an identifier before submitting; the model cannot choose between two rows for one gene. | Two rows carry the same identifier. This is the ordinary output of an Ensembl-to-symbol mapping, not a sign the file is broken. | Add the duplicate rows together so each identifier appears once. Do not simply drop one of each pair — both rows are real reads of that gene. |
| … finite and nonnegative … Raw counts are never negative, so this is normally a transformed matrix — log, z-scored or scaled — rather than one bad cell. | At least one value is negative or not a number. A count is never negative, so the matrix has been transformed. | Submit the counts this file was made from. Expected counts from salmon, kallisto or RSEM are fine; they are fractional but never below zero. |
| … positive common and panel library … At least one sample has no counts on the model's genes at all — an empty or wrong-tissue library. | A sample column sums to zero over the model's gene list. | Remove the sample, or check the matrix orientation: gene rows, sample columns. |
| The submitted matrix has N samples; one job takes at most 64. | Your matrix has more than 64 columns. Normally caught at submit (COHORT_TOO_MANY_SAMPLES); this is the engine's backstop. | Split into batches of 64 or fewer and submit one job per batch; the per-sample tables concatenate directly. |
| Gene symbols containing a path separator (for example the readthrough symbol THRA1/BTR) are refused. | A gene identifier contains a path separator. | Drop those rows. No gene on any model's list contains a separator, so they cannot affect the result. |
| Submit a .csv, .tsv, .csv.gz or .tsv.gz file. | Should not occur — the delimiter is detected from contents. | Contact support with the job id. |
| The pipeline ran out of memory and was stopped. This usually means the cohort is larger than the current configuration supports — contact us with the job id and we will re-run it with more. | The run outgrew the memory it was given. Nothing about your matrix is wrong. | Contact us with the job id. |
Limits and quotas
Where each limit is enforced matters as much as its value. The API-side limits — file size, submission rate, quotas — refuse while your request is being accepted, and cost nothing. The input checks — sample count, gene rows, the exact gene list, value sanity — run at the START of the GPU task: they stop the run in its first minute, before any model work, but the submission has already been accepted by then. Check the gene list and split before you submit and none of them ever fires.
| LIMIT | VALUE | ENFORCED | NOTES |
|---|---|---|---|
| Samples per run | 64 | At submit, from the file’s own header | Where cohort splitting is enabled the matrix is ACCEPTED and split into runs of 64, spending one of your allowance per batch up to 4,096 samples in one submission; where it is not, it is refused at submit with a 400 (COHORT_TOO_MANY_SAMPLES) before anything is queued, so it costs nothing. GET /b2sc/models publishes which applies here, as cohort.enabled, cohort.batch_size and cohort.max_samples — read it rather than assuming a limit. |
| Input size | 256 MB | At presign, and again at submit | Gzip accepted and recommended. Re-checked against the real object at submit, so a false fileSize fails there rather than later. |
| Gene rows | 100,000 | At run start, inside the engine | A gene-level human annotation is 40–60k and fits comfortably. A transcript-level matrix (~250,000 rows) does not — aggregate to gene level first. |
| Required genes | Every one, exactly | In the dashboard before upload; at run start inside the engine | Per model: breast-tumour-2k-v2 16,864, t-all-2k-v2 55,719, sepsis-pbmc-2k-v1 40,200. The list is published at GET /b2sc/models/{id}/required-genes and is immutable per release. There is no percentage and no floor — a missing identifier refuses the run, and it is refused inside the engine after a GPU has been placed, which is why the dashboard checks it first. |
| Submissions | 20 / hour | At submit | Per account, rolling, one budget across all three sources. Set well above what iterating on a file takes. |
| Raw .h5ad downloads | 200 / 24 h on a provisioned account | At GET /result | YOUR OWN FIGURE IS ON GET /b2sc/usage, and it is the one to size a batch against: 200 is what a provisioned account gets, and an account that has not been provisioned yet has a smaller allowance. The tiers themselves are deliberately not listed here (the offering has no tier menu) — the endpoint answers for you. Only the raw matrix counts: proportions, tables and the summary are unmetered, and the window rolls. |
| File size of a request body | n/a | — | Your matrix never passes through this API: it goes straight to S3 from your machine. |
| Retention | 365 days | Automatic | Job records and their outputs are deleted together at the end of the window. Published atlases are permanent. |
Reproducibility and scope
Record the release_id and the model_id
Every model is served by an immutable release, and every job records the release and the model it ran against from the moment it is accepted. Results are comparable across jobs that share BOTH and not otherwise — when a model is replaced, the cell-type axis can change beneath the same product name. Keep them with your results; they are what lets you say months later what actually produced them.
Seeds and determinism
Every run uses the release's default seed, 20260904, which is fixed per release: there is no seed parameter on the API or in the UI. The release also ships weight sets for seeds 20260905 and 20260906, but they are not selectable yet. The engine records the seed, the device, the solver, the step count and every library version in run_manifest.json. Measured on the staging fleet: a repeat of the same input on the same GPU model (L4) with the same seed is bitwise identical. Equality across different GPU models is not promised.
What this release guarantees
| GUARANTEED | NOT CLAIMED |
|---|---|
| Checkpoint, gene-list and cell-type-axis integrity, verified by hash before loading | Biological validity of the generated cells |
| The output contract, self-checked on every run (verification.json: shapes, finite non-negative expression, unique axes, allocation totals, output hashes) | Downstream biology, and formal differential expression |
| Fleet reproducibility: same seed, same GPU model, same input — bitwise identical | Equality across different GPU models |
| A clean, immutable distribution carrying its own release_id and model_id | |
| Input-contract enforcement: every required gene present, or the run is refused before any model work |
Because downstream analysis is a separate evaluation, no analysis stage is offered: the proportions and the generated cells are the complete result. validation_caveat on GET /b2sc/models states this per model, and it is the sentence to read before drawing a conclusion from a run.
Retention
Job records and their outputs are kept for 365 days and then deleted together. Published atlases are permanent. DELETE /b2sc/jobs/{id} removes a job and everything it produced immediately, and is not reversible.
| WHAT | HOW LONG | WHAT YOU NOTICE |
|---|---|---|
| The matrix you uploaded | 30 days | After that you cannot re-run it by passing the same input_file_key — upload again. The results computed from it are unaffected and stay for the full year. |
| Results and the generated matrices | 90 days hot, then archival storage; deleted at 365 days | Reads stay available throughout, but once an object has moved GET /b2sc/jobs/{id}/result, /proportions and /results-data answer 409 RESULT_ARCHIVED with the archive state (archived or restoring), rather than handing you a link that would fail or a bare error. The file listing reports the state per file. |
| A GEO series we fetched for you | 30 days | Nothing — the next job on that accession fetches it again. |
| Published atlases | permanent | Nothing. They are worked examples and are meant to stay quotable. |
License & terms
The Bulk to Single Cell API is offered as a closed beta. What a key does and does not allow is stated here in full view, because it shapes how you should evaluate: freely inside your organization, and through a conversation with us for everything beyond that.
- Outputs are Research Use Only — see the input contract and the scope section above.
- Do not share your key: activity under it is attributed to you.
- Feedback you provide about the beta may be used by WittGen to improve the service.
- A key used outside these terms may be revoked at any time.
The governing text is the WittGen Bulk to Single Cell API Terms.
Catalogue
From here down: every operation as plain HTTP, for integrations that do not use the Python SDK — R, a workflow engine, another language. Generating a client instead of writing one? Feed the OpenAPI specification — a JSON file for tools like openapi-generator or Postman, not for reading — into your generator. Nothing below adds capability; it is what the SDK calls underneath.
First: what you are allowed to run, and what each model demands of a file.
Every model, the tissue it applies to, its cell-type axis, the exact gene list it requires (as a count here and a list at required_genes_url), the immutable release it runs on, its seeds and cells per sample, and the reference input published beside it. Read applies_to before you submit your own data, and validation_caveat before drawing a conclusion from a result.
RETURNS An object with a models array.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/models{
"models": [
{
"id": "breast-tumour-2k-v2",
"name": "Breast tumour",
"subtitle": "Breast tumour, bulk-conditioned single-cell generation (GSE176078 reference)",
"description": "Cell-type composition and 1,000 generated single cells per sample for bulk breast tumour RNA-seq",
"applies_to": "Human breast tumour tissue, bulk RNA-seq of the whole tumour including its microenvironment, raw counts.",
"not_applicable_to": "Blood, normal breast, other tumour types, cell lines or non-human samples.",
"validation_caveat": "Research model from the ICLR 2027 submission. Verified for output contract and fleet reproducibility; biological validity of generated cells is not claimed.",
"n_genes": 2000,
"n_cell_types": 11,
"cell_types": ["B-cells","Cancer Epithelial","Cycling T-cells","Endothelial","Fibroblast","Myeloid","NK","Normal Epithelial","PVL","Plasmablasts","T cells"],
"release_id": "iclr27-pbe-bcs-main-20261016",
"model_id": "iclr27-pbe-bcs-GSE176078",
"input_gene_contract": {
"kind": "exact_common_genes",
"required_genes": 16864,
"panel_genes": 2000,
"required_genes_url": "/api/b2sc/models/breast-tumour-2k-v2/required-genes"
},
"seed_policy": { "default_seed": 20260904, "seeds": [20260904,20260905,20260906] },
"n_cells_per_sample": 1000,
"expression_semantics": "continuous BCS-corrected generated expression; not measured or integer counts",
"estimated_runtime_min": 1,
"reference_dataset": {
"filename": "GSE176078_bulk.csv",
"n_samples": 1,
"source": "GEO GSE176078, sample CID3586",
"description": "The release's golden case for this cohort: one breast tumour sample (CID3586) from GSE176078, the input the engine's own end-to-end check runs."
}
}
],
"analysis_stage_offered": false,
"cohort": { "enabled": false, "batch_size": 64, "max_samples": 64 }
}Every identifier a model requires, in the model's own order, spelled as the engine will match it. Between 16,864 and 55,719 entries (under 600 KB). Immutable per release: the ETag is the sha256 of the release's model manifest, so send If-None-Match on a repeat and get a 304. Fetch it once per model, take the set difference against your gene column, and fix the file before you upload it.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | A model id from GET /b2sc/models. An unknown id is 404. |
RETURNS The model and release the list belongs to, its length, the manifest hash, and genes[].
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/models/breast-tumour-2k-v2/required-genes{
"model": "breast-tumour-2k-v2",
"model_id": "iclr27-pbe-bcs-GSE176078",
"release_id": "iclr27-pbe-bcs-main-20261016",
"n_required": 16864,
"manifest_sha256": "f0e0ab7ad3f0b7f030e3de567dc021ae07290f479a7aea68710eb1e6db5334f8",
"genes": ["FAM87B", "LINC00115", "SAMD11", "NOC2L", "KLHL17", "…"]
}Pre-computed public cohorts, so you can see real output at scale without waiting for a run of your own. Each is an ordinary job id: every read endpoint below accepts it. Atlases are read-only, owned by a system account, and permanent — the retention window does not apply to them. They are built per release; none is published for the current release yet, so the list is empty today.
RETURNS An object with an atlases array of job records — empty until the first atlas for this release is published.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/atlases{
"atlases": []
}Getting data in
Step one of two. Returns a URL to PUT your matrix straight to S3, and the s3Key to submit with. The file does not pass through this API, so there is no request size limit and no gateway timeout to work around.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| fileNamerequired | body | string | Must end in .tsv, .csv or .txt, optionally gzipped. The SUFFIX is what is validated, not the declared type. |
| fileSizerequired | body | integer | Bytes. Checked against the 256 MB ceiling here, and against the real object at submit. |
| fileType | body | string | Content-Type the URL is signed with; defaults to text/plain. Whatever you declare, the PUT must repeat exactly. |
RETURNS uploadUrl (valid one hour) and s3Key.
curl -X POST https://www.wittgenbio.com/api/v1/b2sc/upload \
-H "Authorization: Bearer $WITTGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"fileName":"my_cohort.tsv.gz","fileSize":10906492,"fileType":"application/gzip"}'
# then, and BOTH headers are required:
curl -X PUT -H "Content-Type: application/gzip" \
-H "x-amz-server-side-encryption: AES256" \
--data-binary @my_cohort.tsv.gz "<uploadUrl>"{
"uploadUrl": "https://<bucket>.s3.amazonaws.com/b2sc/uploads/…?X-Amz-Signature=…",
"s3Key": "b2sc/uploads/15/da3a0244-08ce-4c3f-b308-815ead5f8fc6_my_cohort.tsv.gz"
}Running a job
Starts a run and returns immediately with a job id. Three sources: the reference input published beside the model, a matrix you uploaded, or a public GEO accession. Send an Idempotency-Key to make a retry safe. The job records the release and model it will run under from the moment it exists.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| disease_modelrequired | body | string | An id from GET /b2sc/models: breast-tumour-2k-v2, t-all-2k-v2, sepsis-pbmc-2k-v1. Any other value — including the ids of the previous generation — is refused with 400 INVALID_MODEL rather than redirected, because redirecting would answer with a different cell-type axis. |
| sourcerequired | body | enum | reference_dataset | user_upload | geo |
| input_file_key | body | string | The s3Key from POST /b2sc/upload. Required when source=user_upload. Reusable: submit the same key to two models whose gene lists it satisfies. |
| geo_accession | body | string | Required when source=geo. Only accessions verified against the current release are accepted; none is yet — see the GEO tutorial. |
| geo_file | body | string | Name a specific supplementary file. Use this after a GEO_AMBIGUOUS refusal. |
| input_units | body | enum | raw_counts | expected_counts | tpm | fpkm | rpkm | cpm | normalized | log_normalized | vst. Optional, and it converts nothing — normalisation cannot be reversed. It exists because non-negative normalised values (TPM, FPKM, CPM) look like counts to any value check, so such a matrix RUNS and returns confident, wrong proportions. Only you know which yours is. Declaring a normalised unit is refused in seconds; declaring a count scale is recorded on the job, which is the first thing worth knowing if a result is ever questioned. |
| gene_id_type | body | enum | Omit it. These models match the gene column VERBATIM against their own list, so the only accepted value is the default (symbols as the list spells them); asking for Ensembl conversion is refused with 400 GENE_IDS_MUST_BE_SYMBOLS, because renaming identifiers on the way in would hide the mismatch the required-genes check exists to show. Re-label the file to the list instead. |
| Idempotency-Key | header | string | Printable ASCII, no spaces, ≤200 characters. Replaying it returns the same job. Lifetime 24 hours. |
RETURNS The accepted job: id, status, the release and model it will run under, and an estimate. A submission over 64 samples also returns n_samples and batch_count.
curl -X POST https://www.wittgenbio.com/api/v1/b2sc/jobs \
-H "Authorization: Bearer $WITTGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"disease_model":"breast-tumour-2k-v2","source":"reference_dataset"}'{
"job_id": "bb468d69-3f2a-4c81-9e77-1a2b3c4d5e6f",
"status": "QUEUED",
"disease_model": "breast-tumour-2k-v2",
"release_id": "iclr27-pbe-bcs-main-20261016",
"model_id": "iclr27-pbe-bcs-GSE176078",
"estimated_runtime_min": 1,
"created_at": "2026-10-17T13:26:29.000Z"
}The call you poll. Stop on COMPLETED or FAILED; on FAILED, message says what to change. A finished job also carries what the run recorded about itself: the seed it used, the cells it generated per sample, and the cell-type axis.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job or atlas id. |
RETURNS The job record.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}{
"job_id": "bb468d69-3f2a-4c81-9e77-1a2b3c4d5e6f",
"status": "COMPLETED",
"disease_model": "breast-tumour-2k-v2",
"release_id": "iclr27-pbe-bcs-main-20261016",
"model_id": "iclr27-pbe-bcs-GSE176078",
"seed": 20260904,
"n_cells_per_sample": 1000,
"n_samples": 1,
"n_cell_types": 11,
"cell_types": ["B-cells","Cancer Epithelial","Cycling T-cells","Endothelial","Fibroblast","Myeloid","NK","Normal Epithelial","PVL","Plasmablasts","T cells"],
"progress": 100,
"message": "Inference complete (7 files)",
"estimated_runtime_min": 1,
"created_at": "2026-10-17T13:26:29.000Z"
}Newest first, cursor paginated. Follow cursor until it is null. The cursor is opaque and tied to the key it was issued to — pass it back unchanged, and do not try to read it.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| limit | query | integer | Page size. |
| cursor | query | string | The cursor from the previous page, verbatim. |
RETURNS jobs[] plus cursor when more remain, null on the last page.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
"https://www.wittgenbio.com/api/v1/b2sc/jobs?limit=25"{
"jobs": [ { "job_id": "…", "status": "COMPLETED", "…": "…" } ],
"count": 25,
"cursor": "9tK1s…"
}Server-sent events carrying the same fields as the job record, every 3 seconds. Suits a browser; for a script, polling is simpler and survives a dropped connection.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job id. |
RETURNS text/event-stream.
curl -N -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/streamevent: status
data: {"status":"RUNNING_STAGE1","progress":25,"message":"Stage 1: predicting cell-type proportions"}
event: done
data: {"status":"COMPLETED","runtime_seconds":31}Removes the record and cascades to every stored object — the input you submitted, the result files and the generated cells. Not reversible, and logged. Your own jobs only; an atlas belongs to a system account and is refused.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job id. |
RETURNS Confirmation of what was removed.
curl -X DELETE -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}{ "deleted": true, "job_id": "…" }Taking results out
Four ways to read a finished job. Proportions are the primary result; results-data is the at-a-glance summary and the run record; files lists everything; result is the generated matrix and the only metered one.
The dataframe-shaped result: every sample against every cell type. Long by default, one row per sample per cell type; wide gives one row per sample with a column per type. Within a sample the values sum to 1 within rounding (six decimal places, so a row can total 0.999999 or 1.000001). The models normalise raw counts themselves, so an already-normalised matrix (TPM, FPKM, CPM) is the wrong input, not a lesser one — its non-negative values pass every value check and the run returns confident, wrong proportions. Declare input_units at submit so an incompatible matrix is refused in seconds.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job or atlas id. |
| format | query | enum | long (default) or wide. |
RETURNS cell_types in their fixed order, the proportions themselves, and a presigned CSV.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
"https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/proportions?format=long"{
"job_id": "bb468d69-3f2a-4c81-9e77-1a2b3c4d5e6f",
"disease_model": "breast-tumour-2k-v2",
"n_samples": 1,
"cell_types": ["B-cells","Cancer Epithelial","Cycling T-cells","Endothelial","Fibroblast","Myeloid","NK","Normal Epithelial","PVL","Plasmablasts","T cells"],
"format": "long",
"proportions": [
{ "sample": "CID3586", "cell_type": "B-cells", "proportion": 0.028853 },
{ "sample": "CID3586", "cell_type": "Cancer Epithelial", "proportion": 0.224694 },
{ "sample": "CID3586", "cell_type": "Fibroblast", "proportion": 0.332837 }
],
"downloads": { "proportions_csv": "https://… (presigned, 15 min)" },
"intended_use": "Research Use Only. Not a medical device. …"
}Mean proportions across the cohort plus the run record — the release and model, sample count, the cell-type axis, the seed, cells per sample, what the generated values ARE, the engine code version, and the input contract the run was held to. The at-a-glance view; use /proportions for per-sample numbers.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job or atlas id. |
RETURNS metadata, mean proportions, and the Research Use Only notice.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/results-data{
"job_id": "bb468d69-3f2a-4c81-9e77-1a2b3c4d5e6f",
"metadata": {
"disease_model": "breast-tumour-2k-v2",
"model_id": "iclr27-pbe-bcs-GSE176078",
"release_id": "iclr27-pbe-bcs-main-20261016",
"n_samples": 1,
"n_cell_types": 11,
"cell_types": ["B-cells","Cancer Epithelial","Cycling T-cells","Endothelial","Fibroblast","Myeloid","NK","Normal Epithelial","PVL","Plasmablasts","T cells"],
"n_genes": 2000,
"n_cells_per_sample": 1000,
"seed": 20260904,
"expression_semantics": "continuous BCS-corrected generated expression; not measured or integer counts",
"engine_code_version": "5ab9acb350328cad3e8199fbd4c7270cf0c02c09",
"runtime_seconds": 31,
"created_at": "2026-10-17T13:26:29.000Z",
"input_contract": { "kind": "exact_common_genes", "required_genes": 16864, "satisfied": true }
},
"proportions": [
{ "name": "Fibroblast", "mean": 0.332837 },
{ "name": "Cancer Epithelial", "mean": 0.224694 }
],
"intended_use": "Research Use Only. Not a medical device. …"
}One entry per artifact with a presigned URL valid for an hour. The primary way to collect a finished run. Listing is free and you may call it as often as you like — links expire, so re-list rather than storing them. Every result file sits directly under the job's result prefix, and platform_manifest.json names each of them.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job or atlas id. |
| category | query | string | Filter to one kind: result · input. |
| include_raw | query | boolean | Presign the generated matrices here (.h5ad and .npz). OFF by default, because presigning them spends one of your rolling 24-hour raw downloads and listing your files should not cost a download. Without it they are listed with an empty url and reason RAW_NOT_REQUESTED. |
RETURNS files[] — name, type, category, size, and url.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/files{
"files": [
{ "name": "proportions.csv", "type": "csv", "category": "result",
"size": 212, "url": "https://… (presigned, 1 h)" },
{ "name": "cell_counts.csv", "type": "csv", "category": "result",
"size": 164, "url": "https://…" },
{ "name": "platform_manifest.json", "type": "text", "category": "result",
"size": 1180, "url": "https://…" },
{ "name": "run_manifest.json", "type": "text", "category": "result",
"size": 4312, "url": "https://…" },
{ "name": "verification.json", "type": "text", "category": "result",
"size": 640, "url": "https://…" },
{ "name": "generated_cells.h5ad", "type": "h5ad", "category": "result",
"size": 8402944, "url": "", "restricted": true, "reason": "RAW_NOT_REQUESTED" }
]
}| FILE | WHAT IT IS |
|---|---|
| platform_manifest.json | The index of the result: release_id, model_id, seed, n_cells_per_sample, n_samples, the cell-type axis, the engine code version and image digest, the input sha256, expression_semantics, and the name of every other file. |
| proportions.csv | One row per sample (sample_id), one column per cell type, six decimal places. Each row sums to 1 within that rounding. |
| cell_counts.csv | The same table as integer cell counts — how many of the generated cells per sample were allocated to each type. |
| generated_cells.h5ad | The generated cells as AnnData: obs carries sample_id and cell_type; var is the 2,000-gene panel; X is continuous BCS-corrected generated expression; not measured or integer counts. |
| generated_cells.npz | The same matrix and axes as compressed NumPy arrays, for readers without an AnnData stack. |
| run_manifest.json | The engine's own record of the run: solver, steps, batch size, seed, device and library versions, and the sha256 of every checkpoint it loaded. |
| verification.json | The engine's self-check of the output contract — shapes, finite non-negative expression, unique axes, allocation totals, checkpoint load, output hashes. PASS or the job does not complete. |
| RESTRICTED REASON | WHAT IT MEANS | WHAT TO DO |
|---|---|---|
| RAW_NOT_REQUESTED | The generated matrix exists but you did not ask for it. | Re-list with ?include_raw=1. |
| REFERENCE_DATASET | The job ran on a reference dataset we ship — including the one the Quickstart tells you to run. The proportions are yours; the generated matrix is not. | Run the same model on a matrix of your own. |
| RAW_OUTPUT_OWNER_ONLY | The job is readable by you but not yours — a published atlas. | Run your own cohort. |
| INPUT_OWNER_ONLY | The input file belongs to whoever uploaded it. | Nothing — you already hold your own input. |
| RAW_DOWNLOAD_BUDGET_EXCEEDED | The rolling 24-hour raw-download budget is spent. | It rolls continuously, so it frees without an action from you. |
A presigned link to the .h5ad itself, signed for 15 minutes. This is the ONE call that spends your rolling 24-hour raw-download budget — everything else on this page is unmetered. Fetch it immediately rather than storing or forwarding the link. The matrix carries 1,000 generated cells per sample over the model's 2,000-gene panel — the release's setting, not configurable per request — and its values are continuous BCS-corrected generated expression; not measured or integer counts. Do not feed it to a tool that expects integer counts.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| idrequired | path | string | Job id. Atlases are deliberately excluded. |
RETURNS downloadUrl, an optional contextUrl, the link lifetime, and the stable S3 key.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/result{
"downloadUrl": "https://… (presigned, 15 min)",
"contextUrl": null,
"expires_in": 900,
"result_s3_key": "b2sc/results/<user>/<job>/generated_cells.h5ad",
"runtime_seconds": 31
}Account
What you are allowed and what has been consumed: plan, job quota, a 30-day per-operation ledger, and where you stand against the rolling raw-download budget. Read your own figures here rather than planning against the numbers on this page. Two scopes are mixed and it matters which is which: **metered_30d is YOUR KEY’s** activity, while **plan, used, quota and raw_downloads are the ACCOUNT’s** — and an account can carry more than one key. If your metered_30d looks smaller than `used`, that is why, not a lost job.
RETURNS plan, quota, metered_30d and raw_downloads.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/usage{
"plan": "paid",
"used": 15,
"quota": null,
"remaining": null,
"enforced": true,
"metered_30d": [
{ "op": "gpu_job", "unit": "op", "events": 15, "total": 15 },
{ "op": "raw_download", "unit": "op", "events": 2, "total": 2 }
],
"raw_downloads": { "used_24h": 2, "cap_24h": 200 }
}