The public API
With this API, a script, a notebook, a lab's CI or another service can read and write OSCR's own layer: repositories and their papers, research issues, stars and follows, notifications, outgoing webhooks and commit statuses. This is version 2026-09-29. The machine-readable description is openapi.json (OpenAPI 3.1).
GitHub's own objects (commits, files, issues, pull requests, releases) are served by GitHub's own API, with GitHub's tokens. OSCR's API serves what OSCR adds to them. The API never runs code.
Authentication and scopes
Every route but the index takes a personal token, made on your settings page: Authorization: Bearer oscr_pat_…. A token has scopes and expires after 1 to 366days (30 by default; every token expires). It can be revoked at once, and it is listed with its last use. An account holds 20 tokens at most. A token is shown once, and OSCR keeps only its SHA-256. Tokens are made and listed on the settings page only; a token cannot make or list tokens. The API never reads cookies, so a browser's session cannot act through it. CORS answers every origin, without credentials.
curl -H "Authorization: Bearer $TOKEN" https://openscicode.org/api/forge/v1/user
| Scope | What it allows |
|---|---|
| repos:read | read the registry's layer over repositories: their papers, their state, the commit statuses posted to it. |
| research:read | read research issues and their comments. |
| research:write | open, comment on, edit, close and reopen research issues, as you; it also reads what its area reads. |
| social:read | read your stars, lists and follows, people's public profiles and your feed. |
| social:write | star, list, follow and watch, and change your profile, as you; it also reads what its area reads. |
| notifications:read | read your notifications (the registry's own inbox). |
| notifications:write | mark your notifications read, done or saved, and unsubscribe; it also reads what its area reads. |
| hooks:read | read your outgoing webhooks and their deliveries. |
| hooks:write | make, change, test, redeliver and delete your outgoing webhooks; it also reads what its area reads. |
| statuses:write | post commit statuses on the repositories the registry knows (a CI or a reproduction service); it also reads what its area reads. |
| security:write | upload code-scanning results (SARIF) from your CI, for the repositories the registry knows; it also reads what its area reads. |
Every answer says the token's scopes (X-Token-Scopes), the scope the route needs (X-Accepted-Scopes) and the token's expiry (X-Token-Expires). A write through the API is the same write as on the site, made as you, with the same checks, daily caps and rows. Until OSCR's content rules open writing to everyone, only its owner can make a token.
Rate limits
A token may make 60 requests a minute and 1,000 a day. Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used and X-RateLimit-Reset (Unix seconds, the next 00:00 UTC). Past a limit, the answer is 429 with Retry-After. GET /api/forge/v1/rate_limit shows the token's current use and is never counted. Writes also count toward your account's daily caps, as on the site. OSCR runs on a free plan. When its requests for the day are spent, every route answers 429 until 00:00 UTC, and the pages stay up.
Pagination and conditional requests
A list with more pages answers with Link: <…>; rel="next" and a next field, which hold the address of the next page with its cursor (?after=). Every GET answer carries an ETag. Send it back in If-None-Match, and an unchanged answer is 304 Not Modified, which does not count against the rate limit.
Errors
An error is a JSON object with a message in words, the request's id (also in X-Request-Id) and this page's address:
{
"error": {
"code": "insufficient_scope",
"message": "This token may not do this: it needs the scope social:read. Make a token with it in your settings.",
"request_id": "u2Hn0cZ3VwQy8pKe",
"documentation_url": "https://openscicode.org/developers/#errors"
}
}| Code | Status | What it means |
|---|---|---|
| requires_authentication | 401 | No token was sent. Send Authorization: Bearer <token>. |
| bad_credentials | 401 | Not a token of the registry, revoked, or its account is gone. |
| token_expired | 401 | The token has expired. Make a new one. |
| insufficient_scope | 403 | The token does not have the scope this route needs. X-Accepted-Scopes names that scope. |
| forge_closed | 403 | Writing will open to everyone together with the registry's content rules. Until then, only its owner can write. |
| bad_payload | 400 | The body does not match what the route takes. The message says what is wrong. |
| unsupported_version | 400 | X-Api-Version names a version the API does not have. |
| not_found | 404 | No such route, or the registry does not know this object. |
| too_large | 413 | The body is larger than the route reads. |
| too_many | 429 | Your account has reached its daily cap for this kind of write. |
| rate_limited | 429 | The token has reached its rate limit. Wait Retry-After seconds. |
| quota | 503 | The registry has used its share of today's database writes. Try again after 00:00 UTC. |
The headers a browser's script may read (CORS): ETag, Link, Retry-After, X-Request-Id, X-Api-Version, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Reset, X-RateLimit-Resource, X-Token-Scopes, X-Accepted-Scopes, X-Token-Expires.
Versions and breaking changes
The API is versioned by date. Send X-Api-Version: 2026-09-29 to pin a version; without it, you get the current one. A breaking change comes with a new date. The previous version keeps answering as it did until its end date, which is announced here. Versions: 2026-09-29.
Breaking changes
There are none yet, because 2026-09-29 is the first version.
The routes
Under https://openscicode.org/api/forge/v1. A POST takes JSON (Content-Type: application/json).
GET /api/forge/v1GET /api/forge/v1/userGET /api/forge/v1/rate_limitGET /api/forge/v1/cliPOST /api/forge/v1/device/codePOST /api/forge/v1/device/tokenPOST /api/forge/v1/token/revokeGET /api/forge/v1/searchGET /api/forge/v1/reposGET /api/forge/v1/repos/mineGET /api/forge/v1/researchPOST /api/forge/v1/research/openPOST /api/forge/v1/research/commentPOST /api/forge/v1/research/editGET /api/forge/v1/socialGET /api/forge/v1/social/mineGET /api/forge/v1/social/personPOST /api/forge/v1/social/starPOST /api/forge/v1/social/followPOST /api/forge/v1/social/listPOST /api/forge/v1/social/profileGET /api/forge/v1/social/feedGET /api/forge/v1/social/activityGET /api/forge/v1/notificationsPOST /api/forge/v1/notifications/markGET /api/forge/v1/hooksGET /api/forge/v1/hooks/deliveriesPOST /api/forge/v1/hooks/writeGET /api/forge/v1/statusesPOST /api/forge/v1/statuses/postPOST /api/forge/v1/security/sarifPOST /api/forge/v1/statuses/actions
GET /api/forge/v1
This index: the version, the routes, how to authenticate.
Scope: none (no token needed).
GET /api/forge/v1/user
Whose token this is (public handles), its scopes, its expiry.
Scope: any valid token.
GET /api/forge/v1/rate_limit
The token's use of its rate limits (never counted).
Scope: any valid token.
GET /api/forge/v1/cli
What a command line needs to sign in: the GitHub App's public client id (for GitHub's own device flow, asked of GitHub directly), the device flow's routes, the scopes.
Scope: none (it takes GitHub Actions' own token).
POST /api/forge/v1/device/code
Start a command line's sign-in: a device code (kept by the command line), a user code of 8 letters, and the approval page's address; it lives 15 minutes and writes nothing.
Scope: none (it takes GitHub Actions' own token).
scopes- What the token may do, as scopes (required).
days- Its life, 1 to 366 days (30 by default).
name- Its name in your settings (40 characters).
POST /api/forge/v1/device/token
Poll a sign-in (at most every 5 seconds): 400 authorization_pending, slow_down, access_denied or expired_token; once approved on the page, the token, answered once.
Scope: none (it takes GitHub Actions' own token).
device_code- The device code (required).
POST /api/forge/v1/token/revoke
The token that makes this call revokes itself at once (a command line's sign-out). It never revokes another.
Scope: any valid token.
(none)- An empty object: the token in the Authorization header is the one revoked.
GET /api/forge/v1/search
The registry's search, as its search page asks it.
Scope: any valid token.
q(required)- The words searched (the search page's syntax).
type- papers (the default), repositories, issues, people or topics.
page- The page of results, from 1.
GET /api/forge/v1/repos
The registry's layer over one repository: its papers, its state, how it is followed, its releases tied to papers.
Scope: repos:read.
path- owner/name, as on GitHub.
id- <forge>:<id>, the forge's durable id (github:123456): one of path or id.
GET /api/forge/v1/repos/mine
Your repositories the registry knows, by name, a page at a time (Link: rel=next).
Scope: repos:read.
after- The cursor: the `next` of the previous page.
limit- At most 100 a page.
mode- created, installed or public.
template- 1: templates only.
GET /api/forge/v1/research
One research issue with its comments, or the research issues of 1 to 10 papers.
Scope: research:read.
id- A research issue's number.
paper- A paper's DOI (repeat it for up to 10 papers).
repo- <forge>:<id>: only the issues on this repository's code.
POST /api/forge/v1/research/open
Open a research issue on a paper and its code (answer 201: its id and its page).
Scope: research:write.
paper- The paper's DOI (required).
repo- {forge, id, path}: the repository of the code (or `code`, an address the paper cites).
type- code_error, mismatch or reproduction (required).
title- Its title (required).
body- Its text (Markdown; addresses masked).
commit- The commit it is about.
path- The file it is about.
lines- {start, end}: the lines.
paragraph- The paper's paragraph it is about.
report- A reproduction report (for type reproduction).
labels- Labels, words.
POST /api/forge/v1/research/comment
Comment on a research issue; edit, delete or hide a comment.
Scope: research:write.
id- The research issue's number (required).
n- The comment's number (to edit, delete or hide one).
body- The text.
delete- true: delete the comment n.
hide- A reason: hide the comment n.
POST /api/forge/v1/research/edit
Edit a research issue: title, text, close with a resolution, reopen, labels, lock, pin.
Scope: research:write.
id- The research issue's number (required).
title- A new title.
body- A new text.
state- open or closed.
reason- Why it is closed: completed, not_planned, duplicate.
resolution- The resolution, in words.
duplicateOf- The research issue it duplicates.
labels- {add, remove}: labels.
locked- true or false.
pinned- true or false.
GET /api/forge/v1/social
Your star, lists and follow of up to 20 subjects or targets.
Scope: social:read.
s(required)- A subject (repo:<forge>:<id>, paper:doi:10.…, topic:<name>) or a target (github:<id>, orcid:<iD>, owner:<forge>:<login>…); repeat it.
GET /api/forge/v1/social/mine
Your stars, lists (private ones included), follows and profile.
Scope: social:read.
GET /api/forge/v1/social/person
A person's public profile, lists, stars and follows (none from a private profile).
Scope: social:read.
github- Their GitHub numeric id.
orcid- Their ORCID iD: one of github or orcid.
POST /api/forge/v1/social/star
Star or unstar a repository, a paper or a topic.
Scope: social:write.
subject- repo:<forge>:<id>, paper:doi:10.… or topic:<name> (required).
label- What you call it (your Stars page only).
on- true to star, false to unstar (required).
POST /api/forge/v1/social/follow
Follow a person, an author by ORCID iD, an organization, a paper; watch a repository at a level; or stop.
Scope: social:write.
target- What to follow (required).
label- What you call it.
level- all, participating, ignore or custom (a repository).
events- custom: issues, pulls, releases, research.
on- true or false (required).
POST /api/forge/v1/social/list
Your star lists: create, edit, delete, add, remove, propose as a collection.
Scope: social:write.
op- create, edit, delete, add, remove or propose (required).
id- The list's number (1 to 32).
name- Its name.
description- Its description.
public- true or false.
subject- The subject to add or remove.
propose- true or false.
POST /api/forge/v1/social/profile
Change your profile (no address is ever asked for or kept).
Scope: social:write.
name- Your name.
bio- A few words.
pronouns- Your pronouns.
company- Your lab or company.
location- Where.
timezone- An IANA time zone.
website- An https address.
links- Up to 4 https addresses.
pinned- Up to 6 subjects or lists.
status- A status.
statusUntil- When it ends (Unix seconds).
busy- true or false.
private- true: your activity, stars, lists and follows are yours only.
readme- true: show your profile README.
GET /api/forge/v1/social/feed
The last 14 days of what you follow.
Scope: social:read.
GET /api/forge/v1/social/activity
A person's contribution calendar (a year), timeline (3 months) and milestones.
Scope: social:read.
github- Their GitHub numeric id.
orcid- Their ORCID iD.
me- 1: your own.
GET /api/forge/v1/notifications
Your notifications: the registry's own inbox, computed now (no email is ever sent).
Scope: notifications:read.
POST /api/forge/v1/notifications/mark
Mark threads read, unread, done, undone, saved or unsaved; unsubscribe or subscribe; all read; your settings.
Scope: notifications:write.
op- read, unread, done, undone, save, unsave, unsubscribe, subscribe, all_read or settings (required).
threads- Up to 25 {key, title, url}.
settings- Your notification settings (op settings).
GET /api/forge/v1/hooks
Your outgoing webhooks, and the events a repository's or a paper's offer.
Scope: hooks:read.
GET /api/forge/v1/hooks/deliveries
One webhook's deliveries of the last 7 days: event, status, time, attempts.
Scope: hooks:read.
id(required)- The hook's id.
POST /api/forge/v1/hooks/write
Make a webhook (pinged first; its secret answered once), change its events, pause it, ping it, redeliver a delivery, rotate its secret, delete it.
Scope: hooks:write.
op- create, update, ping, redeliver, rotate or delete (required).
subject- create: repo:<forge>:<id> or paper:doi:10.….
url- create: the https address it posts to.
events- create, update: the events, or "*" for all.
id- The hook's id (every op but create).
active- update: false pauses it (a ping makes it active again).
guid- redeliver: the delivery's id.
GET /api/forge/v1/statuses
The statuses posted on a commit of a repository the registry knows, and their combined state.
Scope: repos:read.
path- owner/name.
id- <forge>:<id>: one of path or id.
sha(required)- The commit's full id.
POST /api/forge/v1/statuses/post
Post a commit status (a lab's CI, a reproduction service): the latest of each context is kept.
Scope: statuses:write.
repo- owner/name or <forge>:<id> (required).
sha- The commit's full id (required).
state- error, failure, pending or success (required).
context- The service and its check, as “lab-ci/tests” (default: default).
description- A short sentence (140 characters).
target_url- An https page with the details.
POST /api/forge/v1/security/sarif
Upload code-scanning results as SARIF 2.1.0 (your CI's own analyser): the registry shows them. It runs no analyser itself. The upload replaces the repository's code-scanning alerts.
Scope: security:write.
repo- owner/name or <forge>:<id> (required).
commit- The commit's full hex id the analysis ran on.
ref- The branch the analysis is about.
sarif- The SARIF 2.1.0 document (required).
POST /api/forge/v1/statuses/actions
Post a commit status from a GitHub Actions workflow, with GitHub's OIDC token (audience: this site's origin) instead of a registry token: no secret in the repository.
Scope: none (it takes GitHub Actions' own token).
sha- The commit's full id (required).
state- error, failure, pending or success (required).
context- Default: “GitHub Actions: <the workflow's name>”.
description- A short sentence (140 characters).
target_url- An https page with the details (the run's page).
For example, star a paper:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"subject": "paper:doi:10.1234/eeg.2026", "label": "EEG filters", "on": true}' \
https://openscicode.org/api/forge/v1/social/starOutgoing webhooks
A webhook, made on your settings page or with POST /api/forge/v1/hooks/write, posts what happens to a paper or a repository to your service. An account holds 10.
- A paper's events: research_opened, research_comment, research_closed, research_reopened, code_linked, release_tied.
- A repository's events: issue_opened, issue_closed, issue_reopened, issue_comment, pull_opened, pull_closed, pull_merged, pull_reopened, pull_review, pull_comment, release_published.
When a webhook is made, OSCR sends a ping, and the webhook becomes active once the ping gets a 2xx answer. Each delivery waits 5 seconds for an answer. It is tried again after 1 s and 4 s when there is no answer, or after a 5xx, 408 or 429 answer. It is not tried again after another 4xx answer, and redirections are not followed. After 10 failures in a row the webhook pauses, and a ping makes it active again. The last 7 days of deliveries are listed, and each event's delivery can be sent again. The address must use https, and it cannot be a private network, this machine, a local name or OSCR itself.
Each delivery has the headers X-Hook-Event, X-Hook-Delivery (its id), X-Hook-ID, and the signature X-Hub-Signature-256: sha256=<hex>. The signature is the HMAC-SHA-256 of the raw body with the webhook's secret, computed the way GitHub signs. An example body:
{
"event": "research_opened",
"delivery": "5f0c6f3e-8a1b-4c7d-9e2f-0a1b2c3d4e5f",
"hook": { "id": "Qk3xY7pLm2Nw9Zt4", "subject": "paper:doi:10.1234/eeg.2026" },
"sent_at": "2026-09-29T12:00:03.000Z",
"subject": "paper:doi:10.1234/eeg.2026",
"at": "2026-09-29T12:00:00.000Z",
"thread": "research:7",
"title": "The filter's order is 4, the paper says 2",
"url": "https://openscicode.org/research/7",
"repository": { "path": "owner/name", "url": "https://openscicode.org/r/owner/name/" },
"paper": { "doi": "10.1234/eeg.2026", "url": "https://doi.org/10.1234/eeg.2026" },
"actor": { "github_id": "4242001", "login": "ada-fixture" }
}The body holds only what OSCR's pages show (a title, a page's address, the time, a GitHub login). It never holds a text or an email address. Check the signature before anything else, in constant time:
import hashlib, hmac
def signed(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
# In your receiver: signed(SECRET, request.body, request.headers["X-Hub-Signature-256"])import { createHmac, timingSafeEqual } from "node:crypto";
export function signed(secret, body, header) {
const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(body).digest("hex"));
const given = Buffer.from(header ?? "");
return expected.length === given.length && timingSafeEqual(expected, given);
}The secret is shown once, when the webhook is made or given a new secret. OSCR does not store it, because it is derived from a key of the server.
Commit statuses
A lab's CI, another forge or a reproduction service can report the result of a commit of a repository OSCR knows: error, failure, pending or success, under a context of its own. The latest status of each context is kept, with at most 20 contexts per commit. The repository's Checks page shows them beside its tests. With a token that has the statuses:write scope:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"repo": "owner/name", "sha": "<the commit's 40 characters>", "state": "success",
"context": "lab-ci/tests", "description": "212 tests passed",
"target_url": "https://ci.lab.example/runs/1"}' \
https://openscicode.org/api/forge/v1/statuses/postFrom GitHub Actions, no secret is needed. The workflow asks GitHub for its OIDC token with OSCR's address as the audience, and OSCR checks it (GitHub's signature, the issuer, the audience, the times, a public repository it knows). The status then belongs to the repository and is named after the workflow:
name: Tests
on: [push, pull_request]
permissions:
contents: read
id-token: write # GitHub's OIDC token: no secret stored in the repository
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m pytest
- name: Tell the registry
if: always()
uses: actions/github-script@v7
with:
script: |
const token = await core.getIDToken("https://openscicode.org");
await fetch("https://openscicode.org/api/forge/v1/statuses/actions", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ sha: context.sha, state: "${{ job.status == 'success' && 'success' || 'failure' }}",
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` }),
});The registry's checks
On every push to a pull request of a repository where its GitHub App is installed, OSCR posts one check run. The check reads the files as text and never runs the code. The same checks are shown for any commit on the repository's Checks page, including the commits the papers cite.
- Licence
- Environment
- The paper's DOI
- CITATION.cff
- Tracing maps
- File sizes
- README
A check fails only when the change itself breaks something a paper relies on, for example when it deletes or renames a file a tracing map points to, deletes the licence file, or makes CITATION.cff unreadable. A missing item is reported for you to look at and does not fail the check. Each finding says how to resolve it. A commit whose message ends with the trailer skip-checks: true is not checked.
