Documentation
AlphaApp webhooks
Receive events from every repository your app is installed on, verify them two ways, and forward them 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.
Set a webhook URL and events on your app. It receives events from every repository it's installed on, signed with your webhook secret.
Pick a payload format when you set it up. The GitHub format uses GitHub's event names, headers and payloads, so Octokit's webhook code works unchanged. The Upstream format uses dotted event names such as issue.opened and change_request.merged.
Headers
| GitHub format | Upstream format | |
|---|---|---|
| Event name | X-GitHub-Event | X-Upstream-Event |
| Delivery ID | X-GitHub-Delivery | X-Upstream-Delivery |
| Hook ID | X-GitHub-Hook-ID | X-Upstream-Hook-Id |
| HMAC signature | X-Hub-Signature-256, and SHA-1 in X-Hub-Signature | X-Upstream-Signature-256 |
| Signed token | X-Upstream-Webhook-Token | X-Upstream-Webhook-Token |
GitHub-format deliveries also carry X-GitHub-Hook-Installation-Target-Type: integration and X-GitHub-Hook-Installation-Target-ID with your app's numeric ID. Payloads include installation.id.
Check the HMAC signature
The signature is sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your app's webhook secret.
Keep the secret in UPSTREAM_WEBHOOK_SECRET. upstream app create writes it to .env and the starter reads it from there. It starts with uhs_ and is shown only once. To replace it, set a new secret in your app's settings in Your apps, or call PATCH /app/hook/config, then update UPSTREAM_WEBHOOK_SECRET.
With 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" }),
});
const ok = await app.webhooks.verify(rawBody, request.headers["x-hub-signature-256"]);
Without a library:
import { createHmac, timingSafeEqual } from "node:crypto";
function validSignature(rawBody: Buffer, header: string, secret: string) {
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
Hash the bytes you received, before you parse the JSON.
Check the webhook token
Every delivery to an app also carries X-Upstream-Webhook-Token, an EdDSA JWT that Upstream signs. You check it with the public key set at https://upstream.codes/.well-known/jwks.json, so a server that only receives webhooks doesn't need the webhook secret.
| Claim | Value |
|---|---|
iss | https://upstream.codes |
aud | urn:upstream:app:{slug} |
jti | The delivery ID, the same as X-GitHub-Delivery or X-Upstream-Delivery |
body_sha256 | The base64url SHA-256 of the raw body |
event | The event name |
installation_id | The installation's numeric ID. Missing on installation and installation_repositories events. |
iat, exp | When Upstream sent it, and 5 minutes later |
The header has typ: upstream-webhook+jwt.
import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://upstream.codes/.well-known/jwks.json"));
export async function checkDelivery(rawBody: Buffer, headers: Headers) {
const token = headers.get("x-upstream-webhook-token");
if (!token) throw new Error("Missing X-Upstream-Webhook-Token.");
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://upstream.codes",
audience: "urn:upstream:app:my-bot",
typ: "upstream-webhook+jwt",
algorithms: ["EdDSA"],
});
if (payload.body_sha256 !== createHash("sha256").update(rawBody).digest("base64url"))
throw new Error("The body doesn't match the token.");
const deliveryId = headers.get("x-github-delivery") ?? headers.get("x-upstream-delivery");
if (payload.jti !== deliveryId) throw new Error("The token belongs to another delivery.");
return payload;
}
Each attempt gets a new token, so retries pass. A relay keeps the token it received, so a delivery forwarded more than 5 minutes later fails the expiry check. Use the HMAC signature in local development.
Events every app gets
Your app gets these events whatever events you chose. They go only to your app, never to repository or organization webhooks:
| Event | Actions | Sent when |
|---|---|---|
installation | created, deleted, suspend, unsuspend, new_permissions_accepted | Someone installs, removes, suspends or resumes your app, or approves permissions you added. Includes the installation, its account and the repositories it can reach. |
installation_repositories | added, removed | The owner adds or removes repositories, or switches between all and selected repositories. Includes repository_selection, repositories_added and repositories_removed. |
In the Upstream format these are installation.created, installation_repositories.added and so on.
You can subscribe to these GitHub-format events: push, create, delete, repository, fork, member, issues, issue_comment, pull_request, pull_request_review, pull_request_review_comment, release, check_run, check_suite, workflow_run, workflow_job and ping. See Run CI from your app for the check events.
Deliveries
GET /app/hook/deliveries lists recent deliveries with their status. GET /app/hook/deliveries/{id} shows one with its request and response, and POST /app/hook/deliveries/{id}/attempts sends it again. GET and PATCH /app/hook/config read and change the webhook URL and secret. All of them take the app JWT.
Local development
A relay is a webhook URL on Upstream that stores deliveries instead of sending them. upstream webhook forward reads them and posts each one to a server on your computer with its original headers and body, so signature checks pass unchanged. No tunnel is needed.
upstream webhook forward --app my-bot
This points my-bot's webhook at your relay for that app and forwards deliveries to http://localhost:3000/api/webhook. Each delivery prints one line as it arrives, also when the output goes to a file or a pipe:
14:02:11 issues.opened 200 84 ms
You get the same relay every time you forward the same app, so a restart picks up where the last run stopped: nothing is repeated, and nothing that arrived in between is lost.
Where deliveries go while nothing is forwarding depends on what the webhook pointed at before:
- Nothing else, or already a relay (as after
upstream app create, orPOST /v1/appswithwebhookRelay: true): the webhook stays on the relay. Deliveries wait there and are sent the next time you start. - A server of yours: Ctrl+C puts that URL back, so deliveries go to your server again until you forward next. Add
--keepto leave the webhook on the relay instead, so they wait for you. Then set the URL back in the app's settings when you're done.
A delivery that failed at the old URL and is waiting to retry goes to the new URL as soon as the webhook changes, instead of after its retry delay.
| Flag | Does |
|---|---|
--app my-bot | Points the app's webhook at your relay for it and forwards it |
--keep | With --app: leaves the webhook on the relay when you stop |
--relay whr_… | Forwards a relay you already have, by ID or URL |
--port 8080, --path /webhooks | Changes the local target. The defaults are port 3000 and /api/webhook. |
--to http://localhost:3000/api/github/webhooks | Sets the whole local URL |
Without --app or --relay, it uses UPSTREAM_WEBHOOK_RELAY from the environment or .env, which upstream app create writes. The CLI remembers the last delivery it sent from each relay, so a restart doesn't repeat ones already sent. The stable relay for --app and --keep need CLI 0.8.2 or newer.
- A relay lasts 30 days after it was last read.
- It keeps one day of deliveries, up to the last 500.
- You can have 20 live relays. A twenty-first returns
409 WEBHOOK_RELAY_LIMIT. Delete one or let an unused one expire. - Only the person who made it can read it, and only they can save its URL as an app or repository webhook URL.
- Deliveries to an expired relay show
410in the delivery log.
Relay API
The CLI uses these native endpoints. They take a personal access token with webhooks:admin, or a CLI session.
| Method | Path | Does |
|---|---|---|
POST | /v1/webhook-relays | Creates a relay. With { "app": "my-bot" }, points that app's webhook at your relay for it (the one it points at, else the one from your last run, else a new one) and turns the webhook on. |
GET | /v1/webhook-relays/{id}/events?after={cursor}&wait=20 | Lists deliveries after a cursor. wait holds the request open up to 20 seconds until one arrives. |
POST | /v1/webhook-relays/{id}/detach | Stops forwarding: an app the relay borrowed gets previousWebhookUrl back. The relay, its ID and its deliveries stay for next time. |
DELETE | /v1/webhook-relays/{id} | Ends the relay and drops its deliveries. An app it was attached to gets its previous webhook URL back. |
curl -X POST https://api.upstream.codes/v1/webhook-relays \
-H "Authorization: Bearer $UPSTREAM_TOKEN" \
-H "Content-Type: application/json" \
-d '{"app":"my-bot"}'
{
"data": {
"id": "whr_…",
"url": "https://api.upstream.codes/v1/webhook-relays/whr_…",
"appSlug": "my-bot",
"previousWebhookUrl": "https://example.com/webhook",
"expiresAt": "2026-12-09T18:00:00.000Z"
}
}
previousWebhookUrl is what the app goes back to on detach; null means it stays on the relay.
curl "https://api.upstream.codes/v1/webhook-relays/whr_…/events?wait=20" \
-H "Authorization: Bearer $UPSTREAM_TOKEN"
{
"data": {
"events": [
{
"id": "4182",
"deliveryId": "5f0c2b7e-…",
"event": "issues",
"headers": { "Content-Type": "application/json", "X-GitHub-Event": "issues", "X-Hub-Signature-256": "sha256=…" },
"body": "{\"action\":\"opened\",…}",
"receivedAt": "2026-11-09T19:44:33.386Z"
}
],
"cursor": "4182"
}
}
Each event has id, which is its cursor, plus deliveryId, event, headers, body and receivedAt. Send body exactly as given, with headers, to replay it. Pass the last cursor as after on the next call.