Webhooks
Webhooks deliver activity events (pushes, reviews, comments, check-outs, downloads) to an HTTPS endpoint as they happen. Each endpoint can receive every event or a chosen subset, as signed JSON or as Slack-formatted messages. All webhook endpoints are Admin only.
| Method and path | Scope | Purpose |
|---|---|---|
GET /admin/webhooks |
read | List endpoints |
POST /admin/webhooks |
write | Register an endpoint |
PATCH /admin/webhooks/{webhook_id} |
write | Change, pause or rotate the secret |
DELETE /admin/webhooks/{webhook_id} |
write | Remove an endpoint and its delivery history |
POST /admin/webhooks/{webhook_id}/test |
write | Send a test event now |
GET /admin/webhooks/{webhook_id}/deliveries |
read | Recent delivery attempts |
POST /admin/webhooks
Section titled “POST /admin/webhooks”curl -X POST -H "Authorization: Bearer $PH_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/prophouse", "events": ["push.commit", "review.change"]}' \ https://api.prophouse.dev/api/v1/admin/webhooks| Field | Type | Description |
|---|---|---|
url |
string, required | https:// URL on a public address (up to 2,000 characters). Private and internal addresses are rejected (422). |
secret |
string | 8 to 200 characters. Omit it and Prophouse generates one (whsec_...). |
events |
array | Event types to deliver. Omit or null for all. Unknown types are 422. |
format |
string | json (default) or slack. |
active |
boolean | Default true. |
Returns 201 with the endpoint and a secret field. This is the only time the secret is shown; store it. A new endpoint receives events from now on, never past ones.
Endpoint payload
Section titled “Endpoint payload”{ "id": 2, "url": "https://hooks.example.com/prophouse", "events": ["push.commit", "review.change"], "format": "json", "active": true, "cursor": 1840, "created_by": { "id": 7, "name": "<user>", "display_name": "Sam Rigger" }, "created_at": "2026-07-25T12:00:00Z", "deliveries": { "pending": 0, "delivered": 311, "failed": 2 }, "last_delivery": { "id": 913, "event_type": "push.commit", "status": "delivered" }}GET /admin/webhooks returns {"webhooks": [endpoint]}. Payloads never include the secret. (last_delivery has the same fields as a delivery below.)
PATCH /admin/webhooks/{webhook_id}
Section titled “PATCH /admin/webhooks/{webhook_id}”Send any of url, secret, events (null means all events), format, active. A new secret is echoed back once in the response. Setting active: false pauses the endpoint: nothing is queued or sent until you reactivate it, and events that happen meanwhile are then delivered.
Delivery format
Section titled “Delivery format”Each event is sent as a POST with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
prophouse-webhooks/1 |
X-PH-Event |
The event type, for example push.commit. |
X-PH-Delivery |
Unique per HTTP attempt: <delivery id>.<attempt>. Use the part before the dot to de-duplicate retries. |
X-PH-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
With format: "json" the body is the event:
{ "id": 1841, "type": "review.change", "actor": { "id": 12, "name": "<user>", "display_name": "Lee Lead" }, "entry_id": 412, "lineage_id": "guid:6F9619FF8B86D011B42D00C04FC964FF", "object_id": 1880, "batch_key": null, "payload": { "state": "approved", "previous_state": "in_review", "assignee_id": null }, "created_at": "2026-07-25T12:00:00Z"}With format: "slack", the body is {"text": "Prophouse: ..."}, which a Slack incoming-webhook URL posts to its channel as-is.
Verify the signature
Section titled “Verify the signature”v1 is the HMAC-SHA256, keyed with your secret, of the string <t>. followed by the raw request body. Compute it over the exact bytes you received (before parsing JSON), compare in constant time, and reject timestamps too far from your clock.
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item) t, sig = parts.get("t"), parts.get("v1") if not t or not sig: return False if abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig)import crypto from "node:crypto";
export function verify(secret, header, body, tolerance = 300) { const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false; const expected = crypto .createHmac("sha256", secret) .update(`${parts.t}.`) .update(body) // raw Buffer .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1 ?? ""); return a.length === b.length && crypto.timingSafeEqual(a, b);}Retries
Section titled “Retries”Any 2xx response is success. Anything else, a timeout (10 seconds) or a network error is retried: 5 attempts in total, at roughly +1 minute, +5 minutes, +30 minutes and +2 hours after the first. After the fifth failure the delivery is marked failed with last_error. Up to 3 redirects are followed, each checked against the same public-address rule.
Respond quickly with 2xx and do slow work afterwards. Deliveries can arrive more than once and out of order; use the event id to de-duplicate and created_at to order.
Test and inspect
Section titled “Test and inspect”POST /admin/webhooks/{webhook_id}/test sends a webhook.test event immediately (not queued, not retried) and reports the result: {"delivered": true, "status_code": 204} or {"delivered": false, "error": "HTTP 500"}.
GET /admin/webhooks/{webhook_id}/deliveries?limit= (1 to 200, default 50) returns recent attempts, newest first:
{ "deliveries": [ { "id": 913, "event_id": 1841, "event_type": "review.change", "attempt": 1, "status": "delivered", "next_attempt_at": null, "delivered_at": "2026-07-25T12:00:01Z", "last_error": null, "created_at": "2026-07-25T12:00:00Z" } ]}status is pending, delivered or failed. Finished deliveries are kept for 30 days.
Unknown webhook ids: 404 WEBHOOK_NOT_FOUND.
