Programmatic access to the Only Good Antibodies dataset: knockout-controlled characterisation of commercial antibodies, generated by YCharOS to community consensus protocols.
Base URL: https://onlygoodantibodies.co.uk/api/v1/
Everything here is a GET unless it says otherwise. What changes something on
our side: /mark-reviewed/, /portal-config/, /reviewed/, /reviewed/clear/,
/report-issue/ — and /antibodies/, which advances your review cursor
unless you pass preview=true or advance_cursor=false. That last one is the
easiest to trip over, so it has a section of its own.
Everything else is safe to call as often as your rate limit allows.
This page is the narrative — why the endpoints are shaped as they are and what a recommendation means. The contract is OpenAPI 3.1, served, needing no key, at:
https://onlygoodantibodies.co.uk/api/v1/openapi.json
Point anything at it:
# a browsable reference, no install
npx @redocly/cli preview-docs https://onlygoodantibodies.co.uk/api/v1/openapi.json
# a typed client in your language
npx @openapitools/openapi-generator-cli generate \
-i https://onlygoodantibodies.co.uk/api/v1/openapi.json -g python -o ./oga-client
Postman and Insomnia both import that URL directly and give you every endpoint, parameter and example as a ready collection.
It is generated from the code rather than kept alongside it, so the rate limits, the recommendation values, the four applications and the CSV column order are the same objects the server uses — they cannot say one thing while the API does another.
The two read feeds need no key. GET /antibodies/ and GET /genes/ answer
anyone, as do GET / and GET /openapi.json. They publish what the public gene
pages already publish, so a key was never what protected the data. Start here:
curl -s https://onlygoodantibodies.co.uk/api/v1/genes/
Keyless callers share a ceiling of 30 requests a minute and 300 an hour, and the replies are cacheable for five minutes, so a mirror that re-reads the same URL is usually answered by the CDN rather than by us.
A key is still worth having, and everything below assumes one. It raises
your rate limit, names you in every reply, applies your supplier filter, and
keeps the review cursor that makes /antibodies/ an incremental feed rather
than a full dump. GET /gene-detail/ — the per-vendor competitor view —
needs one and is for antibody manufacturers only.
Every keyed request carries it in a header:
X-API-Key: YOUR_API_KEY_HERE
Replace YOUR_API_KEY_HERE throughout this document with the key you were
issued. Keys are UUIDs, of the shape 3f9a2c14-….-5ea1d8b47f30.
Send it as a header, not in the URL. The data portal accepts ?key= for
convenience because people bookmark and email those links, but a key in a query
string ends up in server logs, in browser history and in the Referer of every
outbound click. Do not use it from a program.
Two failures to expect:
| Status | Meaning |
|---|---|
401 |
No X-API-Key header was sent, to an endpoint that needs one |
403 |
The key is unknown, or the account is inactive |
A wrong key is refused even on the endpoints that need none. Falling back to keyless access there would silently widen what you receive — a manufacturer whose key had lapsed would start getting every supplier's rows instead of their own — so fix the key rather than dropping the header.
Every read endpoint answers HEAD as well as GET, with the same status and
headers and no body — so curl -I, an uptime monitor or a link checker gets a
real answer. It returned 405 until 12 Aug 2026.
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/status/
Per key, across all endpoints:
We count requests per key, per endpoint, per day — a running total, not a log of individual calls. Nothing about the content of a request is stored, and requests that arrive without a usable key are counted with no record of who sent them. It is there so we can tell how the API is actually used, and so a client stuck in a loop is visible to us before it is a problem for you.
Every reply carries your remaining budget, including error replies — a 400
still spent a request:
X-RateLimit-Limit-Burst: 60
X-RateLimit-Remaining-Burst: 57
X-RateLimit-Limit-Sustained: 1000
X-RateLimit-Remaining-Sustained: 993
A 429 carries Retry-After in seconds and repeats it in the body as
retry_after_seconds. Read the remaining count and pace yourself; a
well-behaved client never sees a 429.
These are deliberately generous. A full sync is two requests — one manifest, then the images from a CDN that is not us.
A recommendation is per application — a pass in western blot says nothing
about immunofluorescence. Every antibody carries oga_recommendations with one
of three values per application:
| Value | Means |
|---|---|
recommended |
Recommended for this application in the conditions tested. |
not_recommended |
Tested and not recommended in the conditions tested. |
not_tested |
Not tested for this application. |
Results are based on consensus protocols. Antibody performance is protocol and sample dependent, and these results do not validate or invalidate experiments in other assay systems or sample types. (Ayoubi et al., 2024, Nature Protocols.)
That sentence is in the data as well as in this document. Every response
that carries recommendations — /antibodies/, /genes/, /gene-detail/ —
carries it as recommendation_scope on the envelope, beside count, so it
reaches code that never reads a page. It is one field per response rather than
one per row: a caveat repeated on 1,600 rows is one nobody reads, and the rows
themselves are unchanged.
Not to be confused with scope on /manifest/, which says which part of the
dataset your key covers. Different question, so a different name.
Two older keys carry the same information. verdicts is a deprecated alias of
oga_recommendations. recommendations is the original object of plain
booleans and cannot tell not_recommended from not_tested, because false
means both.
The data is published under Creative Commons Attribution 4.0 International (CC BY 4.0). Copy it, redistribute it, build on it commercially — including in a product you sell.
Attribution here means the DOI. Every gene we have reported carries the DOI
of its report. It is shown on that gene's page, and GET /genes/ returns it as
f1000_report beside gene_page_url — the key predates Zenodo deposits and the
value is whichever DOI that gene has. Cite the DOI of each gene you used rather
than this website: that is what points a reader at the experiments instead of at
a URL that can move, and it is what makes your reuse traceable back to us.
A gene we have not reported yet has no DOI. Attribute those to Only Good Antibodies and link the gene page.
Two things that are not conditions, so nobody has to ask: there is no non-commercial restriction, and there is no share-alike. CC BY is the whole of it.
GET / — the endpoint catalogueMachine-readable list of everything below, with parameters and side effects. Needs no key.
curl -s https://onlygoodantibodies.co.uk/api/v1/
Use it to discover changes without waiting for a new version of this document.
GET /download/ — everything in one fileThe simplest thing this API does. One request, one zip, every published figure
plus a manifest.csv naming each one.
curl -L -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/download/ \
-o oga-figures.zip
-L is not optional. The endpoint answers 302 and points at a prepared
archive in object storage. The bytes never pass through our application, so the
download is CDN-served, supports range requests and resumes if it drops. Most
clients follow the redirect on their own; curl needs telling.
Inside:
figures/ every published figure, one file each
manifest.csv one row per file — gene, catalogue number, RRID, supplier,
and what OGA recommends. Joins to the files on `filename`.
README.txt what the recommendation values mean
The archive is rebuilt whenever the dataset changes, and the URL you are redirected to carries the dataset version — so a URL you have already fetched is safe to cache for ever, and asking this endpoint again tells you the current one.
One gene at a time:
curl -L -H "X-API-Key: YOUR_API_KEY_HERE" \
"https://onlygoodantibodies.co.uk/api/v1/download/?gene=ACE,ANXA11" \
-o oga-snca-mapt.zip
Those are built on request and returned directly, with no redirect.
Two replies that are not errors on your side. A 503 means a figure was
published since the last build and the archive for the current dataset does not
exist yet — nothing is missing, and the reply names the manifest to use in the
meantime. A 413 means you asked for more genes than we will build on request;
use the whole archive, or the manifest.
If your key is scoped to part of the dataset — a manufacturer key limited to your own catalogue — this endpoint always builds your zip on request rather than redirecting, because the shared archive holds every supplier's figures.
GET /manifest/ — the list, for syncingThis is the endpoint to build against if you are keeping a copy in step with ours. It lists every published figure you may download, with full metadata, and it does not contain image bytes.
Use /download/ to get everything once. Use /manifest/ when you need to know
what changed, fetch selectively, or hold your own mirror.
The images live on Cloudflare R2 behind a public custom domain, so you fetch them directly — in parallel, resumably, without touching our servers or needing a key.
Send a User-Agent when you fetch them. The default one most HTTP libraries
send (Python-urllib/3.x, Go-http-client, and so on) can be refused by bot
protection in front of the CDN, and what you get back is a 403 on a file that
is entirely public — which reads as a permissions problem and is not one.
The older https://onlygoodantibodies.co.uk/media/<key> form of these URLs still
works: it permanently redirects to the same object, so links captured before the
storage move are not broken.
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/manifest/
{
"manifest_version": 1,
"generated_at": "2026-08-05T14:52:15.005985+00:00",
"dataset_version": "e91d64dd2bff512cf0ede397aa40c0c58d4554451126641eec504ce0a18bf248",
"consumer": "Abcam",
"consumer_type": "manufacturer",
"scope": {
"genes": null,
"supplier_filter": null,
"includes_recommendations": true,
"recommendation_scope": "Results are based on consensus protocols. …"
},
"counts": {
"files_in_scope": 4935,
"files_matched": 4935,
"files_returned": 4935
},
"complete": true,
"files": [
{
"image_id": 2,
"url": "https://onlygoodantibodies.co.uk/media/publication_images/2026/ab254222_ICC.png",
"application": "IP",
"application_display": "Immunoprecipitation",
"gene": "ACE",
"catalogue_number": "ab254222",
"rrid": "AB_3073965",
"supplier": "Abcam",
"product_link": "https://www.abcam.com/ab254222",
"discontinued": false,
"gene_page_url": "https://onlygoodantibodies.co.uk/antibodies/ACE/",
"added_at": "2026-08-05T14:52:14.964314+00:00",
"oga_recommendation": "not_recommended",
"filename": "ACE_ab254222_IP.png"
}
],
"sync": {
"mode": "full",
"etag": "\"e91d64…8f248-json\"",
"compare_on": "url",
"compare_note": "To fetch only what you do not already hold, compare each file's `url` …",
"reconnect_with": "If-None-Match: \"e91d64…8f248-json\"",
"reconnect_note": "Send that header verbatim. It is not `dataset_version` …",
"deletions_tracked": true,
"image_format": "original",
"image_format_note": "Files are served exactly as stored. …",
"detail": "This is the complete set for your scope. …"
},
"bulk_download": {
"endpoint": "https://onlygoodantibodies.co.uk/api/v1/download/",
"scope": "whole_public_dataset",
"ready": true
},
"tier": "standard"
}
The _note fields are the same guidance as this document, carried in the reply
so a client that never reads this page still gets it; they are prose and may be
reworded. bulk_download.ready is the machine-readable form of the 503 in
§5 — false means no prepared archive exists for this dataset version yet, and
the block carries a note saying so. Check it before calling /download/
rather than handling the 503. tier is vestigial: nothing in this API reads
it to decide anything.
At the current dataset size that is about 2.3 MB and 4,900 files.
complete — true means this is every file in your scope. If it is
false, the response was capped or filtered, and a URL missing from it has
not necessarily been removed from the dataset. Only diff-and-delete against a
response where complete is true.
sync.deletions_tracked — true only on a full manifest.
?gene=ACE,ANXA11 narrows the manifest the way it narrows /download/, so you
can hold an incremental mirror of part of the dataset rather than all of it.
The reply is complete: true — it really is every file for those genes — and
that is the one thing to be careful with. complete is what tells a diffing
client that a URL's absence means the file was withdrawn, so pointing a narrowed
manifest at a mirror of the whole dataset makes every other gene look deleted.
The reply says which way it is:
{
"complete": true,
"scope": { "requested_genes": ["ACE"] },
"sync": {
"narrowed_by_request": true,
"narrowed_note": "This reply covers only the gene(s) you asked for …"
}
}
One directory per scope. Keep a narrowed mirror somewhere of its own and
diff it against nothing wider. The sync client below takes OGA_GENES and puts
each scope in its own directory for exactly this reason.
The ETag names the narrowing too, so a tag from the full manifest will not satisfy a request for one gene, and vice versa.
Store sync.etag (identical to the ETag response header). Next time, send it
back:
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
-H 'If-None-Match: "e91d64…8f248-json"' \
-i https://onlygoodantibodies.co.uk/api/v1/manifest/
If nothing has changed you get 304 Not Modified with an empty body — about
0.04s and no data transferred. If anything has changed you get the whole
manifest and diff it against what you hold.
Send the header verbatim, quotes included. It is not the same string as
dataset_version: the ETag also identifies the format, so a cached CSV cannot
satisfy a request for JSON.
The ETag covers image identity, stored filename and every recommendation in your scope. So it changes when a figure is added, removed, replaced with a different image, or when a recommendation changes — a timestamp cursor would miss the last two.
Deletions are recorded nowhere. A withdrawn figure leaves no tombstone, so a delta feed can only ever tell you what arrived, never what went — and a client syncing on deltas alone slowly accumulates files that are no longer part of the dataset, with no way to find out.
The full manifest solves both directions at once: diff it against what you hold, and additions and removals both fall out. The ETag makes that free when nothing has changed.
CSV — one row per file, for spreadsheets and shell pipelines:
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
"https://onlygoodantibodies.co.uk/api/v1/manifest/?format=csv" -o manifest.csv
url,filename,gene,catalogue_number,rrid,supplier,application,application_display,oga_recommendation,product_link,discontinued,gene_page_url,image_id,added_at
https://onlygoodantibodies.co.uk/media/publication_images/2026/ab254222_WB.png,ACE_ab254222_WB.png,ACE,ab254222,AB_3073965,Abcam,WB,Western Blot,recommended,https://www.abcam.com/ab254222,False,https://onlygoodantibodies.co.uk/antibodies/ACE/,1,2026-08-05T14:52:33.547850+00:00
Plain URLs — one per line, for wget -i:
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
"https://onlygoodantibodies.co.uk/api/v1/manifest/?format=urls" -o urls.txt
wget -i urls.txt -P images/
| Parameter | Default | Notes |
|---|---|---|
format |
json |
json, csv or urls |
gene |
— | Comma-separated symbols, e.g. ACE,ANXA11. Keeps complete: true — see below. |
since |
— | ISO 8601 or YYYY-MM-DD. Additions only. Cannot report removals or replacements — the reply says so. |
limit |
none | Caps the reply. Sets complete: false. |
offset |
0 |
Use with limit. |
filename applies the pattern set on your account, defaulting to
{gene}_{catalogue}_{application}. Placeholders: {gene}, {catalogue},
{rrid}, {application}. Characters illegal on Windows are stripped, and if a
pattern would give two files the same name they are suffixed _2, _3 and the
count is reported as filename_collisions. Include {application} to avoid
that.
Files are served exactly as stored — no format conversion. If you need PNG or JPEG specifically, convert after download.
GET /antibodies/ — antibody recordsThe metadata feed. Use the manifest for downloads; use this when you want the records rather than the files.
Needs no key. Without one you get the whole published set every time —
there is no cursor to advance and no delta gate, exactly as ?preview=true
behaves for a keyed caller, and ?since= still works as an ordinary filter if
you pass it yourself. With a key you additionally get your supplier filter and
the incremental behaviour described below.
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
"https://onlygoodantibodies.co.uk/api/v1/antibodies/?preview=true"
{
"consumer": "Abcam",
"query_time": "2026-08-05T14:52:33.621227+00:00",
"since": null,
"preview": true,
"count": 1,
"matched": 1,
"complete": true,
"cursor_advanced": false,
"consumer_type": "manufacturer",
"antibodies": [
{
"antibody_name": "ab254222",
"gene": "ACE",
"gene_has_recommendations": true,
"gene_page_url": "https://onlygoodantibodies.co.uk/antibodies/ACE/",
"created_at": "2026-08-05T14:52:33.547008+00:00",
"metadata": {
"rrid": "AB_3073965",
"supplier": "Abcam",
"host": "Rabbit",
"clonality": "Recombinant monoclonal",
"clone_id": "EPR22291-247",
"recombinant": "Yes",
"product_link": "https://www.abcam.com/ab254222",
"discontinued": false
},
"recommendations": { "WB": true, "ICC-IF": false, "IP": false, "FC": false },
"oga_recommendations": {
"WB": "recommended",
"IP": "not_recommended",
"ICC-IF": "not_tested",
"FC": "not_tested"
},
"experiments": [
{
"experiment_type": "WB",
"experiment_type_display": "Western Blot",
"image_url": "https://onlygoodantibodies.co.uk/media/publication_images/2026/ab254222_WB.png"
}
],
"embed_urls": {
"all": "https://onlygoodantibodies.co.uk/embed/?rrid=AB_3073965",
"WB": "https://onlygoodantibodies.co.uk/embed/?rrid=AB_3073965&application=WB",
"IP": "…", "ICC-IF": "…", "FC": "…"
}
}
]
}
Called without preview=true, this endpoint returns only what is new since
your last call and moves your cursor, and it is limited to one such call per
hour. That has two consequences worth knowing:
429.So unless you specifically want that behaviour:
?preview=true — the whole set, no hourly limit, cursor untouched. This
is what you want in almost every case.?advance_cursor=false — take the delta but leave the cursor where it is,
so you can ask again if something goes wrong.For keeping a copy in step with ours, prefer the manifest with an ETag. It is idempotent, it detects replacements and removals, and nothing about it depends on server-side state.
| Parameter | Notes |
|---|---|
preview |
true = full set, no cursor, no hourly limit |
advance_cursor |
false = read the delta without consuming it |
since |
ISO 8601 or YYYY-MM-DD |
gene |
Exact gene symbol, e.g. ACE |
application |
WB, IP, ICC-IF or FC — recommended for that application |
recommended_only |
true = recommended for at least one application |
limit, offset |
Paging. A capped reply sets complete: false. |
GET /genes/ — the gene catalogueEvery gene with characterisation data, with counts. Needs no key.
curl -s https://onlygoodantibodies.co.uk/api/v1/genes/
{
"consumer": "Abcam",
"count": 159,
"since": null,
"mode": "full",
"genes": [
{
"gene": "ACE",
"gene_page_url": "https://onlygoodantibodies.co.uk/antibodies/ACE/",
"f1000_report": "10.12688/f1000research.1234.1",
"has_recommendations": true,
"antibody_count": 12,
"experiment_count": 31,
"recommendations_by_application": { "WB": 3, "ICC-IF": 1, "IP": 2, "FC": 0 }
}
]
}
mode is full unless you pass ?since=, in which case it is incremental
and lists only genes with antibodies added after that point. Also takes
?has_recommendations=true|false.
antibody_count is the antibodies on that gene with a published figure —
the same rows /gene-detail/ returns, the same rows the gene page draws, and
the same rows /v1/manifest/ has files for. experiment_count is their
figures, one per antibody per application, and
recommendations_by_application is counted over the same set. So the counts
sum across this feed to the total_antibodies and total_experiments in
/status/, and any gene here totalling zero is
a defect rather than a gene awaiting characterisation — those are not listed at
all.
GET /gene-detail/ — the competitor viewManufacturers only. Every antibody against one gene, grouped by supplier, with a recommended-versus-total summary per vendor.
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
"https://onlygoodantibodies.co.uk/api/v1/gene-detail/?gene=ACE"
{
"gene": "ACE",
"consumer": "Abcam",
"gene_page_url": "https://onlygoodantibodies.co.uk/antibodies/ACE/",
"has_recommendations": true,
"total_antibodies": 12,
"consumer_suppliers": ["Abcam"],
"supplier_summary": {
"Abcam": { "count": 4, "recommended": 2 },
"Proteintech": { "count": 5, "recommended": 1 }
},
"antibodies": ["…"]
}
Any other account type gets 403 with your_consumer_type naming what it is.
?gene= is required; an unknown gene is 404.
GET /status/ — your accountcurl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/status/
{
"consumer": "Abcam",
"consumer_type": "manufacturer",
"supplier_filter": null,
"gene_filter": null,
"last_queried_at": null,
"pending_antibodies": 1645,
"total_genes": 159,
"total_antibodies": 1645,
"total_experiments": 4935,
"portal_config": {},
"bulk_download": {
"endpoint": "https://onlygoodantibodies.co.uk/api/v1/download/",
"scope": "whole_public_dataset",
"ready": true
},
"tier": "standard"
}
Use it to check a key, to see what your account is scoped to, and as a cheap
health check. No side effects. bulk_download is the same block /manifest/
carries, so this is also the cheapest way to ask whether a prepared archive is
ready. portal_config is what the data portal stored for this account, and
tier is vestigial.
POST /report-issue/ — flag a problemOpen to every account. If a recommendation looks wrong, or a product has been discontinued, please tell us.
curl -s -X POST -H "X-API-Key: YOUR_API_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"catalogue": "ab254222",
"gene": "ACE",
"rrid": "AB_3073965",
"issue_type": "discontinued",
"details": "Delisted from our catalogue in July 2026."
}' \
https://onlygoodantibodies.co.uk/api/v1/report-issue/
catalogue, gene and issue_type are required. issue_type is one of
recommendation, discontinued or technical.
POST /mark-reviewed/ — move your cursorSets your review cursor to now, without fetching anything.
curl -s -X POST -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/mark-reviewed/
GET / POST /reviewed/ — per-antibody review tracking# what you have marked
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/reviewed/
# mark some
curl -s -X POST -H "X-API-Key: YOUR_API_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{"catalogues": ["ab254222", "10842-1-AP"]}' \
https://onlygoodantibodies.co.uk/api/v1/reviewed/
# clear all of them
curl -s -X DELETE -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/reviewed/clear/
GET / PUT /portal-config/ — your preferencesControls the filename pattern the manifest applies, among other things.
curl -s -X PUT -H "X-API-Key: YOUR_API_KEY_HERE" \
-H "Content-Type: application/json" \
-d '{
"filename_pattern": "{gene}_{catalogue}_{application}",
"url_pattern": "https://www.example.com/products/{catalogue}",
"contact_email": "someone@example.com"
}' \
https://onlygoodantibodies.co.uk/api/v1/portal-config/
OGA's figures are cropped from a validation experiment and then wait for a review meeting before they go on the public website. These two endpoints are that window, and they exist for one reason: a wrong catalogue number, a withdrawn lot or a mistaken RRID is far cheaper to fix here than on a live page carrying your product's name.
Everything they return is unpublished. It may change or be withdrawn before
release. Do not quote it, publish it or link it, and do not read
provisional_recommendation as an OGA recommendation — that verdict is not
applied to the antibody until the figure is released, at which point the figure
appears in /manifest/ and on the public gene page like any other.
Both need a key with a supplier scope — the set of reagents that are yours.
A key without one is refused with 403, because unscoped is not a narrow scope,
and these endpoints must never show one manufacturer another's unpublished work.
Your published data is unaffected either way. Email
onlygoodantibodies@gmail.com to have a scope set.
GET /pipeline-data/ — your figures awaiting releasecurl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/pipeline-data/
{
"consumer": "Abcam",
"count": 1,
"genes": ["SNCA"],
"note": "These figures are NOT published...",
"figures": [
{
"id": 12,
"catalogue_number": "ab138501",
"rrid": "AB_2687467",
"gene": "SNCA",
"supplier": "Abcam",
"application": "WB",
"status": "awaiting_release",
"provisional_recommendation": true,
"replaces_a_published_figure": false,
"staged_at": "2026-08-13T09:12:44.108Z",
"image_url": "https://onlygoodantibodies.co.uk/api/v1/pipeline-image/?id=12"
}
]
}
?gene= narrows to one exact gene symbol.
image_url needs your key like any other request here — it is served by the
application, not from object storage, and your scope is re-checked on every
request, so a URL alone is not a permission. An id that is not yours answers
404, the same as one that does not exist.
GET /gene-progress/ — where your genes have got toOne row per gene you have an antibody for, including genes still being worked on — which is usually the question being asked.
curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
https://onlygoodantibodies.co.uk/api/v1/gene-progress/
{
"consumer": "Abcam",
"count": 1,
"note": "Derived from records, not from a status field...",
"genes": [
{
"gene": "SNCA",
"has_public_page": true,
"public_url": "https://onlygoodantibodies.co.uk/antibodies/SNCA/",
"tested_antibodies": 6,
"your_antibodies": 2,
"awaiting_release": 1,
"stage": "published_figures",
"applications": {
"WB": {"published": true, "awaiting_release": false, "run_at_sites": ["McGill"]},
"IP": {"published": false, "awaiting_release": true, "run_at_sites": []},
"ICC-IF": {"published": false, "awaiting_release": false, "run_at_sites": []},
"FC": {"published": false, "awaiting_release": false, "run_at_sites": []}
},
"report": {"status": "published", "doi": "10.5281/zenodo.1", "url": "https://doi.org/10.5281/zenodo.1", "date": "2026-08-01"}
}
]
}
Every field is derived from records that exist, never from a status somebody
typed: a gene has a public page when an antibody for it carries a published
figure, an application is published when a figure for it is on the site, and a
report is published when it carries a Zenodo DOI or a published F1000 date.
That is why these values move without anybody updating anything — and why
awaiting_release, which counts your reagents' figures only, can be
non-zero while nothing has changed on the public page.
stage is a coarse label for sorting a list: in_progress,
awaiting_release, published_figures, reported.
This is the "only fetch what I don't already have" loop. It fetches the manifest, downloads what is new and what has been replaced, removes what has gone, and stores the ETag so the next run is a single cheap request.
It reads its key from OGA_API_KEY, so nothing in it needs editing. Copy the
block below into sync.py — or, with the repository checked out, lift it
straight out of this file so the two cannot drift:
python3 -c '
import re, pathlib
fence = chr(96) * 3
md = pathlib.Path("API.md").read_text()
print(re.findall(fence + "python" + chr(10) + "(.*?)" + fence, md, re.S)[0])
' > sync.py
export OGA_API_KEY=... # your key
export OGA_GENES=ACE # optional: mirror one gene instead of all of them
python3 sync.py # first run downloads
python3 sync.py # second run: "Nothing has changed."
The one rule to keep if you write your own: compare the url, not whether a
file of that name exists locally. A re-cropped figure keeps its filename — that
is built from gene, catalogue number and application — and lands on a new object
key, so the URL is the only field that moves. A client that skips on existence
alone will notice the dataset changed, download the manifest, and then quietly
keep the stale image for ever. The url is the per-file identity; the ETag is
the whole-dataset one.
#!/usr/bin/env python3
"""Keep a local copy of the OGA figure set in step with ours."""
import json
import os
import pathlib
import sys
import urllib.request
# From the environment, so the key never lands in a file you might commit.
# `export OGA_API_KEY=...`, or a Codespaces/CI secret. Replace the fallback
# only if you are pasting this somewhere disposable.
API_KEY = os.environ.get("OGA_API_KEY", "YOUR_API_KEY_HERE")
BASE = "https://onlygoodantibodies.co.uk/api/v1"
# Optional: mirror only these genes, e.g. OGA_GENES=ACE,ANXA11. Each scope gets
# its own directory, because the deletion pass below removes any local file the
# manifest did not mention — point a one-gene manifest at a full mirror and it
# would take everything else with it.
GENES = os.environ.get("OGA_GENES", "").strip()
# Identify your client. urllib sends "Python-urllib/3.x" by default, and bot
# protection in front of the image CDN can refuse that outright -- which returns
# 403 on a file that is perfectly public and looks like a permissions problem.
USER_AGENT = "my-company-oga-sync/1.0"
DEST = pathlib.Path("oga_images" + ("_" + GENES.replace(",", "_") if GENES else ""))
STATE = DEST / ".sync_state.json"
def get(url, headers):
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request) as response:
# Return the headers object, never dict(...): HTTP/2 lowercases
# every header name, so behind a CDN `ETag` arrives as `etag` and a
# dict lookup for "ETag" finds nothing. This object's .get() is
# case-insensitive; a dict's is not, and the failure is silent --
# you store no etag, never send If-None-Match, and refetch the
# whole manifest for ever.
return response.status, response.read(), response.headers
except urllib.error.HTTPError as error:
return error.code, error.read(), error.headers
def main():
DEST.mkdir(exist_ok=True)
state = json.loads(STATE.read_text()) if STATE.exists() else {}
headers = {"X-API-Key": API_KEY, "User-Agent": USER_AGENT}
if state.get("etag"):
headers["If-None-Match"] = state["etag"]
url = f"{BASE}/manifest/" + (f"?gene={GENES}" if GENES else "")
status, body, response_headers = get(url, headers)
if status == 429:
print(f"Rate limited. Retry in {response_headers.get('Retry-After')}s.")
return 1
if status not in (200, 304):
print(f"Manifest failed: HTTP {status}\n{body[:400].decode(errors='replace')}")
return 1
if status == 304:
# A 304 answers "has the dataset changed". It does NOT answer "is my
# copy of it still intact" -- that is not the server's question and it
# has no way to know. A mirror loses files to a half-finished copy, a
# full disk, a cleared directory; take the 304 as proof of both and the
# client prints "Nothing has changed" for ever while the images your
# pages point at are gone. The file list is in the state file, so
# checking costs nothing and needs no second request.
wanted = state.get("files", {})
missing = [name for name in wanted if not (DEST / name).exists()]
if not missing:
print("Nothing has changed.")
return 0
print(f"Dataset unchanged; {len(missing)} file(s) missing locally.")
else:
manifest = json.loads(body)
# A capped reply cannot be diffed: a missing URL might still be in the
# dataset, and deleting on that basis loses real files.
if not manifest["complete"]:
print("Manifest is not complete — refusing to sync deletions.")
return 1
wanted = {f["filename"]: f["url"] for f in manifest["files"]}
held = state.get("files", {})
# Compare the URL, never "does a file of that name exist". A re-cropped
# figure keeps its filename -- that is built from gene, catalogue number and
# application -- and lands on a NEW object key, so the URL is the only thing
# that moves. Checking existence alone silently keeps the old image for ever
# on exactly the change the ETag exists to reveal.
added = replaced = restored = 0
for filename, url in wanted.items():
target = DEST / filename
if target.exists() and held.get(filename) == url:
continue
# No API key needed: these are public CDN URLs on Cloudflare R2. Send a
# User-Agent all the same -- see the note above.
image = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(image) as source:
target.write_bytes(source.read())
# Decided from what we knew before the write, not after it: the old
# version asked `target.exists()` once the bytes were already on disk,
# so the answer was always yes.
if filename not in held:
added += 1
elif held[filename] != url:
replaced += 1
else:
restored += 1
removed = 0
for existing in DEST.iterdir():
if existing.name.startswith(".") or existing.name in wanted:
continue
existing.unlink()
removed += 1
STATE.write_text(json.dumps({
"etag": response_headers.get("ETag"),
"files": wanted,
}))
print(f"{len(wanted)} files in scope: {added} added, "
f"{replaced} replaced, {restored} restored, {removed} removed.")
return 0
if __name__ == "__main__":
sys.exit(main())
Run it as often as you like. After the first run it is one request and a 304
until something actually changes.
The version in openapi.json (info.version) is this API's, not the site's.
It is not the v1 in the URL — that is the path and has not moved.
Removed: verdicts. It was a deprecated alias of oga_recommendations,
which carries exactly the same object and has shipped in the same response
since this API was written. Read that instead. This is why the major number
moved; no key had been issued outside OGA, so nothing broke in practice.
Added: ?gene= on /manifest/ — mirror part of the dataset incrementally
rather than pulling everything. The reply stays complete: true and says it
was narrowed; see §5b, and read the warning there before diffing it against a
mirror of the whole dataset. Manifest body version 2: scope.requested_genes,
sync.narrowed_by_request and sync.narrowed_note are new.
Fixed: conditional requests. If-None-Match was compared exactly, so a tag
weakened in transit — which any cache that compresses the body is required to
do, and ours does — never matched. Nobody had ever received a 304. It uses
the weak comparison RFC 9110 §13.1.2 requires, so reconnects are now the cheap
request this document has always described.
The sync client in §11 was fixed the same day: it read response headers
case-sensitively, so behind HTTP/2 it never stored an ETag and never sent one;
and it treated a 304 as proof that its local copy was intact, so a mirror
that lost files was never repaired. Re-copy it if you took it before that date.
You are welcome to show the figures alongside the products they are about.
Please attribute the source and link the gene_page_url, so a reader can reach
the full record and the report behind it.
Questions, or something here that does not match what the API does: onlygoodantibodies@gmail.com