OSCR

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
ScopeWhat it allows
repos:readread the registry's layer over repositories: their papers, their state, the commit statuses posted to it.
research:readread research issues and their comments.
research:writeopen, comment on, edit, close and reopen research issues, as you; it also reads what its area reads.
social:readread your stars, lists and follows, people's public profiles and your feed.
social:writestar, list, follow and watch, and change your profile, as you; it also reads what its area reads.
notifications:readread your notifications (the registry's own inbox).
notifications:writemark your notifications read, done or saved, and unsubscribe; it also reads what its area reads.
hooks:readread your outgoing webhooks and their deliveries.
hooks:writemake, change, test, redeliver and delete your outgoing webhooks; it also reads what its area reads.
statuses:writepost commit statuses on the repositories the registry knows (a CI or a reproduction service); it also reads what its area reads.
security:writeupload 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"
  }
}
CodeStatusWhat it means
requires_authentication401No token was sent. Send Authorization: Bearer <token>.
bad_credentials401Not a token of the registry, revoked, or its account is gone.
token_expired401The token has expired. Make a new one.
insufficient_scope403The token does not have the scope this route needs. X-Accepted-Scopes names that scope.
forge_closed403Writing will open to everyone together with the registry's content rules. Until then, only its owner can write.
bad_payload400The body does not match what the route takes. The message says what is wrong.
unsupported_version400X-Api-Version names a version the API does not have.
not_found404No such route, or the registry does not know this object.
too_large413The body is larger than the route reads.
too_many429Your account has reached its daily cap for this kind of write.
rate_limited429The token has reached its rate limit. Wait Retry-After seconds.
quota503The 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/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/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/star

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

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

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

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.