Webhooks
Receive a signed POST as soon as a matching permit reaches PermitStack. We refresh most sources once a day and dispatch within 60 seconds of ingest, so a permit published by the city today reaches your endpoint on our next daily run — no polling on your side.
How webhooks work
You register an endpoint URL with optional filters. Whenever a new permit lands in our database matching those filters, PermitStack POSTs a JSON event to your URL within about 60 seconds of ingesting it. Most sources are ingested once a night, so that is usually the morning after the city publishes the permit. Each request includes an HMAC-SHA256 signature you can verify to confirm authenticity.
- Register a webhook endpoint with filters (city, state, category, ZIP, keyword, contractor)
- Receive POST events as new matching permits arrive
- Verify the
X-PermitStack-Signatureheader to ensure the request came from us - Respond with 2xx within 10 seconds — anything else triggers retry
Quick start
Register a webhook via the dashboard (easiest) or the API:
curl -X POST https://api.permit-stack.com/v1/webhooks/ \
-H "X-API-Key: pk_yourkey..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/permits",
"city": "Los Angeles",
"state": "CA",
"category": "solar"
}'
That's it. When the next solar permit gets ingested for Los Angeles, your endpoint receives a POST.
Event payload
Each webhook delivery is a POST request with a JSON body:
{
"event": "permit.created",
"delivered_at": "2026-05-02T15:30:00.000Z",
"data": {
"id": "f7c3e8a9-1234-5678-9abc-def012345678",
"permit_number": "BLD-2026-04-12345",
"address": {
"street": "1234 SUNSET BLVD",
"city": "LOS ANGELES",
"state": "CA",
"zip": "90026"
},
"category": "SOLAR",
"status": "ISSUED",
"description": "INSTALL 8.4KW ROOF MOUNTED PV SOLAR SYSTEM",
"estimated_value": 32500.00,
"date_filed": "2026-04-15",
"date_issued": "2026-05-01",
"jurisdiction": "Los Angeles",
"contractor": "SUNRUN INSTALLATION SERVICES"
}
}
The contractor field is the contractor of record when our data links one (null otherwise) — pair it with the contractor_name filter to track a specific company.
Headers sent with each delivery
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | PermitStack-Webhooks/1.0 |
X-PermitStack-Event | Event type: permit.created, permit.status_changed or permit.test |
X-PermitStack-Signature | HMAC-SHA256 of request body, signed with your secret |
X-PermitStack-Timestamp | Unix seconds at the moment we signed and sent this delivery. |
X-PermitStack-Signature-V2 | t=<timestamp>,v1=<hmac> — HMAC-SHA256 of "<timestamp>.<raw body>". Optional and recommended: because the timestamp is inside the signed string, you can reject a delivery whose t is outside your tolerance and a captured request cannot be replayed. X-PermitStack-Signature keeps working exactly as before. |
X-PermitStack-Delivery-Id | Unique delivery ID — the permit ID for permit.created, the EVENT id for permit.status_changed, and the literal "test" for /test. Dedupe on it together with X-PermitStack-Event. |
X-PermitStack-Webhook-Id | The ID of the webhook this delivery is for, so an account with several webhooks (and several signing secrets) knows which secret to verify with. |
Verifying the signature
Always verify X-PermitStack-Signature matches HMAC-SHA256 of the raw request body using your webhook's secret. This proves the request actually came from PermitStack and hasn't been tampered with.
Get your signing secret from the dashboard or via the API: GET /v1/webhooks/{id}/secret
Fetch the secret once and store it. A webhook's signing secret is static — it does not rotate per delivery, so GET /v1/webhooks/{id}/secret is a setup-time call, not a per-event one. Calling it on every delivery still verifies correctly, but it spends one API request per webhook you receive: a live integration here was making ~4,900 of them a day, which was 99.9% of that account's entire request volume. Read it into config or an environment variable at deploy time.
import hashlib
import hmac
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "your_secret_from_dashboard"
@app.route("/webhooks/permits", methods=["POST"])
def receive_webhook():
# Verify signature
signature = request.headers.get("X-PermitStack-Signature", "")
expected = hmac.new(
WEBHOOK_SECRET.encode(),
request.data,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, "Invalid signature")
# Process the permit
payload = request.json
permit = payload["data"]
print(f"New {permit['category']} permit at {permit['address']['street']}")
# Respond 2xx within 10 seconds
return "", 200
const express = require("express");
const crypto = require("crypto");
const app = express();
const WEBHOOK_SECRET = "your_secret_from_dashboard";
// Important: use raw body for signature verification
app.post(
"/webhooks/permits",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-permitstack-signature"] || "";
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
) {
return res.status(401).send("Invalid signature");
}
const payload = JSON.parse(req.body.toString());
const permit = payload.data;
console.log(`New ${permit.category} permit at ${permit.address.street}`);
res.status(200).end();
}
);
app.listen(3000);
<?php
$WEBHOOK_SECRET = "your_secret_from_dashboard";
$body = file_get_contents("php://input");
$signature = $_SERVER["HTTP_X_PERMITSTACK_SIGNATURE"] ?? "";
$expected = hash_hmac("sha256", $body, $WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
echo "Invalid signature";
exit;
}
$payload = json_decode($body, true);
$permit = $payload["data"];
error_log("New {$permit['category']} permit at {$permit['address']['street']}");
http_response_code(200);
Filtering
All filters are optional. Omit a filter to match any value. Multiple filters combine with AND logic.
| Filter | Type | Example |
|---|---|---|
city | string | "Los Angeles" |
state | 2-letter code | "CA" |
category | string | "solar", "electrical", "hvac", "plumbing", "roofing", "new_construction", "addition", "alteration", "demolition" |
zip_code | string | "90026" |
keyword | string | "pool", "reroof" — only permits whose description contains this word |
contractor_name | string | "ABC Roofing" — only permits pulled by contractors whose name contains this text (case-insensitive). Track a competitor in real time. |
Retries and reliability
If your endpoint returns a non-2xx status or doesn't respond within 10 seconds, PermitStack retries 3 times with exponential backoff (1s, 5s, 15s). After 3 failures, the failure is logged and we move on to the next permit.
If your webhook accumulates 10 consecutive failed deliveries, we automatically deactivate it to protect both sides. You'll see the failure reason and last status code in the dashboard. Once your endpoint is healthy again, reactivate by registering a new webhook with the same filters.
X-PermitStack-Delivery-Id header (the permit ID for permit.created, the event ID for permit.status_changed) together with X-PermitStack-Event to deduplicate.
Testing your endpoint
Send a synthetic test event to verify your integration works before relying on real permits:
curl -X POST https://api.permit-stack.com/v1/webhooks/{webhook_id}/test \
-H "X-API-Key: pk_yourkey..."
The test event uses event: "permit.test" with a synthetic payload. Your handler should ignore test events in production logic but still verify the signature.
From the dashboard, click "Send test" on any registered webhook.
Limits
- Max 10 active webhooks per API key
- Endpoint must be HTTPS (HTTP not allowed)
- Endpoint must respond within 10 seconds
- Available on Developer tier ($79/mo) and above
Common questions
X-PermitStack-Delivery-Id header to deduplicate — it's the permit's UUID for permit.created and the event's ID for permit.status_changed.last_error field.GET /v1/permits/search endpoint with date filters.Ready to integrate?
Webhooks are available on the Developer plan ($79/month) and above. Includes 10,000 requests/day, contractor phone and email where the city publishes them, and priority email support.
Upgrade to Developer — $79/mo