Documentation
AlphaAuthenticate as an app
Sign an app JWT, find installations and create installation tokens, with Octokit or by hand.
The Upstream API is in alpha. Endpoints, fields and limits can change before it's stable. Send feedback to support@coline.app.
An app authenticates in two steps. It signs a short-lived JWT with one of its keys to act as the app. Then it trades that JWT for an installation token to act as its bot on one installation's repositories.
With Octokit
npm install octokit
import { readFileSync } from "node:fs";
import { App, Octokit } from "octokit";
const app = new App({
appId: process.env.UPSTREAM_APP_ID!,
privateKey: readFileSync("private-key.pem", "utf8"),
Octokit: Octokit.defaults({ baseUrl: "https://api.upstream.codes/api/v3" }),
});
// As the app, with its JWT
const { data: me } = await app.octokit.rest.apps.getAuthenticated();
// Find the installation on a repository, then act as the bot there
const { data: installation } = await app.octokit.rest.apps.getRepoInstallation({
owner: "acme",
repo: "api",
});
const octokit = await app.getInstallationOctokit(installation.id);
await octokit.rest.issues.create({ owner: "acme", repo: "api", title: "Nightly report" });
Octokit signs the JWT and refreshes installation tokens for you. appId is the App ID on your app's Keys and secrets tab, or the numeric id from GET /api/v3/app. The client ID works too.
Octokit's App signs only with RSA keys. To sign with an Ed25519 or P-256 key, see Sign with your own key.
Find installations
Call these with the app JWT:
| Method | Path | Does |
|---|---|---|
GET | /app | Gets your app |
GET | /app/installations | Lists every installation |
GET | /app/installations/{id} | Gets one installation |
GET | /repos/{owner}/{repo}/installation | Gets the installation that can reach a repository |
GET | /orgs/{org}/installation | Gets the installation on an organization |
GET | /users/{username}/installation | Gets the installation on a person's account |
DELETE | /app/installations/{id} | Removes an installation |
PUT, DELETE | /app/installations/{id}/suspended | Suspends or resumes an installation |
POST | /app/installations/{id}/access_tokens | Creates an installation token |
POST | /app/install-receipts/verify | Checks an install receipt |
The repository lookup returns 404 when the app is installed on the owner but not on that repository. In Octokit these are apps.getAuthenticated, apps.listInstallations, apps.getInstallation, apps.getRepoInstallation, apps.getOrgInstallation, apps.getUserInstallation, apps.deleteInstallation, apps.suspendInstallation, apps.unsuspendInstallation and apps.createInstallationAccessToken.
Both dialects have these paths. Installation IDs are numeric under /api/v3 and UUIDs under /v1.
Sign the JWT yourself
| Part | Value |
|---|---|
| Header | alg is RS256, EdDSA or ES256. typ: "JWT" and kid are optional. No other fields. |
iss | Your app's UUID, its numeric ID or its client ID |
iat | The current time. Set it 30 seconds early to allow for clock drift. |
exp | At most 10 minutes after iat |
Upstream refuses other claims, except nbf. With jose:
import { readFileSync } from "node:fs";
import { importPKCS8, SignJWT } from "jose";
const key = await importPKCS8(readFileSync("private-key.pem", "utf8"), "RS256");
const now = Math.floor(Date.now() / 1000);
const appJwt = await new SignJWT({})
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuer(process.env.UPSTREAM_APP_ID!)
.setIssuedAt(now - 30)
.setExpirationTime(now + 540)
.sign(key);
Installation tokens
curl -X POST -H "Authorization: Bearer $APP_JWT" \
https://api.upstream.codes/api/v3/app/installations/$INSTALLATION_ID/access_tokens
{
"token": "uis_…",
"expires_at": "2026-11-09T19:04:11.000Z",
"permissions": { "contents": "read", "issues": "write" },
"repository_selection": "selected"
}
Installation tokens start with uis_ and last an hour. They reach only the installation's repositories, with the permissions the owner approved. Use one as a bearer token for the API, or as the password for Git over HTTPS.
Narrow a token with a JSON body:
{ "repositories": ["api"], "permissions": { "issues": "write" } }
repository_ids takes repository IDs instead of names. permissions takes GitHub names with read or write, or a list of Upstream scopes. Asking for more than the installation has returns 403. To revoke a token early, call DELETE /installation/token with it.
Without Octokit
The whole exchange is three HTTP calls. Everything here uses the GitHub-compatible API, https://api.upstream.codes/api/v3, which answers in GitHub's shapes with no data wrapper.
- Sign an app JWT with the private key (above).
- Find the installation:
GET /api/v3/app/installationswith the JWT. - Trade the JWT for an installation token:
POST /api/v3/app/installations/{id}/access_tokens. - Call the API as the bot with
Authorization: Bearer uis_…. Get a new token before the hour is up.
With curl, once APP_JWT holds a signed JWT:
API=https://api.upstream.codes/api/v3
# 2. Installations, as the app. Each has a numeric id and the account it's on.
curl -H "Authorization: Bearer $APP_JWT" $API/app/installations
# [ { "id": 81236571, "account": { "login": "acme", "type": "Organization", … }, "repository_selection": "selected", … } ]
# 3. An installation token
curl -X POST -H "Authorization: Bearer $APP_JWT" $API/app/installations/81236571/access_tokens
# { "token": "uis_…", "expires_at": "…", "permissions": { … }, "repository_selection": "selected" }
# 4. Act as the bot
curl -X POST -H "Authorization: Bearer uis_…" -H "Content-Type: application/json" \
-d '{"body":"Thanks for the issue!"}' \
$API/repos/acme/api/issues/1/comments
The same in Node 22 with no dependencies:
import { createSign } from "node:crypto";
import { readFileSync } from "node:fs";
const API = "https://api.upstream.codes/api/v3";
const appId = process.env.UPSTREAM_APP_ID; // the app's UUID, numeric ID or client ID
const privateKey = readFileSync(process.env.UPSTREAM_PRIVATE_KEY_PATH ?? "private-key.pem", "utf8");
function appJwt() {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ iss: appId, iat: now - 30, exp: now + 540 })}`;
const signature = createSign("RSA-SHA256").update(unsigned).sign(privateKey, "base64url");
return `${unsigned}.${signature}`;
}
async function call(path, token, init = {}) {
const response = await fetch(`${API}${path}`, {
...init,
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "content-type": "application/json", ...init.headers },
});
if (!response.ok) throw new Error(`${init.method ?? "GET"} ${path}: ${response.status} ${await response.text()}`);
return response.status === 204 ? null : response.json();
}
const jwt = appJwt();
const installations = await call("/app/installations", jwt);
const installation = installations.find((each) => each.account?.login === "acme");
const { token } = await call(`/app/installations/${installation.id}/access_tokens`, jwt, { method: "POST" });
await call("/repos/acme/api/issues/1/comments", token, {
method: "POST",
body: JSON.stringify({ body: "Thanks for the issue!" }),
});
To find the installation for one repository instead, call GET /repos/{owner}/{repo}/installation with the JWT.
When it fails
Every JWT problem returns 401 with TOKEN_INVALID and the message "Bad credentials". Check these:
issnames an app that exists and isn't suspended.- The key that signed it is active, and
algmatches the key's type. expis no more than 10 minutes afteriat, and your clock is within 30 seconds of the real time.- The header and claims have no extra fields.