B2SC API v1
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 synthetic 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 B2SC; if a fresh key answers 403 ACCOUNT_NOT_PROVISIONED, contact us and we will enable it.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 synthetic cells consistent with those proportions — currently 200 per sample — readable by scanpy/anndata in Python or Seurat in R.
How much of the model’s gene panel your matrix actually matched, and how much of it carried signal, against the two floors the run was held to.
Quickstart
A real result in about a minute, using the reference input that ships inside 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", 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 SRX20732914 B-cells 0.0142
# 1 SRX20732914 Cycling T-cells 0.0079
# 2 SRX20732914 ER+ Epithelial 0.3915Check 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 revokedSee real output before running anything
The fastest way to judge whether this is useful to you is to read a cohort we have already run. An atlas is an ordinary job id owned by a system account — every read endpoint accepts it, so anything you learn here 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 TCGA-BRCA atlas is 1,231 breast tumour samples run on breast-tumour-2k. This is the same call you will make on your own job.
df = client.get_proportions("atlas-tcga-brca-1231-2k", as_dataframe=True)
print(df.shape) # (16003, 3) = 1,231 samples x 13 cell types
print(df.head())Sample count, cell types delivered, and how much of the gene panel the input matched.
rd = client.get_results_data("atlas-tcga-brca-1231-2k")
print(rd["metadata"]["n_samples"], rd["metadata"]["gene_coverage"]["pct"])
# 1231 98.2Run 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 | HGNC gene symbols. Ensembl or Entrez ids match almost nothing in the panel and the run is refused. |
| Values | RAW COUNTS. Not TPM, not FPKM, not log-transformed. See the input contract — this is the one mistake that fails silently. |
| Samples | At most 64 per job. Split a larger cohort into batches. |
| 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", source="user_upload", input_file_key=key)
client.wait_for_completion(job["job_id"])
df = client.get_proportions(job["job_id"], as_dataframe=True)Run a cohort larger than 64 samples
A job takes at most 64 samples. Larger cohorts are split into batches and submitted as one job each, then concatenated — this is how the published 1,231-sample atlas was produced.
import pandas as pd
m = pd.read_csv("cohort_1231.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",
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)Compare the 2,000 and 5,000-gene models
The two breast models share one ordered 13-cell-type axis, so their results line up column for column and can be compared directly. Running both costs nothing extra to prepare: upload once and submit twice with the same key.
key = client.upload_file("my_cohort.tsv.gz") # once
a = client.submit_job("breast-tumour-2k", source="user_upload", input_file_key=key)
b = client.submit_job("breast-tumour-5k", source="user_upload", input_file_key=key)
for j in (a, b):
client.wait_for_completion(j["job_id"])
rd = client.get_results_data(j["job_id"])
print(j["release_id"], rd["metadata"]["gene_coverage"]["pct"])
# 20260810-…-panel2000-v1 98.2
# 20260810-…-panel5000-v1 97.8It is two submissions, so it counts twice against the hourly limit and takes a GPU each. The larger panel matches more of a typical matrix but is not automatically the better choice: read validation_caveat on GET /b2sc/models for what each release has and has not been evaluated for.
Run a public GEO series
Give an accession instead of a file and the server resolves the series, picks the count matrix out of its supplementary listing, and fetches it. Trying one is cheap — every check below runs before any compute, so a series that cannot work is refused in seconds with the reason attached, not after an hour of GPU.
| MUST BE TRUE | WHY | HOW IT IS REFUSED |
|---|---|---|
| A supplementary gene-level count matrix | We read the series listing, not the SRA runs. No matrix, nothing to run. | GEO_NO_CANDIDATE at submit |
| HGNC symbols in the first column | The panel is matched on that column. Ensembl IDs (ENSG…) match essentially nothing. | Refused at run start, naming the identifier |
| Raw counts — integers, non-negative | The models apply CPM + log1p themselves. TPM/FPKM is the wrong input, not a lesser one; it finishes and the numbers are wrong. | GEO_NO_RAW_COUNTS by name, or the counts check at run start |
| 64 samples or fewer, human tumour tissue | One job is 64 samples. And the model is trained on breast tumour tissue with its microenvironment — it returns confident proportions for a cell line that mean nothing. | The 64-sample guard refuses; nothing refuses the wrong biology but you |
job = client.submit_job("breast-tumour-2k", source="geo", geo_accession="GSE147507")
# -> GEO_AMBIGUOUS: this series publishes human AND ferret raw counts. Name one:
job = client.submit_job("breast-tumour-2k", source="geo",
geo_accession="GSE147507",
geo_file="GSE147507_RawReadCounts_Human.tsv.gz")A series larger than 64 samples
The human matrix above is 78 samples, so that second submission is accepted, fetched — and ends FAILED at run start with the message telling you to split it. Measured, not assumed. For a wide series, download the supplementary file from GEO yourself, slice it into batches of 64, and run them through user_upload — the batching tutorial above is this exact procedure:
import pandas as pd
m = pd.read_csv("GSE147507_RawReadCounts_Human.tsv.gz", sep="\t", index_col=0) # 21,797 x 78
batches = [m.iloc[:, i:i + 64] for i in range(0, m.shape[1], 64)]
# upload_file() + submit_job(source="user_upload") per batch, then concatenateWhen it refuses, and why that is the point
| SITUATION | RESPONSE | WHAT TO DO |
|---|---|---|
| Two plausible count matrices | GEO_AMBIGUOUS | The response carries the file listing. Re-submit with geo_file naming the one you want. GSE147507 is the real case — it publishes human AND ferret raw counts, and guessing would run a ferret matrix through a human model. |
| 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 — find a series with raw counts, or upload counts. |
| No count matrix found at all | GEO_NO_CANDIDATE | The listing is returned so you can see what the series actually contains. |
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","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, 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 | 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. Neither raises an error you would notice. Read this once and you will not make either.
It is refused twice where we can see it — by name when resolving a GEO series, and by value in the pipeline, which checks the matrix looks like counts before any model work. Neither check can save you if you upload a normalised matrix that still looks integer-ish, so the responsibility is yours at the point of export.
| MODEL | CELL TYPES | APPLIES TO | NOT FOR |
|---|---|---|---|
| breast-tumour-2k | 13 | Human breast tumour tissue, bulk RNA-seq of the whole tumour including its microenvironment. | Blood, normal breast, other tumour types, cell lines, or non-human samples. |
| breast-tumour-5k | 13 | Human breast tumour tissue, bulk RNA-seq of the whole tumour including its microenvironment. | Blood, normal breast, other tumour types, cell lines, or non-human samples. |
| t-all-2k | 12 | Human T-cell acute lymphoblastic leukaemia, bulk RNA-seq. | Solid tumours, healthy blood, or non-human samples. |
The two breast models share one ordered 13-cell-type axis — B-cells · Cycling T-cells · ER+ Epithelial · Endothelial · Fibroblast · HER2+ Epithelial · Myeloid · NK · Normal Epithelial · PVL · Plasmablasts · T cells · TNBC Epithelial — which is what lets their outputs be compared and concatenated column for column. Every run returns its own axis as cell_types on GET /b2sc/jobs/{id}/proportions.
Gene panel overlap — the two floors
Each model aligns your matrix to a FROZEN gene panel and declares its own input contract. A run must clear BOTH floors independently: enough of the panel matched by gene symbol, and enough of THAT carrying actual signal. Missing genes are zero-filled, which is why the second floor exists — a matrix can match on names while being almost entirely zero.
| MODEL | PANEL | MATCHED FLOOR | SIGNAL FLOOR |
|---|---|---|---|
| breast-tumour-2k | 2,000 | 1,400 (70%) | 1,400 (70%) |
| breast-tumour-5k | 5,000 | 3,500 (70%) | 3,500 (70%) |
| t-all-2k | 2,000 | 1,900 (95%) | not applied on this release |
Both are fail-closed and both are checked at the START of the run, before any model work — a matrix that falls short fails within the first minutes, not after hours. A finished run reports what it measured against the floors it was held to, so check your result against the run rather than against this page.
{
"pct": 98.2,
"level": "meets_model_floor",
"floor": "1400 of 2000 model genes must match",
"nonzero_floor": "1400 of 2000 model genes must carry signal",
"nonzero_gene_count": 1959,
"note": "The input carries enough of the model's gene panel for it to run.",
"caveat": "Coverage measures gene-name overlap only. It cannot tell whether the sample is the
tissue this model was built for — see applies_to on GET /b2sc/models."
}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 and loading weights. | |
| RUNNING_STAGE1 | Predicting cell-type proportions. | |
| RUNNING_STAGE2 | Generating synthetic cells. | |
| UPLOADING_RESULTS | Writing outputs. | |
| 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. A single reference sample finishes in about a minute of compute; 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 B2SC 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 before your first real submission. |
| list_atlases() | GET /b2sc/atlases | Published cohorts you can read immediately. |
| 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 gene-coverage contract result. |
| list_files(job_id, category=…, include_raw=…) | GET …/files | The filter is applied server-side. |
| get_result(job_id) | GET …/result | The metered raw .h5ad link. |
| get_usage() | GET /b2sc/usage | Your plan, quota and budgets. |
DELETE /b2sc/jobs/{id} has no wrapper in this SDK version — call it over HTTP if you need it.
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("breast-tumour-2k", source="geo", geo_accession="GSE147507")
except B2SCError as e:
if 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 B2SC. | 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 |
| INVALID_MODEL | 400 | disease_model is not one of the ids GET /b2sc/models publishes. | Use a current id. A retired accession is refused rather than redirected, because redirecting would answer with a different cell-type axis. | 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 |
| 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 |
|---|---|---|
| Input matches N model genes; at least X are required. | Too little of the model’s gene panel is present in your matrix by symbol. Almost always the gene column holds Ensembl or Entrez ids rather than HGNC symbols, or the species is not human. | Map your gene column to HGNC symbols and resubmit. X is this model’s own floor, stated in the message, and GET /b2sc/models reports the panel size as n_genes. |
| Input has signal in N aligned genes; at least X are required. | The panel genes are present by name, but nearly all of them are zero across your whole request. A matrix can pass the match check and fail this one — that is the point of having both. | Check you submitted the counts matrix rather than a filtered or already-subset one, and that the samples are the tissue this model is for. |
| Input was classified as <units>, not raw count-scale data. Submit raw counts or quantifier expected counts. | The values look normalized — TPM, FPKM, CPM or log-transformed. The model applies its own normalization, so a pre-normalized matrix is the wrong input rather than a lesser one. | Submit the integer count matrix your quantifier produced. This refusal is deliberate: TPM is non-negative, so without it the run would finish and return confident, wrong proportions. |
| Bulk input exceeds the 64-sample limit. | Your matrix has more than 64 columns. | Split into batches of 64 or fewer and submit one job per batch; the per-sample tables concatenate directly. |
| Gene symbol at row N must not be path-like. | A gene symbol contains a path separator — THRA1/BTR and similar readthrough symbols. | Drop those rows. No gene in the model panel contains a separator, so they cannot affect the result. |
| Bulk input must end in .tsv, .csv, .tsv.gz, or .csv.gz. | Should not occur — the delimiter is detected from contents. | Contact support with the job id. |
| The submitted file has no tab- or comma-separated columns in its first row. | The first row does not look like a header with one column per sample. | Check the file is the matrix and not a README or an index. |
| 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 panel floors, raw-count classification — run at the START of the GPU task: they stop the run in its first minutes, before any model work, but the submission has already been accepted by then. Split and check before you submit and none of them ever fires.
| LIMIT | VALUE | ENFORCED | NOTES |
|---|---|---|---|
| Samples per job | 64 | At run start, inside the engine | A 65-sample matrix is accepted and ends FAILED at run start with a message telling you to split it. Measured, not assumed. A guard against oversized runs in the engine release’s input contract — not a validated ceiling of the model itself — but raising it takes an engine release, so batching (see the tutorial) is the supported path for larger cohorts. |
| 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. |
| Gene-panel overlap | 70%, two floors | At run start, inside the engine | Per model, fail-closed on BOTH: matched by symbol and carrying signal. 1,400 of 2,000; 3,500 of 5,000. Refused before any model work — the run fails in its first minutes. |
| 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 | At GET /result | Only the raw matrix counts — proportions, tables and the summary are unmetered. The window rolls. Read your own figure from GET /b2sc/usage. |
| 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
Every model is served by an immutable release, and every job records the one it ran against. Results are comparable across jobs that share a release_id and not otherwise — when a model is replaced, the cell-type axis can change beneath the same product name. Keep the release_id with your results; it is the only thing that lets you say months later what actually produced them.
What this release guarantees
| GUARANTEED | NOT YET EVALUATED |
|---|---|
| Checkpoint, gene axis and cell-type axis integrity, verified by hash at startup | External target performance against the reference cohort |
| Active bulk conditioning — the output responds to your input | Downstream biology, and formal differential expression |
| A clean, immutable distribution carrying its own release_id | Physical-bulk performance |
| Operational inference: per-sample proportions and the generated matrix | |
| Input-contract enforcement, fail-closed on both floors |
Because downstream analysis is a separate evaluation, no analysis stage is offered here: the proportions and the generated cells are the complete result. validation_caveat on GET /b2sc/models states this per release, 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.
License & terms
The B2SC 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 B2SC 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 finished work you can read immediately.
Every model, the tissue it applies to, its cell-type count, the gene panel it aligns to, the immutable release it runs on, and the reference input bundled with 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",
"name": "Breast tumour",
"subtitle": "Breast tumour, 2,000-gene panel",
"description": "Cell-type composition and synthetic single cells for bulk breast tumour RNA-seq",
"n_genes": 2000,
"n_cell_types": 13,
"release_id": "20260810-breast-external-raw-seurat-v3-panel2000-v1",
"estimated_runtime_min": 1,
"applies_to": "Human breast tumour tissue, bulk RNA-seq of the whole tumour including its microenvironment.",
"not_applicable_to": "Blood, normal breast, other tumour types, cell lines, or non-human samples. The model will still return proportions for these; they will not mean anything.",
"validation_caveat": "This release verifies training integrity and active bulk conditioning. External target performance and downstream biological validity are separate evaluations and are not claimed.",
"reference_dataset": {
"filename": "BREAST_EXTERNAL_ATLAS_SRX20732914_panel2000_raw_pseudobulk.tsv.gz",
"n_samples": 1,
"source": "BREAST_EXTERNAL_ATLAS SRX20732914",
"description": "One pseudobulk breast sample (SRX20732914) from the external breast atlas, shipped with the model release as its reference input."
}
}
]
}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.
RETURNS An object with an atlases array of job records.
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
https://www.wittgenbio.com/api/v1/b2sc/atlases{
"atlases": [
{
"job_id": "atlas-tcga-brca-1231-2k",
"title": "TCGA-BRCA tumour cohort",
"description": "1,231 bulk RNA-seq samples of breast tumour tissue from TCGA, deconvolved into 13 cell types by breast-tumour-2k.",
"disease_model": "breast-tumour-2k",
"n_samples": 1231,
"source_citation": "TCGA-BRCA via GDC",
"status": "COMPLETED",
"atlas": true
}
]
}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://sisyph-dev-files-….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 bundled with the model, a matrix you uploaded, or a public GEO accession. Send an Idempotency-Key to make a retry safe.
| PARAMETER | IN | TYPE | NOTES |
|---|---|---|---|
| disease_modelrequired | body | string | An id from GET /b2sc/models. A retired or unknown id is refused rather than redirected — 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 to compare them. |
| geo_accession | body | string | Required when source=geo, e.g. GSE147507. Series over 64 samples are refused at run start — see the GEO tutorial. |
| geo_file | body | string | Name a specific supplementary file. Use this after a GEO_AMBIGUOUS refusal. |
| 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 it will run against, and an estimate.
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","source":"reference_dataset"}'{
"job_id": "bb468d69-3f2a-4c81-9e77-1a2b3c4d5e6f",
"status": "QUEUED",
"disease_model": "breast-tumour-2k",
"release_id": "20260810-breast-external-raw-seurat-v3-panel2000-v1",
"estimated_runtime_min": 1,
"created_at": "2026-08-11T09:41:02.318Z"
}The call you poll. Stop on COMPLETED or FAILED; on FAILED, message says what to change.
| 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",
"release_id": "20260810-breast-external-raw-seurat-v3-panel2000-v1",
"progress": 100,
"message": "Inference complete (17 files)",
"estimated_runtime_min": 1,
"created_at": "2026-08-11T09:41:02.318Z"
}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":62}Removes the record and cascades to every stored object — the input you submitted, the stage-1 tables 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; files lists everything; result is the raw 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.000.
| 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": "atlas-tcga-brca-1231-2k",
"disease_model": "breast-tumour-2k",
"n_samples": 1231,
"cell_types": ["B-cells", "Cycling T-cells", "ER+ Epithelial", "…"],
"format": "long",
"proportions": [
{ "sample": "TCGA-3C-AAAU-01A-11R-A41B-07",
"cell_type": "B-cells", "proportion": 0.000159 }
],
"downloads": { "proportions_csv": "https://… (presigned, 15 min)" },
"intended_use": "Research Use Only. Not a medical device. …"
}Mean proportions across the cohort plus run metadata — sample count, cell types delivered, and the gene-panel contract result. 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": "…",
"metadata": {
"disease_model": "breast-tumour-2k",
"n_samples": 64,
"n_cell_types": 13,
"n_genes": 2000,
"gene_coverage": {
"pct": 98.2,
"level": "meets_model_floor",
"floor": "1400 of 2000 model genes must match",
"nonzero_floor": "1400 of 2000 model genes must carry signal",
"nonzero_gene_count": 1959
}
},
"proportions": [
{ "name": "ER+ Epithelial", "mean": 0.3915 },
{ "name": "B-cells", "mean": 0.0142 }
],
"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.
| 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 raw .h5ad here. OFF by default, because presigning it spends one of your rolling 24-hour raw downloads and listing your files should not cost a download. Without it the file is 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": 507, "url": "https://… (presigned, 1 h)" },
{ "name": "cell_counts.csv", "type": "csv", "category": "result",
"size": 486, "url": "https://…" },
{ "name": "generated_cells.h5ad", "type": "h5ad", "category": "result",
"size": 13839976, "url": "", "restricted": true, "reason": "RAW_NOT_REQUESTED" }
]
}| 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 200 generated cells per sample — the current service default, not a limit of the model, and not configurable per request today; contact us if your use case needs a different count.
| 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/…/stage2/generated_cells.h5ad",
"runtime_seconds": 62
}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 }
}