Products API & Data Search Coverage Property History Alerts For Contractors Data Exports
Solutions Solar & Energy Roofing HVAC Real Estate Insurance Lenders & CRE Contractors
Pricing Refer & Earn API Docs Sign In Get Started
API Documentation

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.

Available on Developer ($79/mo) and above

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.

  1. Register a webhook endpoint with filters (city, state, category, ZIP, keyword, contractor)
  2. Receive POST events as new matching permits arrive
  3. Verify the X-PermitStack-Signature header to ensure the request came from us
  4. 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

HeaderDescription
Content-Typeapplication/json
User-AgentPermitStack-Webhooks/1.0
X-PermitStack-EventEvent type: permit.created, permit.status_changed or permit.test
X-PermitStack-SignatureHMAC-SHA256 of request body, signed with your secret
X-PermitStack-TimestampUnix seconds at the moment we signed and sent this delivery.
X-PermitStack-Signature-V2t=<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-IdUnique 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-IdThe 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.

FilterTypeExample
citystring"Los Angeles"
state2-letter code"CA"
categorystring"solar", "electrical", "hvac", "plumbing", "roofing", "new_construction", "addition", "alteration", "demolition"
zip_codestring"90026"
keywordstring"pool", "reroof" — only permits whose description contains this word
contractor_namestring"ABC Roofing" — only permits pulled by contractors whose name contains this text (case-insensitive). Track a competitor in real time.
Pro tip: Register multiple narrow webhooks instead of one broad one. Separate endpoints for "LA solar" and "Phoenix HVAC" let you route events to different downstream services without filter logic in your handler. Sending events into GoHighLevel, Zapier, Make, or n8n? Follow the CRM integration guide.

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.

Idempotency: A permit may be delivered more than once if your endpoint responds slowly or returns 5xx. Use the 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

Common questions

How quickly do webhooks fire after a permit is ingested?
Within about 60 seconds of ingest. Our dispatcher runs every minute and fires for any permits added since its last successful run for that webhook. Most sources are ingested once a night, so a permit the city publishes today usually reaches you after our next nightly run.
Can I test without registering a real webhook?
Yes — register one pointing at webhook.site (free service that gives you a unique URL). You'll see each delivery as it arrives, with full headers and body.
Why is my webhook receiving duplicate events?
If your endpoint takes longer than 10 seconds to respond or returns a 5xx, we retry up to 3 times. Use the 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.
My webhook got auto-deactivated. What now?
After 10 consecutive failed deliveries we deactivate the webhook to avoid hammering a broken endpoint. Fix your endpoint, then register a new webhook with the same filters via dashboard or API. Failures are visible in the last_error field.
Can I have multiple webhooks for the same filter?
Yes. You might want one for production and one for staging — both will receive every matching event independently.
Is there a way to backfill historical permits via webhook?
No — webhooks fire for new permits going forward only. For historical data, use the 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