API Documentation

Webhooks

Receive real-time POST notifications when new permits are added that match your filters. Perfect for lead generation pipelines, monitoring competitor projects, and triggering workflows the moment a permit goes live.

Available on Developer ($49/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 ~60 seconds. Each request includes an HMAC-SHA256 signature you can verify to confirm authenticity.

  1. Register a webhook endpoint with filters (city, state, category, ZIP)
  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"
  }
}

Headers sent with each delivery

HeaderDescription
Content-Typeapplication/json
User-AgentPermitStack-Webhooks/1.0
X-PermitStack-EventEvent type (e.g. permit.created, permit.test)
X-PermitStack-SignatureHMAC-SHA256 of request body, signed with your secret
X-PermitStack-Delivery-IdUnique delivery ID (the permit ID for real events)

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

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

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 (which equals the permit ID) 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 60 seconds. Our dispatcher runs every minute and fires for any permits added since its last successful run for that webhook.
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 deliveries arrive in real-time 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.
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 ($49/month) and above. Includes 10,000 requests/day, AI-enriched data, and priority email support.

Upgrade to Developer — $49/mo