Skip to content

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
Terminal window
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.

{
"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.)

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.

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.

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)

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.

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.