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

  1. Open Settings, then Integrations (owners only).
  2. Paste your endpoint URL (it must be https), choose the events you want, and press Add endpoint.
  3. Copy the signing secret. It is shown once. Lost it? Rotate it from the endpoint's edit panel.
  4. Press Send test event to see a signed test.ping arrive.

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

EventSent when
visit.scheduledA visit is scheduled
visit.completedA visit is completed
visit.canceledA visit is canceled
client.createdA client is added
invoice.createdAn invoice is created
invoice.paidAn 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"
    }
  }
}
  • id is unique per event. Use it to ignore a repeat, because delivery is at least once.
  • created is 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 2xx is 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 Gone and 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.