Documentation

Alpha

Authenticate 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:

MethodPathDoes
GET/appGets your app
GET/app/installationsLists every installation
GET/app/installations/{id}Gets one installation
GET/repos/{owner}/{repo}/installationGets the installation that can reach a repository
GET/orgs/{org}/installationGets the installation on an organization
GET/users/{username}/installationGets the installation on a person's account
DELETE/app/installations/{id}Removes an installation
PUT, DELETE/app/installations/{id}/suspendedSuspends or resumes an installation
POST/app/installations/{id}/access_tokensCreates an installation token
POST/app/install-receipts/verifyChecks 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

PartValue
Headeralg is RS256, EdDSA or ES256. typ: "JWT" and kid are optional. No other fields.
issYour app's UUID, its numeric ID or its client ID
iatThe current time. Set it 30 seconds early to allow for clock drift.
expAt 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.

  1. Sign an app JWT with the private key (above).
  2. Find the installation: GET /api/v3/app/installations with the JWT.
  3. Trade the JWT for an installation token: POST /api/v3/app/installations/{id}/access_tokens.
  4. 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:

  • iss names an app that exists and isn't suspended.
  • The key that signed it is active, and alg matches the key's type.
  • exp is no more than 10 minutes after iat, and your clock is within 30 seconds of the real time.
  • The header and claims have no extra fields.