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.