Webhooks
Webhooks send HTTP POSTs to your URL when something happens in a repo (push, PR opened, issue commented, etc.). Configured at Repository → Settings → Webhooks → "Add webhook".
Configuration
- Payload URL — the HTTPS endpoint we POST to. HTTP is rejected on production instances.
- Content type —
application/json(default) orapplication/x-www-form-urlencoded. - Secret — used to HMAC-sign each delivery. We strongly recommend setting one. The secret is stored AEAD-encrypted at rest; you cannot retrieve it after creation, only replace it.
- Events — pick "Just push", "Send everything", or specific events.
- Active — toggle without deleting.
Signature verification
Each delivery includes:
X-Shithub-Event: <event-name>— e.g.,push,pull_request.X-Shithub-Delivery: <uuid>— unique per delivery (idempotent).X-Shithub-Signature-256: sha256=<hex>— HMAC-SHA256 of the raw body using your configured secret.
Always verify the signature before trusting the payload.
Constant-time comparison; never compare with ==.
Go
func verify(body []byte, sig, secret string) bool {
sig = strings.TrimPrefix(sig, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
Python
import hmac, hashlib
def verify(body: bytes, sig: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
sig = sig.removeprefix("sha256=")
return hmac.compare_digest(sig, expected)
Node.js
const crypto = require("crypto");
function verify(body, sig, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expected),
);
}
The body must be the raw request body, not the parsed JSON. Frameworks that auto-parse will give you the wrong bytes.
Idempotency
Use X-Shithub-Delivery as your idempotency key. We may retry a
delivery if your endpoint returns 5xx or times out, so processing
the same delivery twice should be safe in your system.
Retries
A delivery is retried on:
- Network error.
- 5xx response.
- Timeout (default 10s).
Retry schedule: exponential backoff with jitter, up to ~6 retries over ~24h. After 50 consecutive failures, the webhook auto- disables to stop bombarding a broken endpoint. You'll see a banner on the webhook config page; flip "Active" back on once the endpoint is fixed.
Inspecting deliveries
Webhook detail page → "Recent deliveries". Each row shows:
- Event + delivery ID + timestamp.
- Request headers + (truncated) body we sent.
- Response status + headers + (truncated) body we got back.
- "Redeliver" — re-sends the original payload with the same signature.
Stored bodies are capped at 32 KiB (your endpoint can accept bigger; we just don't keep more for the inspector).
SSRF defense
shithub validates webhook URLs server-side: hostnames are resolved, IPs are checked against a block-list (RFC1918, link- local, loopback, multicast, 169.254.0.0/16, etc.), and the request is dialed to the resolved IP — no following CNAMEs into internal address space at delivery time.
Operators of self-hosted instances can opt in to private
destinations via an AllowedHosts list — see
self-host configuration.
View source
| 1 | # Webhooks |
| 2 | |
| 3 | Webhooks send HTTP POSTs to your URL when something happens in a |
| 4 | repo (push, PR opened, issue commented, etc.). Configured at |
| 5 | Repository → Settings → Webhooks → "Add webhook". |
| 6 | |
| 7 | ## Configuration |
| 8 | |
| 9 | - **Payload URL** — the HTTPS endpoint we POST to. HTTP is |
| 10 | rejected on production instances. |
| 11 | - **Content type** — `application/json` (default) or |
| 12 | `application/x-www-form-urlencoded`. |
| 13 | - **Secret** — used to HMAC-sign each delivery. We strongly |
| 14 | recommend setting one. The secret is stored AEAD-encrypted at |
| 15 | rest; you cannot retrieve it after creation, only replace it. |
| 16 | - **Events** — pick "Just push", "Send everything", or specific |
| 17 | events. |
| 18 | - **Active** — toggle without deleting. |
| 19 | |
| 20 | ## Signature verification |
| 21 | |
| 22 | Each delivery includes: |
| 23 | |
| 24 | - `X-Shithub-Event: <event-name>` — e.g., `push`, `pull_request`. |
| 25 | - `X-Shithub-Delivery: <uuid>` — unique per delivery (idempotent). |
| 26 | - `X-Shithub-Signature-256: sha256=<hex>` — HMAC-SHA256 of the |
| 27 | raw body using your configured secret. |
| 28 | |
| 29 | **Always verify the signature before trusting the payload.** |
| 30 | Constant-time comparison; never compare with `==`. |
| 31 | |
| 32 | ### Go |
| 33 | |
| 34 | ```go |
| 35 | func verify(body []byte, sig, secret string) bool { |
| 36 | sig = strings.TrimPrefix(sig, "sha256=") |
| 37 | mac := hmac.New(sha256.New, []byte(secret)) |
| 38 | mac.Write(body) |
| 39 | expected := hex.EncodeToString(mac.Sum(nil)) |
| 40 | return hmac.Equal([]byte(sig), []byte(expected)) |
| 41 | } |
| 42 | ``` |
| 43 | |
| 44 | ### Python |
| 45 | |
| 46 | ```python |
| 47 | import hmac, hashlib |
| 48 | |
| 49 | def verify(body: bytes, sig: str, secret: str) -> bool: |
| 50 | expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() |
| 51 | sig = sig.removeprefix("sha256=") |
| 52 | return hmac.compare_digest(sig, expected) |
| 53 | ``` |
| 54 | |
| 55 | ### Node.js |
| 56 | |
| 57 | ```js |
| 58 | const crypto = require("crypto"); |
| 59 | |
| 60 | function verify(body, sig, secret) { |
| 61 | const expected = "sha256=" + crypto |
| 62 | .createHmac("sha256", secret) |
| 63 | .update(body) |
| 64 | .digest("hex"); |
| 65 | return crypto.timingSafeEqual( |
| 66 | Buffer.from(sig), |
| 67 | Buffer.from(expected), |
| 68 | ); |
| 69 | } |
| 70 | ``` |
| 71 | |
| 72 | The body must be the **raw request body**, not the parsed JSON. |
| 73 | Frameworks that auto-parse will give you the wrong bytes. |
| 74 | |
| 75 | ## Idempotency |
| 76 | |
| 77 | Use `X-Shithub-Delivery` as your idempotency key. We may retry a |
| 78 | delivery if your endpoint returns 5xx or times out, so processing |
| 79 | the same delivery twice should be safe in your system. |
| 80 | |
| 81 | ## Retries |
| 82 | |
| 83 | A delivery is retried on: |
| 84 | |
| 85 | - Network error. |
| 86 | - 5xx response. |
| 87 | - Timeout (default 10s). |
| 88 | |
| 89 | Retry schedule: exponential backoff with jitter, up to ~6 retries |
| 90 | over ~24h. After 50 consecutive failures, the webhook **auto- |
| 91 | disables** to stop bombarding a broken endpoint. You'll see a |
| 92 | banner on the webhook config page; flip "Active" back on once |
| 93 | the endpoint is fixed. |
| 94 | |
| 95 | ## Inspecting deliveries |
| 96 | |
| 97 | Webhook detail page → "Recent deliveries". Each row shows: |
| 98 | |
| 99 | - Event + delivery ID + timestamp. |
| 100 | - Request headers + (truncated) body we sent. |
| 101 | - Response status + headers + (truncated) body we got back. |
| 102 | - "Redeliver" — re-sends the original payload with the same |
| 103 | signature. |
| 104 | |
| 105 | Stored bodies are capped at 32 KiB (your endpoint can accept |
| 106 | bigger; we just don't keep more for the inspector). |
| 107 | |
| 108 | ## SSRF defense |
| 109 | |
| 110 | shithub validates webhook URLs server-side: hostnames are |
| 111 | resolved, IPs are checked against a block-list (RFC1918, link- |
| 112 | local, loopback, multicast, 169.254.0.0/16, etc.), and the |
| 113 | request is dialed to the resolved IP — no following CNAMEs into |
| 114 | internal address space at delivery time. |
| 115 | |
| 116 | Operators of self-hosted instances can opt in to private |
| 117 | destinations via an `AllowedHosts` list — see |
| 118 | [self-host configuration](../self-host/configuration.md). |