Documentation

Alpha

App quickstart

Create an app with upstream app create and run it with Octokit, with webhooks forwarded to your computer.

The Upstream API is in alpha. Endpoints, fields and limits can change before it's stable. Send feedback to support@coline.app.

Needs CLI 0.8.1 or newer. Run upstream --version first. If it's older, run upstream update. Signs of an older CLI: unknown command "app" (before 0.8.0), unknown flag: --owner, --repo, --all or --approve on upstream app install, or upstream app create printing a URL and waiting for a browser even with --no-browser (0.8.0).

upstream app create makes an app and a Node project that runs it. The project uses Octokit, so it's the same code you'd write for a GitHub App. Webhooks reach your computer through a relay on Upstream, with no tunnel.

You need Node 22.9 or newer and the Upstream CLI 0.8.1 or newer. Creating and installing apps without a browser came in 0.8.1.

1. Sign in

upstream auth login

When it works, it prints:

Logged in to upstream.codes as your-handle.

The CLI uses your session to create the app, install it and set up its webhook relay. On a machine where you can't sign in through a browser, set UPSTREAM_TOKEN to a personal access token with the Apps permission (apps:write) and Webhooks (webhooks:admin) instead. It must cover your whole account, not chosen repositories.

2. Create the app

upstream app create my-bot

With a browser, the CLI opens upstream.codes with the app's manifest. Pick the owner and choose Create app. The browser comes back to the CLI on 127.0.0.1 with a one-time code, and the CLI trades it for the app's credentials.

When it works, it prints:

✓ Created my-bot (my-bot)
  Install  https://upstream.codes/apps/my-bot/installations/new
  Folder   my-bot

Next:
  cd my-bot
  npm install
  npm run dev

If the slug is taken, Upstream picks a free one, such as my-bot-3f9a1c. The line in parentheses is the slug it got.

From an agent or a server

Agents, SSH sessions, CI and servers have no browser. There the CLI creates the app directly with your sign-in, with no browser step. Pass --no-browser to do this anywhere:

upstream app create my-bot --no-browser --owner acme
Creating the app with your sign-in (no browser)…
✓ Created my-bot (my-bot)
  Owner    acme
  Install  https://upstream.codes/apps/my-bot/installations/new
  Folder   my-bot

Next:
  cd my-bot
  npm install
  upstream app install my-bot --owner acme --repo <name>   # or --all
  npm run dev

Without --owner, you own the app. --owner must be an organization you own. The CLI picks this path on its own when no browser can open: no display on Linux, an SSH session, CI set, or input that isn't a terminal. It writes the same project, .env and private-key.pem, and sets up the same webhook relay.

The whole path for an agent, from nothing to a working app, with no browser:

upstream auth login            # once; or set UPSTREAM_TOKEN
upstream app create my-bot --no-browser --owner acme
cd my-bot && npm install
upstream app install my-bot --owner acme --repo api
npm run dev                    # leave it running
upstream issue create --repo acme/api --title "Hello" --body "Testing my-bot"   # the bot comments on it

Add --json for output a program can read:

{
  "id": "0f6c1a52-…",
  "slug": "my-bot",
  "name": "my-bot",
  "clientId": "uoa_…",
  "htmlUrl": "https://upstream.codes/apps/my-bot",
  "owner": "acme",
  "installUrl": "https://upstream.codes/apps/my-bot/installations/new",
  "permissions": { "metadata": "read", "contents": "read", "issues": "write", "pull_requests": "write", "checks": "write" },
  "events": ["issues", "pull_request"],
  "webhookRelay": "https://api.upstream.codes/v1/webhook-relays/whr_…",
  "directory": "/home/you/my-bot",
  "files": ["server.js", "dev.js", "package.json", "…", ".env", "private-key.pem"]
}

Secrets stay out of the JSON unless you add --show-secrets; they're in .env and private-key.pem. Without the CLI, call POST /v1/apps yourself.

3. Run it

cd my-bot
npm install
npm run dev

npm run dev starts server.js on port 3000 and restarts it when you edit it. It also runs upstream webhook forward, which sends each delivery to http://localhost:3000/api/webhook. When it works, it prints:

Listening on http://localhost:3000
Forwarding https://api.upstream.codes/v1/webhook-relays/whr_… to http://localhost:3000/api/webhook
Press Ctrl+C to stop.

Each delivery then prints a line with its event and your server's answer:

14:02:11  issues.opened                    200  84 ms

4. Install it

upstream app install

This opens the install page for the app named in .env. Pick a repository and approve. Then open an Issue in that repository and the bot thanks you in a comment. Open a Change Request and it adds a passing check called Hello.

To install from the terminal instead, name the repositories, or pass --all:

upstream app install my-bot --owner acme --repo api --repo web
✓ Installed my-bot on acme
  Installation  81236571 (5d0c1f7e-…)
  Repositories  acme/api, acme/web
  Permissions   repo:read, issues:read, issues:write, changes:read, changes:write, checks:write
  Events        issues, pull_request

With --repo, --all or --approve, or with no browser (an agent, SSH, CI) or --no-browser, the CLI installs directly with your sign-in. It's the same install as the browser's: you must own the namespace, organizations that require SSO need your session authorized for them, and the app's installation webhook fires. Without --owner, it installs on your own namespace.

Run it again to change the repositories: --repo and --all replace the current selection. When an app asks for more permissions later, upstream app install my-bot --owner acme --approve grants them and keeps the repositories as they are.

FlagDoes
--owner acmeThe namespace to install on: you or an organization you own. The default is you.
--repo apiA repository to install on, by name or ID. Repeat it, or use commas, for more.
--allInstalls on every repository in the namespace, including new ones.
--approveGrants the permissions the app asked for since it was installed.
--no-browserInstalls with your sign-in instead of opening the install page.
--jsonPrints the installation as JSON, as POST /v1/user/installations returns it.

Which API URL to use

ForBase URL
Octokit and other GitHub-compatible codehttps://api.upstream.codes/api/v3
Upstream's native API (/v1)https://api.upstream.codes/v1

The native API wraps every answer: the object is under data, as in { "data": { "id": "…", "slug": "my-bot" } }, and errors come as { "error": { "code": "…", "message": "…" } }. Read response.data.slug, not response.slug. The GitHub-compatible API returns GitHub's shapes with no wrapper.

The starter reads UPSTREAM_API_URL and passes it to Octokit as baseUrl. Set it to https://api.upstream.codes/api/v3; that's the default when it's empty. It reads UPSTREAM_WEBHOOK_SECRET to check each webhook's signature, UPSTREAM_APP_ID and UPSTREAM_PRIVATE_KEY_PATH to sign in as the app, and PORT, which defaults to 3000.

Create an app over the API

POST /v1/apps creates an app from a manifest with your own token. It's what upstream app create --no-browser calls. The token needs apps:write, and upstream auth login grants it. A personal access token needs the Apps permission and must cover your whole account, not chosen repositories.

curl -X POST https://api.upstream.codes/v1/apps \
  -H "Authorization: Bearer $UPSTREAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "owner": "acme",
    "manifest": {
      "name": "my-bot",
      "callback_urls": ["http://localhost:3000/oauth/callback"],
      "hook_attributes": { "url": "https://my-bot.example.com/api/webhook" },
      "default_permissions": { "issues": "write", "contents": "read" },
      "default_events": ["issues"]
    }
  }'

It returns 201 with the app's credentials, once:

{
  "data": {
    "id": "0f6c1a52-…",
    "slug": "my-bot",
    "name": "my-bot",
    "owner": "acme",
    "clientId": "uoa_…",
    "clientSecret": "uoc_…",
    "webhookSecret": "uhs_…",
    "pem": "-----BEGIN PRIVATE KEY-----\n…",
    "permissions": ["repo:read", "issues:read", "issues:write"],
    "events": ["issues"],
    "webhookUrl": "https://my-bot.example.com/api/webhook",
    "webhookRelay": null,
    "htmlUrl": "https://upstream.codes/apps/my-bot",
    "installUrl": "https://upstream.codes/apps/my-bot/installations/new"
  }
}

Like every native /v1 answer, the app is under data. Save pem, webhookSecret and clientSecret now: they aren't shown again.

owner is optional and defaults to you. It must be a namespace you own. The manifest is checked exactly as in the browser flow; redirect_url isn't used here. A person can create up to 20 apps an hour, in the browser and over the API together, and a namespace can own up to 100.

Webhooks on your computer

Upstream sends webhooks only to a public HTTPS URL, so http://localhost:3000/api/webhook is refused. Use a relay instead: leave out hook_attributes.url and add "webhookRelay": true.

curl -X POST https://api.upstream.codes/v1/apps \
  -H "Authorization: Bearer $UPSTREAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookRelay": true,
    "manifest": {
      "name": "my-bot",
      "callback_urls": ["http://localhost:3000/oauth/callback"],
      "default_permissions": { "issues": "write" },
      "default_events": ["issues"]
    }
  }'

The app's webhook then points at a new relay you own, and the answer names it:

{
  "data": {
    "id": "0f6c1a52-…",
    "slug": "my-bot",
    "…": "…",
    "webhookUrl": "https://api.upstream.codes/v1/webhook-relays/whr_…",
    "webhookRelay": {
      "id": "whr_…",
      "url": "https://api.upstream.codes/v1/webhook-relays/whr_…",
      "expiresAt": "2026-12-09T19:44:32.000Z"
    }
  }
}

Forward it to your server, which checks signatures with webhookSecret as usual:

upstream webhook forward --relay whr_… --to http://localhost:3000/api/webhook

The relay keeps deliveries that arrive while nothing is forwarding, so they're sent when you start. Reading a relay needs webhooks:admin, which upstream auth login grants; a personal access token needs the Webhooks permission too. Without the CLI, read it with the relay API. When you deploy, set the app's webhook URL to your server.

Webhook URL rules

A webhook URL (hook_attributes.url) must:

  • use https://;
  • resolve in DNS when you save it, to a public address (not localhost, a private network or a link-local address);
  • have no user name or password in it.

Upstream checks it when the app is created, so a host that doesn't exist yet is refused. Relay URLs are the one exception: a live relay of yours is always accepted.

StatusCodeMeans
400MANIFEST_INVALIDA manifest field is wrong; the message names it. Also sent for webhookRelay together with hook_attributes.url.
400OUTBOUND_URL_HTTPS_REQUIREDThe webhook URL isn't HTTPS, e.g. http://localhost…. Use webhookRelay: true.
400OUTBOUND_URL_DNS_FAILEDThe webhook URL's host doesn't resolve.
400OUTBOUND_URL_PRIVATE_HOSTThe host resolves to a private, loopback or link-local address.
400OUTBOUND_URL_CREDENTIALS_FORBIDDENThe URL has a user name or password.
400WEBHOOK_RELAY_INVALIDA relay URL that's unknown, expired or not yours.
400APP_OWNER_REQUIREDYou own several namespaces and none is personal. Pass owner.
403TOKEN_SCOPE_REQUIREDThe token doesn't have apps:write.
403TOKEN_REPOSITORY_FORBIDDENThe token covers only some repositories.
404APP_OWNER_NOT_FOUNDYou don't own that namespace. The message lists the ones you do.
409WEBHOOK_RELAY_LIMITYou have 20 live relays. Delete one first.
429APP_CREATION_RATE_LIMITEDMore than 20 apps this hour.

Install an app over the API

POST /v1/user/installations installs an app on a namespace you own with your own token. It's what upstream app install calls without a browser. It needs the same apps:write scope, and a personal access token must cover your whole account.

curl -X POST https://api.upstream.codes/v1/user/installations \
  -H "Authorization: Bearer $UPSTREAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "app": "my-bot", "owner": "acme", "repositories": ["api", "web"] }'
FieldValue
appThe app's slug or ID
ownerThe namespace to install on, which you must own. Optional; the default is yours.
alltrue to install on every repository in the namespace, including new ones
repositoriesRepository names (api or acme/api) or IDs. Use this or all.
approvePermissionstrue to grant the permissions the app asked for since it was installed
statePassed to the app's setup URL, as on the install link

It returns 201 for a new installation and 200 when the app was already installed there:

{
  "data": {
    "id": "5d0c1f7e-…",
    "numericId": 81236571,
    "created": true,
    "app": { "id": "0f6c1a52-…", "slug": "my-bot", "name": "my-bot" },
    "owner": "acme",
    "workspaceId": "9b2e…",
    "repositorySelection": "selected",
    "repositories": [
      { "id": "1c7d…", "name": "api", "fullName": "acme/api" },
      { "id": "4e90…", "name": "web", "fullName": "acme/web" }
    ],
    "permissions": ["repo:read", "issues:read", "issues:write"],
    "events": ["issues"],
    "pendingPermissions": null,
    "pendingEvents": null,
    "approvedPermissions": false,
    "suspendedAt": null,
    "setup": null,
    "htmlUrl": "https://upstream.codes/apps/my-bot/installations/new?target=9b2e…"
  }
}

numericId is the installation ID the GitHub-compatible API and Octokit use. repositories is null when the app has every repository. When the app has a setup URL, setup holds it with the installation_id, setup_action, state and signed receipt the browser would have carried, and setup.receipt on its own; see Install without a browser.

On an existing installation, all or repositories replace the selection; leave both out to keep it. The app keeps its current permissions until you send approvePermissions: true; until then the new ones show in pendingPermissions and pendingEvents. A person can make up to 60 installs and changes an hour over the API.

StatusCodeMeans
400INSTALL_REPOSITORIES_REQUIREDA new installation needs all or repositories.
403TOKEN_SCOPE_REQUIREDThe token doesn't have apps:write.
403TOKEN_REPOSITORY_FORBIDDENThe token covers only some repositories.
403ORGANIZATION_SSO_REQUIREDThe organization requires SSO, and this token or session isn't authorized for it.
404APP_NOT_FOUNDNo such app, it's suspended, or it's private to a namespace you don't own.
404INSTALL_TARGET_NOT_FOUNDYou don't own that namespace. The message lists the ones you do.
404INSTALL_REPOSITORY_NOT_FOUNDA repository isn't in that namespace. The message names it.
429APP_INSTALL_RATE_LIMITEDToo many installs this hour.

What it writes

FileHolds
server.jsThe webhook handlers, written with Octokit's App
dev.jsRuns the server and upstream webhook forward together
package.jsonnpm run dev, npm start and octokit, the only dependency
.envUPSTREAM_APP_ID, UPSTREAM_APP_SLUG, UPSTREAM_CLIENT_ID, UPSTREAM_CLIENT_SECRET, UPSTREAM_WEBHOOK_SECRET, UPSTREAM_PRIVATE_KEY_PATH, UPSTREAM_API_URL, UPSTREAM_WEBHOOK_RELAY and PORT
private-key.pemThe app's RSA private key
.env.example, .gitignore, README.mdA blank .env, Git ignores and run notes

.env and private-key.pem are readable only by you, and Git ignores both.

Here is the core of server.js:

import { readFileSync } from "node:fs";
import { App, Octokit } from "octokit";

const app = new App({
  appId: process.env.UPSTREAM_APP_ID,
  privateKey: readFileSync(process.env.UPSTREAM_PRIVATE_KEY_PATH, "utf8"),
  webhooks: { secret: process.env.UPSTREAM_WEBHOOK_SECRET },
  Octokit: Octokit.defaults({ baseUrl: "https://api.upstream.codes/api/v3" }),
});

app.webhooks.on("issues.opened", async ({ octokit, payload }) => {
  await octokit.rest.issues.createComment({
    owner: payload.repository.owner.login,
    repo: payload.repository.name,
    issue_number: payload.issue.number,
    body: `Thanks for opening this, @${payload.issue.user.login}.`,
  });
});

The octokit each handler gets is already signed in as the installation that sent the event. Add handlers with app.webhooks.on("push", ...). Octokit's docs apply as written.

Options

FlagDoes
--permissions issues=write,contents=readSets permissions with GitHub names. The default is metadata=read, contents=read, issues=write, pull_requests=write and checks=write.
--events issues,pushSets webhook events. The default is issues and pull_request.
--owner acmePicks the organization that owns the app. Without it, you pick in the browser, or you own it when there's no browser.
--publicLets anyone install the app. By default only the owner can.
--format nativeSends webhooks in Upstream's format instead of GitHub's.
--description, --urlSets the app's description and homepage.
--dir pathWrites the project to another folder. The default is a new folder named after the app.
--no-templateWrites only .env and private-key.pem, into the current folder.
--no-webhookCreates the app without a webhook or relay.
--no-browserCreates the app with your sign-in, without the browser review. See From an agent or a server.
--jsonPrints the result as JSON. Secrets stay out unless you add --show-secrets.

upstream app open opens your apps' settings.

Deploy

Run npm start on any host with Node 22. Set the variables from .env there and put private-key.pem next to server.js, or point UPSTREAM_PRIVATE_KEY_PATH at it. Keep UPSTREAM_API_URL=https://api.upstream.codes/api/v3. Then set the app's webhook URL to https://your-host/api/webhook in Your apps.