Bulk to Single Cell API

BETA
START

Overview

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.

One key is all the setup there is. If a key was sent to you, that is the whole setup — this API needs no account and no sign-in. (Account holders mint their own under Settings → API keys; a key is shown once, at creation.) Send it as Authorization: Bearer on every call to 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.
Everything is asynchronous. A submission returns a job id immediately. Nothing blocks for the length of a run — you poll the job, or subscribe to its event stream, and collect results when it reaches COMPLETED. The engine itself takes about ten seconds on a one-sample input and the container about thirty; a 64-sample cohort takes longer, and the first job after an idle period also pays a GPU cold start.

What you get back

Per-sample proportions

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.

Generated single cells

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.

The run record

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.

Research Use Only. Not a medical device. Outputs are computational predictions and must not be used to diagnose, treat, or make clinical decisions for any patient without independent clinical review and validation. This notice is returned on the responses that carry results, as intended_use.

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.

SUBMIT, POLL, COLLECT
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.043717
reference_dataset is not a demo stub. It is the release's own golden case for that model — for the breast model, one tumour sample (CID3586) from GSE176078, the input the engine's own end-to-end check runs. It runs the same pipeline your own data will, and the numbers above are what it returns. It is ONE sample, though: a cohort of your own is the next tutorial.

Check your key before anything else

BASH
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 revoked
TUTORIALS

Read 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.

None is published for this release yet. Atlases are built per release, because a release can change a model's cell-type axis and an atlas built on the previous one would describe cells the current model does not report. The cohorts run on the previous generation were retired with it, so GET /b2sc/atlases answers an empty list today. The quickstart's reference run is the fastest real output until the first one is published; this section stays so the call is documented where you will look for it.
1
List what is published

Atlases are readable by every account and writable by none.

CODE
curl -H "Authorization: Bearer $WITTGEN_API_KEY" https://www.wittgenbio.com/api/v1/b2sc/atlases
2
Read one as a dataframe

The same call you will make on your own job.

CODE
df = client.get_proportions("<atlas job id>", as_dataframe=True)
print(df.shape)          # (samples x cell types, 3)
3
Read the run summary

Sample count, the cell-type axis, the seed and cells per sample, and the input contract the run was held to.

CODE
rd = client.get_results_data("<atlas job id>")
print(rd["metadata"]["n_samples"], rd["metadata"]["release_id"])
What an atlas hands over, and what it does not. GET /b2sc/jobs/{id}/files returns the result files for a cohort — **proportions.csv**, **cell_counts.csv**, **platform_manifest.json**, **run_manifest.json** and **verification.json** — each with a presigned link, no submission and no wait. What a published atlas does not include is the generated single-cell matrix: there is no .h5ad in its listing, and GET /b2sc/jobs/{id}/result answers 404 for a cohort you do not own. A worked example is deliberately not an unmetered tap on the model's richest output. Run your own cohort and the .h5ad is yours — ask for it with `GET /b2sc/jobs/{id}/files?include_raw=1`, a query parameter on the listing rather than a field on the submission.

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

REQUIREMENTDETAIL
OrientationFirst column is the gene identifier; every other column is one sample. First row is the sample names.
IdentifiersEXACTLY 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.
ValuesRAW COUNTS. Not TPM, not FPKM, not log-transformed. See the input contract — this is the one mistake that fails silently.
SamplesOne 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.
UPLOAD AND SUBMIT
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)
The PUT needs both headers, or S3 answers 403. The presigned URL is signed with the Content-Type you declared in step 1 AND with a server-side-encryption header, because the bucket refuses unencrypted uploads. The PUT must repeat both for the signature to match. It fails as a signature mismatch with no readable body — not as a validation error — which makes it the one common way to get stuck. client.upload_file() handles this for you.

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:

PYTHON
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 present

Run 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.

An oversized cohort is refused at submit, not after a GPU. We read the sample count out of the file's own header when you submit. A matrix over the per-job limit comes back as a 400 (code COHORT_TOO_MANY_SAMPLES) telling you how many batches it needs — before anything is queued, so nothing is charged. Where cohort splitting is enabled on your account we do the split for you instead. The engine carries the same limit as a backstop; it is not the line you will meet first.
SPLIT, SUBMIT, CONCATENATE
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)
Mind the hourly limit. Twenty submissions per rolling hour, per account. A 20-batch cohort fits exactly; a larger one should pace itself rather than retry into a 429. The limit is on submissions, not on jobs running at once — batches run concurrently as GPU capacity allows.
Every batch must clear the gene list on its own. Splitting by sample column leaves the gene axis intact, so a matrix that carries every required gene carries it in every batch. Split by column only — never drop rows to make a batch smaller.

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.

Series are re-verified per release, and none is verified yet. A series is verified against a MODEL's exact gene list, not against a tissue: the current release requires every one of a model's 16,864 / 55,719 / 40,200 genes to be present, and a series that passed the previous release's percentage check tells us nothing about that. So the verified list ships EMPTY for this release and every accession is refused with GEO_SERIES_NOT_VERIFIED_FOR_MODEL until it has been run and read sample by sample. Uploading the matrix yourself has always worked and is not restricted — download the series' supplementary count matrix, re-label its gene column to the model's list, and use the upload tutorial above. **Send us an accession and we will check it and add it.**

The verified series

ACCESSIONMODELSAMPLESWHAT IT ISVERIFIED
———No series is verified against release iclr27-pbe-bcs-main-20261016 yet.—
PYTHON
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 TRUEWHYHOW WE CHECK
A supplementary gene-level count matrixWe read the series listing, not the SRA runs. No matrix, nothing to run.GEO_NO_CANDIDATE
Every gene the model requires, present onceThe 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 countsIntegers 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 jobOne 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 sampleA 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

SITUATIONRESPONSEWHAT TO DO
The accession is not on the verified listGEO_SERIES_NOT_VERIFIED_FOR_MODELThe 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 itGEO_SERIES_WRONG_MODELA 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 matricesGEO_AMBIGUOUSThe response carries the file listing. Re-submit with geo_file naming the one you want.
The series publishes no raw countsGEO_NO_RAW_COUNTSGSE81538 and GSE96058 are both this case: their supplementary files are transformed matrices. Nothing here can use them — upload counts instead.
It refuses rather than guesses. Where the choice would change what your results describe, the API stops and hands you the listing. A wrong-but-plausible pick is worse than an error, because nothing downstream would ever tell you.

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.

BASH
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 KEYRESPONSE
The job exists200 with that job — no second run, no quota consumed
Still being accepted202 IDEMPOTENT_REQUEST_IN_FLIGHT — retry in a moment, keep the key
The job was deleted409 IDEMPOTENT_JOB_GONE — use a new key
Malformed key400 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

PYTHON
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)
Do not treat an unrecognised status as finished. Stop on COMPLETED or FAILED and nothing else. Stages have been added before and will be again; a client that treats anything unfamiliar as done will collect results that do not exist yet. Fifteen seconds is a sensible interval — it is what the SDK uses.

Know which failures are worth retrying

KINDEXAMPLESRETRY?
Your inputINVALID_MODEL, GENE_IDS_MUST_BE_SYMBOLS, UNSUPPORTED_FILE_TYPE, FILE_TOO_LARGE, GEO_NO_RAW_COUNTSNo — fix and resubmit
Ambiguity we refuse to resolveGEO_AMBIGUOUSNo — re-submit naming the file
Your budgetRATE_LIMITED, RAW_DOWNLOAD_BUDGET_EXCEEDEDYes — after the window rolls
Ours5xx, 503 MODEL_NOT_PROVISIONEDYes — with backoff. Send the job id if it persists
USING THE API

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.

1. Raw counts. Not TPM, not FPKM, not log-transformed. The model normalises each sample itself (log1p of counts per 10k over its gene list), so a pre-normalised matrix is the WRONG input rather than a lesser one — and only one of its two failure modes is loud. Log or z-scored values carry negatives and are refused for it. TPM and FPKM are non-negative, so nothing raises: the run finishes and returns confident, wrong proportions. This is the single most expensive mistake you can make with this API.

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.

2. Match the model to the tissue. Each model is trained on one tissue and reports the cell types found there. Given a different tissue it still returns a well-formed table whose rows sum to 1 within rounding, so check applies_to and not_applicable_to on GET /b2sc/models before your first submission — this is the one input property nothing downstream re-checks for you.
MODELCELL TYPESAPPLIES TONOT FOR
breast-tumour-2k-v211Human 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-v210Human 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-v110Human 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.

MODELCELL-TYPE AXIS, IN COLUMN ORDER
breast-tumour-2k-v2B-cells · Cancer Epithelial · Cycling T-cells · Endothelial · Fibroblast · Myeloid · NK · Normal Epithelial · PVL · Plasmablasts · T cells
t-all-2k-v2ALL blast · B · Classical Monocyte · Cytotoxic T · DC · Erythroid · FCGR3A+ Monocyte · HSC · NKT · naive/Tcm
sepsis-pbmc-2k-v1T_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.

MODELMODEL_ID (ENGINE ALIAS)PANELREQUIRED GENES (EVERY ONE)THE LIST
breast-tumour-2k-v2iclr27-pbe-bcs-GSE1760782,00016,864GET /b2sc/models/breast-tumour-2k-v2/required-genes
t-all-2k-v2iclr27-pbe-bcs-SCPCP0000032,00055,719GET /b2sc/models/t-all-2k-v2/required-genes
sepsis-pbmc-2k-v1iclr27-pbe-bcs-GSE279448_GSE279452_sepsis_PBMC2,00040,200GET /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.

The lists mix identifier types, and nothing is converted. Two of the three lists spell some genes as HGNC symbols and others as Ensembl ids (ENSG…), gene by gene, exactly as the reference data the model was trained on spelled them. Your matrix must match that spelling — a file of bare Ensembl ids against a symbol-keyed list misses every symbol. gene_id_type other than symbols is refused for these models (GENE_IDS_MUST_BE_SYMBOLS): renaming identifiers on the way in would hide exactly the mismatch this check exists to show. Download the list and re-label your rows to it.
A GDC matrix (TCGA, TARGET) carries a version suffix — remove it yourself. GDC ships Ensembl ids with their annotation-release suffix (ENSG00000000003.15); the lists carry them without it, so a GDC matrix submitted as exported misses every Ensembl row — about a third of the T-ALL list. Cut the suffix only off identifiers that are Ensembl ids (ENSG followed by digits, then the dot): a gene SYMBOL may contain a dot of its own, and RP11-34P13.7 cut at the first dot collides with RP11-34P13. Keep the rest of the spelling exactly as it is, including letter case: the lists are case-sensitive and several hundred entries are not upper-case (C10orf105). Removing the suffix merges the pseudoautosomal pairs (…_PAR_Y) onto one id — 44 pairs in a GENCODE v36 matrix, 6 of them on the T-ALL list — so add those two rows together, as with any duplicate. Keep your original identifiers and the annotation version alongside the submitted file; that record is what makes the result traceable to the matrix it came from.
INPUT_CONTRACT, FROM GET /JOBS/{ID}/RESULTS-DATA
{
  "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.

STATUSMEANINGTERMINAL
PENDINGAccepted, not yet queued.
QUEUEDWaiting for a GPU.
LAUNCHINGA GPU task is starting. Includes image pull on a cold node.
DOWNLOADING_INPUTFetching your matrix.
LOADING_MODELVerifying the model release by hash and loading its weights.
RUNNING_STAGE1Predicting cell-type proportions.
RUNNING_STAGE2Generating 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_RESULTSVerifying the output contract and writing the result files.
COMPLETEDProportions and generated cells are ready.terminal
FAILEDSee 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.

Stop on the two terminal values, nothing else. Do not treat an unrecognised status as finished. New stages have been added before and will be again; wait for COMPLETED or FAILED rather than assuming anything else means done.

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.

BASH
pip install 'wittgen-b2sc[pandas]'   # drop [pandas] if you do not want DataFrames
PYTHON
from wittgen_b2sc import B2SCClient, B2SCError, B2SCTimeout

client = B2SCClient(api_key="wgk_…")            # or WITTGEN_API_KEY in the environment

Method to endpoint

METHODCALLSNOTES
list_models()GET /b2sc/modelsRead applies_to and input_gene_contract before your first real submission.
list_atlases()GET /b2sc/atlasesPublished cohorts you can read immediately — none for this release yet.
upload_file(path)POST /b2sc/upload + PUTBoth steps, both required headers. Returns the s3Key.
submit_job(model, source=…)POST /b2sc/jobsAccepts 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/jobsFollows 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 …/proportionsReturns a pandas DataFrame when asked.
get_results_data(job_id)GET …/results-dataThe summary and the run record: release, model, seed, cells per sample, the input contract.
list_files(job_id, category=…, include_raw=…)GET …/filesThe filter is applied server-side.
get_result(job_id)GET …/resultThe metered generated-matrix link.
get_usage()GET /b2sc/usageYour 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.

PYTHON
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:
        raise
REFERENCE

Errors

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.

CODEHTTPWHAT HAPPENEDWHAT TO DORETRY
API_KEY_INVALID401Missing, 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_PROVISIONED403The account exists but is not enabled for Bulk to Single Cell.Contact us to have it enabled.no
TERMS_ACCEPTANCE_REQUIRED403Terms have not been accepted on this account.Sign in to the dashboard once and accept.no
UNSUPPORTED_FILE_TYPE400fileName 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_LARGE400Over 256 MB at the presign step.Gzip the file, or split the cohort into batches of 64 samples or fewer.no
INPUT_TOO_LARGE400The stored object is larger than declared, or than the limit.Re-check the file you actually uploaded.no
COHORT_TOO_MANY_SAMPLES400The 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_UNKNOWN400The 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_MODEL400disease_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_PROVISIONED503The 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_SYMBOLS400gene_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_KEY400The key is malformed.Printable ASCII, no spaces, at most 200 characters.no
IDEMPOTENT_REQUEST_IN_FLIGHT202A 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_GONE409The key was used for a job that no longer exists.Use a new key.no
GEO_AMBIGUOUS422The 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_COUNTS422The 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_COUNTS400You 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_UNITS400input_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_MODE400analysis 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_MODEL422The 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_MODEL422A 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_CANDIDATE422No gene-level count matrix found in the series.The listing is returned so you can see what it actually contains.no
RATE_LIMITED429Two 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_EXCEEDED429The 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_ARCHIVED409The 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_INCOMPLETE409Some 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
A FAILED job is not an error response. A job that is accepted and then fails returns 200 from GET /b2sc/jobs/{id} with status FAILED and a message that says what to change — a gene the model requires that the matrix does not carry, a duplicated identifier, a negative value. Read message; it is written for a person, and internal detail is never included in it. The dashboard's new-run screen catches the first three in the browser before anything is uploaded.

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 GOTWHAT IT MEANSWHAT 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.

LIMITVALUEENFORCEDNOTES
Samples per run64At submit, from the file’s own headerWhere 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 size256 MBAt presign, and again at submitGzip accepted and recommended. Re-checked against the real object at submit, so a false fileSize fails there rather than later.
Gene rows100,000At run start, inside the engineA 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 genesEvery one, exactlyIn the dashboard before upload; at run start inside the enginePer 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.
Submissions20 / hourAt submitPer account, rolling, one budget across all three sources. Set well above what iterating on a file takes.
Raw .h5ad downloads200 / 24 h on a provisioned accountAt GET /resultYOUR 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 bodyn/a—Your matrix never passes through this API: it goes straight to S3 from your machine.
Retention365 daysAutomaticJob records and their outputs are deleted together at the end of the window. Published atlases are permanent.
Plan against your own numbers, not this page. GET /b2sc/usage reports your quota and your rolling raw-download budget. The figures above are the current service defaults; if a limit is genuinely in your way, say so rather than working around it.

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

GUARANTEEDNOT CLAIMED
Checkpoint, gene-list and cell-type-axis integrity, verified by hash before loadingBiological 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 identicalEquality 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.

The generated values are not counts. X in generated_cells.h5ad is continuous BCS-corrected generated expression; not measured or integer counts. It is already on a normalised, batch-corrected scale; do not re-normalise it as counts, and do not feed it to a tool that assumes integer UMI counts. The sentence is carried on every result as expression_semantics so a pipeline can check it rather than remember it.

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.

WHATHOW LONGWHAT YOU NOTICE
The matrix you uploaded30 daysAfter 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 matrices90 days hot, then archival storage; deleted at 365 daysReads 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 you30 daysNothing — the next job on that accession fetches it again.
Published atlasespermanentNothing. They are worked examples and are meant to stay quotable.
The 30-day input expiry is the one to plan around. It is shorter than everything else here and it is the only one that changes what you can DO rather than where the bytes live. If you intend to re-run a cohort — a new release, another seed — keep your own copy of the matrix rather than relying on ours still being there.
Research Use Only. Not a medical device. Outputs are computational predictions and must not be used to diagnose, treat, or make clinical decisions for any patient without independent clinical review and validation.

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.

Test keys are an evaluation grant. A key issued during the beta is a TEST KEY: a limited, non-exclusive, non-transferable, revocable right to use the API solely for your organization’s internal testing and evaluation of its suitability for your research. It is not for production use, not for any clinical, diagnostic or patient-facing setting, and not for providing services to — or processing data on behalf of — any third party. The beta service is provided as-is, without service-level or support commitments, and may be changed, suspended or discontinued at any time.
Commercial use only under a separate agreement. A test key conveys no commercial or production rights. Any use beyond internal evaluation — production deployment, use in a paid service, or continued use after the evaluation period — requires a separately negotiated written agreement with WittGen, with its own pricing, service levels and data-protection terms; contact info@wittgenbio.com. Absent such an agreement, WittGen may deactivate a test key at any time.
Usage records and review. WittGen records the usage made with each key — request metadata (endpoints called, timestamps, request status), job and pipeline execution records, and the usage ledger associated with the key — and may access and review those records to operate and improve the service, meter usage, secure the service against abuse, and verify compliance with these terms. These are operational records: your submitted datasets and analysis results are not used to train WittGen’s models.
  • 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.

HTTP REFERENCE

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.

GET/b2sc/models
List models

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.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/models
RESPONSE
{
  "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 }
}
GET/b2sc/models/{id}/required-genes
The exact gene list

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.

PARAMETERINTYPENOTES
idrequiredpathstringA 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[].

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/models/breast-tumour-2k-v2/required-genes
RESPONSE
{
  "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", "…"]
}
GET/b2sc/atlases
List published atlases

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.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/atlases
RESPONSE
{
  "atlases": []
}

Getting data in

POST/b2sc/upload
Get a presigned upload URL

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.

PARAMETERINTYPENOTES
fileNamerequiredbodystringMust end in .tsv, .csv or .txt, optionally gzipped. The SUFFIX is what is validated, not the declared type.
fileSizerequiredbodyintegerBytes. Checked against the 256 MB ceiling here, and against the real object at submit.
fileTypebodystringContent-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.

REQUEST
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>"
RESPONSE
{
  "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

POST/b2sc/jobs
Submit 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.

PARAMETERINTYPENOTES
disease_modelrequiredbodystringAn 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.
sourcerequiredbodyenumreference_dataset | user_upload | geo
input_file_keybodystringThe s3Key from POST /b2sc/upload. Required when source=user_upload. Reusable: submit the same key to two models whose gene lists it satisfies.
geo_accessionbodystringRequired when source=geo. Only accessions verified against the current release are accepted; none is yet — see the GEO tutorial.
geo_filebodystringName a specific supplementary file. Use this after a GEO_AMBIGUOUS refusal.
input_unitsbodyenumraw_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_typebodyenumOmit 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-KeyheaderstringPrintable 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.

REQUEST
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"}'
RESPONSE
{
  "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"
}
GET/b2sc/jobs/{id}
Get job status

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob or atlas id.

RETURNS The job record.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}
RESPONSE
{
  "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"
}
GET/b2sc/jobs
List your jobs

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.

PARAMETERINTYPENOTES
limitqueryintegerPage size.
cursorquerystringThe cursor from the previous page, verbatim.

RETURNS jobs[] plus cursor when more remain, null on the last page.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  "https://www.wittgenbio.com/api/v1/b2sc/jobs?limit=25"
RESPONSE
{
  "jobs": [ { "job_id": "…", "status": "COMPLETED", "…": "…" } ],
  "count": 25,
  "cursor": "9tK1s…"
}
GET/b2sc/jobs/{id}/stream
Stream progress

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob id.

RETURNS text/event-stream.

REQUEST
curl -N -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/stream
RESPONSE
event: status
data: {"status":"RUNNING_STAGE1","progress":25,"message":"Stage 1: predicting cell-type proportions"}

event: done
data: {"status":"COMPLETED","runtime_seconds":31}
DELETE/b2sc/jobs/{id}
Delete a job

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob id.

RETURNS Confirmation of what was removed.

REQUEST
curl -X DELETE -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}
RESPONSE
{ "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.

GET/b2sc/jobs/{id}/proportions
Per-sample proportions

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob or atlas id.
formatqueryenumlong (default) or wide.

RETURNS cell_types in their fixed order, the proportions themselves, and a presigned CSV.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  "https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/proportions?format=long"
RESPONSE
{
  "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. …"
}
GET/b2sc/jobs/{id}/results-data
Run summary and record

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob or atlas id.

RETURNS metadata, mean proportions, and the Research Use Only notice.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/results-data
RESPONSE
{
  "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. …"
}
GET/b2sc/jobs/{id}/files
List every artifact

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob or atlas id.
categoryquerystringFilter to one kind: result · input.
include_rawquerybooleanPresign 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.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/files
RESPONSE
{
  "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" }
  ]
}
FILEWHAT IT IS
platform_manifest.jsonThe 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.csvOne row per sample (sample_id), one column per cell type, six decimal places. Each row sums to 1 within that rounding.
cell_counts.csvThe same table as integer cell counts — how many of the generated cells per sample were allocated to each type.
generated_cells.h5adThe 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.npzThe same matrix and axes as compressed NumPy arrays, for readers without an AnnData stack.
run_manifest.jsonThe 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.jsonThe 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 REASONWHAT IT MEANSWHAT TO DO
RAW_NOT_REQUESTEDThe generated matrix exists but you did not ask for it.Re-list with ?include_raw=1.
REFERENCE_DATASETThe 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_ONLYThe job is readable by you but not yours — a published atlas.Run your own cohort.
INPUT_OWNER_ONLYThe input file belongs to whoever uploaded it.Nothing — you already hold your own input.
RAW_DOWNLOAD_BUDGET_EXCEEDEDThe rolling 24-hour raw-download budget is spent.It rolls continuously, so it frees without an action from you.
The Quickstart’s first job is a reference dataset. So its .h5ad comes back restricted with REFERENCE_DATASET even though the job is yours — the reference matrices are ours to lend, not to hand over. It is the one thing about that first run which does not generalise to your own data. Everything else does: proportions, files, results-data and the metadata all behave identically.
GET/b2sc/jobs/{id}/result
Download the generated cells

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.

PARAMETERINTYPENOTES
idrequiredpathstringJob id. Atlases are deliberately excluded.

RETURNS downloadUrl, an optional contextUrl, the link lifetime, and the stable S3 key.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/jobs/{id}/result
RESPONSE
{
  "downloadUrl": "https://… (presigned, 15 min)",
  "contextUrl": null,
  "expires_in": 900,
  "result_s3_key": "b2sc/results/<user>/<job>/generated_cells.h5ad",
  "runtime_seconds": 31
}

Account

GET/b2sc/usage
Plan, quota and metered usage

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.

REQUEST
curl -H "Authorization: Bearer $WITTGEN_API_KEY" \
  https://www.wittgenbio.com/api/v1/b2sc/usage
RESPONSE
{
  "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 }
}
Research Use Only. Not a medical device and not cleared or approved by any regulator. Every output is a computational prediction, not a clinical finding, and must not be used to diagnose, treat, or decide the care of any patient without independent clinical review.