All genes
You need a key for everything below. Portal & API explains what the API is for and how to ask for one. The machine-readable specification is at /api/v1/openapi.json.

OGA Data API — v1

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.

The machine-readable contract

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.


1. Your API key

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.

Check your key works

curl -s -H "X-API-Key: YOUR_API_KEY_HERE" \
  https://onlygoodantibodies.co.uk/api/v1/status/

2. Rate limits

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.


3. What a recommendation means

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.


3b. Licence and citation

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.


4. GET / — the endpoint catalogue

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


5. GET /download/ — everything in one file

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


5b. GET /manifest/ — the list, for syncing

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

The whole manifest

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.

Two fields to check before you act on it

completetrue 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_trackedtrue only on a full manifest.

Mirroring one gene

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

Reconnecting: send the ETag back

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.

Why the whole manifest, and not just what changed

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.

Other formats

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/

Parameters

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.

Filenames

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.


6. GET /antibodies/ — antibody records

The 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": "…"
      }
    }
  ]
}

The cursor — the one thing on this API with a side effect

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:

So unless you specifically want that behaviour:

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.

Parameters

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.

7. GET /genes/ — the gene catalogue

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


8. GET /gene-detail/ — the competitor view

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


9. GET /status/ — your account

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


10. Writing endpoints

POST /report-issue/ — flag a problem

Open 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 cursor

Sets 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 preferences

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

10b. Pre-release — your own reagents, before they are published

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 release

curl -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 to

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


11. A complete sync client

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.


12. What changed

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.

2.0.0 — 7 August 2026

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.


13. Using the data

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