B2SC API v1

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

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 B2SC; if a fresh key answers 403 ACCOUNT_NOT_PROVISIONED, contact us and we will enable it.
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. A single reference sample takes about a minute of compute; 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 synthetic cells consistent with those proportions — currently 200 per sample — readable by scanpy/anndata in Python or Seurat in R.

The input contract result

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.

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

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", 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.3915
reference_dataset is not a demo stub. It is a real pseudobulk sample shipped inside the model release — for the breast models, SRX20732914 from an external breast atlas. It runs the same pipeline your own data will. It is ONE sample, though: for real output at cohort scale without running anything, read a published atlas instead (see Tutorials).

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

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

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 its proportions as a dataframe

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

CODE
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())
3
Read the run summary

Sample count, cell types delivered, and how much of the gene panel the input matched.

CODE
rd = client.get_results_data("atlas-tcga-brca-1231-2k")
print(rd["metadata"]["n_samples"], rd["metadata"]["gene_coverage"]["pct"])
# 1231 98.2
How that atlas was built. A job takes at most 64 samples, so 1,231 samples were run as 20 batches and the per-sample tables concatenated. Every batch returns the same cell-type columns in the same order, which is what makes that safe. The batching tutorial below is the same procedure.
What an atlas hands over, and what it does not. GET /b2sc/jobs/{id}/files returns four downloadable artifacts for this cohort: **proportions.csv** (the per-sample predictions, 1,231 tumours x 13 cell types), **cell_counts.csv**, **summary.json** and **run_manifest.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.
IdentifiersHGNC gene symbols. Ensembl or Entrez ids match almost nothing in the panel and the run is refused.
ValuesRAW COUNTS. Not TPM, not FPKM, not log-transformed. See the input contract — this is the one mistake that fails silently.
SamplesAt most 64 per job. Split a larger cohort into batches.
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", 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.

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.

This is the one limit enforced inside the engine. A 65-sample matrix is ACCEPTED, allocated a GPU, and only then fails — with a message telling you to split it. Measured, not assumed. Split before you submit and you never pay for that.
SPLIT, SUBMIT, CONCATENATE
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)
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.

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.

PYTHON
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.8
They are separate immutable releases. Each job records the release_id it ran against, and results are only comparable across jobs that share one. Keep the release_id with your results — it is the only thing that lets you say months later what actually produced them.

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

The matrix must clear BOTH models’ floors. A full gene-level matrix (40–60k rows) does. A matrix already subset to one model’s panel cannot clear the larger model’s floor — 2,000 genes can never match 3,500 — and the second submission ends FAILED with the matched-genes refusal. Measured, not assumed. Compare from the full matrix, not from a panel export.

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.

Most public series do not qualify. Four things must be true. We measured this rather than guessing: of sixteen human breast-tumour series on GEO with 10–64 samples published since 2019, **none** were both runnable and appropriate. Twelve published no supplementary matrix our resolver could pick; one used Ensembl IDs; one was a targeted 721-gene panel; the two that cleared every mechanical check were cell lines. So check the GEO page before you submit — or send us the accession and we will check it for you.
MUST BE TRUEWHYHOW IT IS REFUSED
A supplementary gene-level count matrixWe read the series listing, not the SRA runs. No matrix, nothing to run.GEO_NO_CANDIDATE at submit
HGNC symbols in the first columnThe panel is matched on that column. Ensembl IDs (ENSG…) match essentially nothing.Refused at run start, naming the identifier
Raw counts — integers, non-negativeThe 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 tissueOne 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
To see real output right now, use the atlas. The TCGA-BRCA cohort — 1,231 patient tumours — is already run and readable with your key, with no submission and no wait. The atlas tutorial above is two calls. Come back to GEO when you have an accession of your own in mind.
PYTHON
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:

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

When it refuses, and why that is the point

SITUATIONRESPONSEWHAT TO DO
Two plausible count matricesGEO_AMBIGUOUSThe 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 countsGEO_NO_RAW_COUNTSGSE81538 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 allGEO_NO_CANDIDATEThe listing is returned so you can see what the series actually contains.
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","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, 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
Ours5xxYes — 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. Neither raises an error you would notice. Read this once and you will not make either.

1. Raw counts. Not TPM, not FPKM, not log-transformed. The model applies CPM + log1p itself, 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, produce NaN, and crash about two minutes in. 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 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.

2. The model must match the tissue. A deconvolution model given the wrong tissue does not fail. It returns a well-formed table that sums to 1.000 for every sample and means nothing at all. Read applies_to and not_applicable_to on GET /b2sc/models before your first real submission — they exist precisely because nothing downstream will tell you.
MODELCELL TYPESAPPLIES TONOT FOR
breast-tumour-2k13Human 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-5k13Human 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-2k12Human 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.

MODELPANELMATCHED FLOORSIGNAL FLOOR
breast-tumour-2k2,0001,400 (70%)1,400 (70%)
breast-tumour-5k5,0003,500 (70%)3,500 (70%)
t-all-2k2,0001,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.

GENE_COVERAGE, FROM GET /JOBS/{ID}/RESULTS-DATA
{
  "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."
}
A low match is almost always identifiers. If a real human matrix matches only a few hundred panel genes, the gene column is usually holding Ensembl or Entrez ids rather than HGNC symbols — or the species is not human. Falling short on the SIGNAL floor instead means the genes are there but nearly all zero.

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 and loading weights.
RUNNING_STAGE1Predicting cell-type proportions.
RUNNING_STAGE2Generating synthetic cells.
UPLOADING_RESULTSWriting outputs.
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. 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.

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 before your first real submission.
list_atlases()GET /b2sc/atlasesPublished cohorts you can read immediately.
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 gene-coverage contract result.
list_files(job_id, category=…, include_raw=…)GET …/filesThe filter is applied server-side.
get_result(job_id)GET …/resultThe metered raw .h5ad link.
get_usage()GET /b2sc/usageYour 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.

PYTHON
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:
        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 B2SC.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
INVALID_MODEL400disease_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_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
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 matrix below the gene-panel floor, or more than 64 samples. Read message; it is written for a person, and internal detail is never included in it.

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

LIMITVALUEENFORCEDNOTES
Samples per job64At run start, inside the engineA 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 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.
Gene-panel overlap70%, two floorsAt run start, inside the enginePer 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.
Submissions20 / hourAt submitPer account, rolling, one budget across all three sources. Set well above what iterating on a file takes.
Raw .h5ad downloads200 / 24 hAt GET /resultOnly 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 bodyn/aYour 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

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

GUARANTEEDNOT YET EVALUATED
Checkpoint, gene axis and cell-type axis integrity, verified by hash at startupExternal target performance against the reference cohort
Active bulk conditioning — the output responds to your inputDownstream biology, and formal differential expression
A clean, immutable distribution carrying its own release_idPhysical-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.

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

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 B2SC 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 finished work you can read immediately.

GET/b2sc/models
List models

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.

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

RETURNS An object with an atlases array of job records.

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

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://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

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

PARAMETERINTYPENOTES
disease_modelrequiredbodystringAn id from GET /b2sc/models. A retired or unknown id is refused rather than redirected — 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 to compare them.
geo_accessionbodystringRequired when source=geo, e.g. GSE147507. Series over 64 samples are refused at run start — see the GEO tutorial.
geo_filebodystringName a specific supplementary file. Use this after a GEO_AMBIGUOUS refusal.
Idempotency-KeyheaderstringPrintable 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.

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

The call you poll. Stop on COMPLETED or FAILED; on FAILED, message says what to change.

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",
  "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"
}
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":62}
DELETE/b2sc/jobs/{id}
Delete a job

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.

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; files lists everything; result is the raw 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.000.

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": "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. …"
}
GET/b2sc/jobs/{id}/results-data
Run summary

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.

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": "…",
  "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. …"
}
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.

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

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": 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 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 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.

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/…/stage2/generated_cells.h5ad",
  "runtime_seconds": 62
}

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.