Webhooks and the hooks API
Porchlight can tell your other tools when something happens: a visit is completed, a client is added, an invoice is paid. Use it with Zapier or Make, or point it at your own server. Every plan includes it.
Quick start
- Open Settings, then Integrations (owners only).
- Paste your endpoint URL (it must be https), choose the events you want, and press Add endpoint.
- Copy the signing secret. It is shown once. Lost it? Rotate it from the endpoint's edit panel.
- Press Send test event to see a signed
test.pingarrive.
With Zapier or Make, use their "Catch Hook" webhook trigger, paste the URL it gives you, and map the fields from the sample event.
Events
| Event | Sent when |
|---|---|
visit.scheduled | A visit is scheduled |
visit.completed | A visit is completed |
visit.canceled | A visit is canceled |
client.created | A client is added |
invoice.created | An invoice is created |
invoice.paid | An invoice is paid in full |
Visits created automatically from a recurring template do not send visit.scheduled. Invoice events can arrive up to five minutes after the invoice changes. Door codes, alarm codes and payment details are never included in a payload.
The request
Porchlight sends an HTTPS POST with a JSON body. Reply with any 2xx status within 10 seconds to acknowledge it.
{
"id": "evt_6f1c2d9e-3b7a-4c55-8a10-9d2e4f7b1a33",
"type": "visit.completed",
"api_version": "2026-10-08",
"created": "2026-10-12T15:42:11.204Z",
"data": {
"visit": {
"id": "0b2c7a4e-1c1f-4a58-9d6b-2f6f0f3c9a11",
"status": "completed",
"date": "2026-10-12",
"window_start": "10:00",
"window_end": "13:00",
"service": "Drop-in visit",
"price_cents": 2500,
"sitter": "Alex Rivera",
"completed_at": "2026-10-12T15:42:10.000Z"
},
"client": {
"id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"first_name": "Jordan",
"last_name": "Lee",
"email": "jordan@example.com"
}
}
}idis unique per event. Use it to ignore a repeat, because delivery is at least once.createdis an ISO 8601 timestamp in UTC. Amounts are integer cents.- Headers:
Porchlight-Signature,Porchlight-Event,Porchlight-Delivery.
Verify the signature
Each request carries Porchlight-Signature: t=1700000000,v1=3f9a.... The v1 value is the hex HMAC-SHA256 of the string {t}.{raw body}, keyed with your endpoint's secret. Compute it over the raw request body (before any JSON parsing), compare in constant time, and reject a t more than five minutes old to stop replays.
Node
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const given = parts.v1 ?? ''
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}Python
import hashlib, hmac, time
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)Retries and failures
- A delivery that does not get a
2xxis retried with growing delays: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then 24 hours (8 attempts in all). - After 15 failed attempts in a row the endpoint is switched off and the reason is shown in Settings. Turn it back on once it is fixed.
- Answer
410 Goneand the endpoint is switched off straight away, which is how a subscription says it no longer wants events. - Redirects are not followed. Failed deliveries can be retried by hand from the delivery log.
Security rules for URLs
Endpoints must use https on port 443 and a public hostname. Porchlight refuses localhost, private network ranges, link-local addresses and cloud metadata addresses, and checks the resolved address again on every delivery.
REST hooks API (for Zapier apps)
Create an API key under Settings, then Integrations, and send it as Authorization: Bearer plk_.... A key can only see and remove the subscriptions it created.
Subscribe
POST /api/v1/hooks
Content-Type: application/json
{ "target_url": "https://hooks.zapier.com/hooks/standard/123/abc/", "event": "visit.completed" }
201 Created
{
"id": "0b2c7a4e-1c1f-4a58-9d6b-2f6f0f3c9a11",
"target_url": "https://hooks.zapier.com/hooks/standard/123/abc/",
"events": ["visit.completed"],
"active": true,
"secret": "whsec_...",
"created": "2026-10-12T15:00:00.000Z"
}Send events (an array) instead of event to subscribe to several at once. The secret is returned only in this response.
Unsubscribe
DELETE /api/v1/hooks/{id} (or DELETE /api/v1/hooks?id={id})
200 OK
{ "id": "0b2c7a4e-...", "deleted": true }List and connection test
GET /api/v1/hooks -> { "data": [ ...subscriptions made with this key ] }
GET /api/v1/me -> { "company": { "id": "...", "name": "..." } }Errors are JSON: 401 for a missing or revoked key, 400 for a bad URL or event (the message says which), 404 for a subscription that is not yours.
Sample payloads
visit.scheduled
{
"type": "visit.scheduled",
"data": {
"visit": {
"id": "0b2c7a4e-1c1f-4a58-9d6b-2f6f0f3c9a11",
"status": "assigned",
"date": "2026-10-12",
"window_start": "10:00",
"window_end": "13:00",
"service": "Drop-in visit",
"price_cents": 2500,
"sitter": "Alex Rivera"
},
"client": {
"id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"first_name": "Jordan",
"last_name": "Lee",
"email": "jordan@example.com"
}
}
}visit.canceled
{
"type": "visit.canceled",
"data": {
"visit": {
"id": "0b2c7a4e-1c1f-4a58-9d6b-2f6f0f3c9a11",
"status": "cancelled",
"date": "2026-10-12",
"window_start": "10:00",
"window_end": "13:00",
"service": "Drop-in visit",
"price_cents": 2500,
"sitter": null
},
"client": {
"id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"first_name": "Jordan",
"last_name": "Lee",
"email": "jordan@example.com"
},
"cancellation": {
"charge_cents": 0,
"reason": "Client changed plans"
}
}
}client.created
{
"type": "client.created",
"data": {
"client": {
"id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"first_name": "Jordan",
"last_name": "Lee",
"email": "jordan@example.com",
"phone": "+15125550123",
"status": "active",
"zone": "Downtown"
}
}
}invoice.created
{
"type": "invoice.created",
"data": {
"invoice": {
"id": "7a9c5b60-2f3d-4e6a-9b8c-1d2e3f4a5b6c",
"number": "INV-1042",
"client_id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"status": "draft",
"period_start": "2026-10-01",
"period_end": "2026-10-15",
"total_cents": 12500,
"paid_cents": 0,
"due_on": "2026-10-30"
},
"client": {
"id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"first_name": "Jordan",
"last_name": "Lee",
"email": "jordan@example.com"
}
}
}invoice.paid
{
"type": "invoice.paid",
"data": {
"invoice": {
"id": "7a9c5b60-2f3d-4e6a-9b8c-1d2e3f4a5b6c",
"number": "INV-1042",
"client_id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"status": "paid",
"period_start": "2026-10-01",
"period_end": "2026-10-15",
"total_cents": 12500,
"paid_cents": 12500,
"due_on": "2026-10-30",
"paid_at": "2026-10-20T18:05:00.000Z"
},
"client": {
"id": "5e1d62d3-6a1f-4d63-8f0a-0f1c2d3e4f50",
"first_name": "Jordan",
"last_name": "Lee",
"email": "jordan@example.com"
}
}
}Questions or an event you need? Start a free trial and write to support from inside the app.