Documentation
AlphaApp 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 --versionfirst. If it's older, runupstream update. Signs of an older CLI:unknown command "app"(before 0.8.0),unknown flag: --owner,--repo,--allor--approveonupstream app install, orupstream app createprinting 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.
| Flag | Does |
|---|---|
--owner acme | The namespace to install on: you or an organization you own. The default is you. |
--repo api | A repository to install on, by name or ID. Repeat it, or use commas, for more. |
--all | Installs on every repository in the namespace, including new ones. |
--approve | Grants the permissions the app asked for since it was installed. |
--no-browser | Installs with your sign-in instead of opening the install page. |
--json | Prints the installation as JSON, as POST /v1/user/installations returns it. |
Which API URL to use
| For | Base URL |
|---|---|
| Octokit and other GitHub-compatible code | https://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.
| Status | Code | Means |
|---|---|---|
400 | MANIFEST_INVALID | A manifest field is wrong; the message names it. Also sent for webhookRelay together with hook_attributes.url. |
400 | OUTBOUND_URL_HTTPS_REQUIRED | The webhook URL isn't HTTPS, e.g. http://localhost…. Use webhookRelay: true. |
400 | OUTBOUND_URL_DNS_FAILED | The webhook URL's host doesn't resolve. |
400 | OUTBOUND_URL_PRIVATE_HOST | The host resolves to a private, loopback or link-local address. |
400 | OUTBOUND_URL_CREDENTIALS_FORBIDDEN | The URL has a user name or password. |
400 | WEBHOOK_RELAY_INVALID | A relay URL that's unknown, expired or not yours. |
400 | APP_OWNER_REQUIRED | You own several namespaces and none is personal. Pass owner. |
403 | TOKEN_SCOPE_REQUIRED | The token doesn't have apps:write. |
403 | TOKEN_REPOSITORY_FORBIDDEN | The token covers only some repositories. |
404 | APP_OWNER_NOT_FOUND | You don't own that namespace. The message lists the ones you do. |
409 | WEBHOOK_RELAY_LIMIT | You have 20 live relays. Delete one first. |
429 | APP_CREATION_RATE_LIMITED | More 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"] }'
| Field | Value |
|---|---|
app | The app's slug or ID |
owner | The namespace to install on, which you must own. Optional; the default is yours. |
all | true to install on every repository in the namespace, including new ones |
repositories | Repository names (api or acme/api) or IDs. Use this or all. |
approvePermissions | true to grant the permissions the app asked for since it was installed |
state | Passed 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.
| Status | Code | Means |
|---|---|---|
400 | INSTALL_REPOSITORIES_REQUIRED | A new installation needs all or repositories. |
403 | TOKEN_SCOPE_REQUIRED | The token doesn't have apps:write. |
403 | TOKEN_REPOSITORY_FORBIDDEN | The token covers only some repositories. |
403 | ORGANIZATION_SSO_REQUIRED | The organization requires SSO, and this token or session isn't authorized for it. |
404 | APP_NOT_FOUND | No such app, it's suspended, or it's private to a namespace you don't own. |
404 | INSTALL_TARGET_NOT_FOUND | You don't own that namespace. The message lists the ones you do. |
404 | INSTALL_REPOSITORY_NOT_FOUND | A repository isn't in that namespace. The message names it. |
429 | APP_INSTALL_RATE_LIMITED | Too many installs this hour. |
What it writes
| File | Holds |
|---|---|
server.js | The webhook handlers, written with Octokit's App |
dev.js | Runs the server and upstream webhook forward together |
package.json | npm run dev, npm start and octokit, the only dependency |
.env | UPSTREAM_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.pem | The app's RSA private key |
.env.example, .gitignore, README.md | A 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
| Flag | Does |
|---|---|
--permissions issues=write,contents=read | Sets permissions with GitHub names. The default is metadata=read, contents=read, issues=write, pull_requests=write and checks=write. |
--events issues,push | Sets webhook events. The default is issues and pull_request. |
--owner acme | Picks the organization that owns the app. Without it, you pick in the browser, or you own it when there's no browser. |
--public | Lets anyone install the app. By default only the owner can. |
--format native | Sends webhooks in Upstream's format instead of GitHub's. |
--description, --url | Sets the app's description and homepage. |
--dir path | Writes the project to another folder. The default is a new folder named after the app. |
--no-template | Writes only .env and private-key.pem, into the current folder. |
--no-webhook | Creates the app without a webhook or relay. |
--no-browser | Creates the app with your sign-in, without the browser review. See From an agent or a server. |
--json | Prints 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.