Home

madison / open-git

public
Overview Code History Branches Pull requestsIssuesInsights Campfire Docs
main
HomeOverview Code PRsIssues

Docs

Markdown docs from docs.

DocsAPI
Pages
Bot marketplace (moved)Dashboard list "table" patternGitHub two-way syncHow install worksUninstall, unpublish, and billingSign in with open-gitCreate a botSDK and a working exampleReceive webhooksOrganizations and Repository RBACUI Component Audit
Receive webhooks

Receive webhooks

open-git POSTs JSON to the webhook URL on your bot. Verify the signature, then act.

Events you can subscribe to

On the bot form, pick which pull-request and release events we POST. Installation events (installation.created, installation.repos_changed, installation.deleted) are always delivered. Unchecked events are not. Existing bots keep every event until you change the list. Bots that already subscribed to the previous full set also receive release.published. Bots that already subscribed to pull_request.opened also receive pull_request.edited.

Event When to care
installation.created First install on an account. Store installation_id and the repository ids.
installation.repos_changed Repos were added or removed. Replace your grant list with the ids in the payload.
installation.deleted They uninstalled, or you unpublished. Stop API calls. Payload is installation_id only.
pull_request.opened New pull request. Mint a token and review / check.
pull_request.edited Title or description changed. Re-run title/body gates. Payload includes changes with the previous values.
pull_request.synchronize New commits. Re-run.
pull_request.closed Closed or merged. Clean up if you want.
pull_request.comment A person commented. Comments authored by a bot are not sent back.
release.published A version left draft. Announce the ship.

Verify every request

Headers:

Header Use
X-Open-Git-Event Full event name (installation.created, pull_request.opened, …)
X-Open-Git-Delivery Id for logs / idempotency
X-Hub-Signature-256 sha256= + HMAC-SHA256 of the raw body
User-Agent open-git-Hookshot
Content-Type application/json

Read the raw body. Parsing JSON first and re-stringifying will fail the signature. The signed body is the JSON we POST, which is your payload plus action (the part after the last dot).

import { verifyWebhookSignature } from "@opengit/sdk"

export async function POST(request: Request) {
  const rawBody = await request.text()
  const signature = request.headers.get("x-hub-signature-256") ?? ""
  const event = request.headers.get("x-open-git-event") ?? ""
  const delivery = request.headers.get("x-open-git-delivery") ?? ""

  if (
    !verifyWebhookSignature({
      body: rawBody,
      secret: process.env.OPEN_GIT_BOT_WEBHOOK_SECRET!,
      signature,
    })
  ) {
    return new Response("invalid signature", { status: 401 })
  }

  if (await db.deliveries.seen(delivery)) {
    return new Response("ok", { status: 200 })
  }

  const payload = JSON.parse(rawBody) as Record<string, unknown>
  await handleWebhook(event, payload)
  await db.deliveries.markSeen(delivery)
  return new Response("ok", { status: 200 })
}

Respond 2xx quickly. Do the API work after you verify, or on a queue. We try up to three times (immediate, then 250ms, then 1s). A 3xx redirect is a failed attempt. Delivery times out after 10 seconds.

Without the SDK:

import { createHmac, timingSafeEqual } from "node:crypto"

function verifySignature(body: string, secret: string, signature: string) {
  const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`
  const left = Buffer.from(expected)
  const right = Buffer.from(signature)
  return left.length === right.length && timingSafeEqual(left, right)
}

Payload shape

Install created / repos changed (repositories are repository UUIDs, not owner/name):

{
  "action": "created",
  "installation_id": "…",
  "repositories": ["repo-uuid-1", "repo-uuid-2"]
}

Install deleted:

{
  "action": "deleted",
  "installation_id": "…"
}

Pull request events (repository.name is the repo slug):

{
  "action": "opened",
  "installation_id": "…",
  "pull_request": {
    "id": "…",
    "number": 12,
    "author": "madison",
    "title": "Fix timeout",
    "body": "The worker hangs after 30s.",
    "head_sha": "abc123…"
  },
  "repository": {
    "id": "…",
    "name": "api",
    "owner": "acme"
  }
}

Comment events add:

{
  "action": "comment",
  "comment": {
    "id": "…",
    "author": "madison",
    "body": "Can you add a test?"
  }
}

comment.author is the commenter’s username, or null.

pull_request.author is the opener’s username (GitHub login for imported users), or null. pull_request.body is the description, or null. Title and body are on every pull-request event, including comments, so a bot can gate the PR itself rather than only later comments.

Edited events add the previous title and/or body:

{
  "action": "edited",
  "changes": {
    "title": { "from": "Fix timeout" },
    "body": { "from": "The worker hangs after 30s." }
  }
}

Only fields that changed are present. body.from may be null.

Release published:

{
  "action": "published",
  "installation_id": "…",
  "release": {
    "id": "…",
    "name": "v1.0.0",
    "tag_name": "v1.0.0",
    "url": "/acme/api/releases/tag/v1.0.0"
  },
  "repository": {
    "id": "…",
    "name": "api",
    "owner": "acme"
  }
}

action is always the part after the last dot (created, repos_changed, opened, edited, synchronize, closed, comment, published).

A typical handler

import { createInstallationClient } from "@opengit/sdk"

type WebhookPayload = {
  installation_id?: string
  repositories?: string[]
  changes?: {
    body?: { from?: string | null }
    title?: { from?: string }
  }
  comment?: { author?: string | null; body?: string; id?: string }
  pull_request?: {
    author?: string | null
    body?: string | null
    number?: number
    title?: string
    head_sha?: string | null
  }
  repository?: { name?: string; owner?: string }
}

async function handleWebhook(event: string, payload: WebhookPayload) {
  const installationId = payload.installation_id
  if (!installationId) {
    return
  }

  if (event === "installation.created" || event === "installation.repos_changed") {
    await db.installs.upsert({
      id: installationId,
      repositoryIds: payload.repositories ?? [],
    })
    return
  }

  if (event === "installation.deleted") {
    await db.installs.markDeleted(installationId)
    return
  }

  if (
    event !== "pull_request.opened" &&
    event !== "pull_request.edited" &&
    event !== "pull_request.synchronize"
  ) {
    return
  }

  const install = await db.installs.get(installationId)
  if (!install?.entitled) {
    return
  }

  const owner = payload.repository?.owner
  const repository = payload.repository?.name
  const number = payload.pull_request?.number
  const sha = payload.pull_request?.head_sha
  if (!owner || !repository || !number || !sha) {
    return
  }

  const { api } = await createInstallationClient({
    baseUrl: process.env.OPEN_GIT_URL,
    botId: process.env.OPEN_GIT_BOT_ID!,
    installationId,
    privateKey: process.env.OPEN_GIT_BOT_PRIVATE_KEY!.replace(/\\n/g, "\n"),
  })

  await api.upsertCheck(owner, repository, sha, {
    name: "your-bot",
    status: "success",
    summary: `Saw "${payload.pull_request?.title ?? "this pull request"}".`,
    detailsUrl: "https://your-app.com",
  })
  if (event === "pull_request.edited") {
    return
  }
  await api.comment(
    owner,
    repository,
    number,
    "Your bot received this pull request."
  )
}

entitled is your flag. If you have not linked a paying customer to that installation_id yet, skip the job or post a “subscribe on your-app.com” check — see lifecycle.

URL requirements

The webhook URL must be public HTTPS. Localhost, *.localhost, 0.0.0.0, and private IP literals are rejected. We do not follow redirects.

The Setup URL (browser redirect after install) can be localhost. That is a different field.

If the secret leaks, rotate it on the bot page and update your server. The new secret is 64 hex characters.