Webhooks

Receive real-time notifications when events occur in your Park Graph account. Webhooks are signed with HMAC-SHA256 for security.

Receive a signed HTTP POST when something happens at your lots: a session starts, ends or is extended, a payment succeeds or fails, a lot changes, or occupancy crosses your alert level. Webhooks are available on the Pro and Enterprise plans and are signed with HMAC-SHA256.

Last updated June 2026

Setup

Configure webhooks in your dashboard. Provide an HTTPS endpoint URL and select the events you want to receive. Each event is sent to every active endpoint subscribed to it, for all of your lots. Events are queued when they happen and sent separately from the request that caused them, so a slow endpoint never delays a driver's checkout. The dashboard shows every delivery attempt.

Events

Subscribe to one or more of these seven event types:

A new parking session was created (driver, agent, or API)
session.createdA session became active: a driver paid at your QR page, or a session was started through the API, an AI agent, or a reservation.
A session was ended and final amount was captured
session.endedAn active session ended: the driver or the API ended it, its time ran out, or it was cancelled. Includes the final amount when there is one.
A session duration was extended
session.extendedA session's end time was extended. One event per extension.
Payment was successfully captured
payment.completedA card payment for a session or an extension was captured. One event per payment.
Payment capture failed and requires action
payment.failedA driver's card payment was declined or failed at Stripe.
Lot details, status, or pricing were modified
lot.updatedA lot's details, status, listing, capacity settings or pricing changed. Lists the fields that changed.
Lot occupancy crossed a configured threshold
occupancy.changedOccupied spaces rose to the lot's capacity alert level (90% unless you set another), or fell back below it. Checked when a car arrives or leaves and when you change the lot's total spaces or alert level, so adding spaces to a full lot reports it below right away. Sent once per crossing, not on every car or edit.

Session, reservation, pricing and payment lifecycle

Six more events follow a session from hold to refund. Choose them the same way, in the dashboard or with POST /api/v1/webhooks. They are queued, signed, retried and logged exactly like the events above.

session.startedA session became active, whatever started it: a QR payment, the API, an AI agent or a confirmed reservation. Sent once per session.
reservation.createdA space hold was placed with POST /api/v1/reserve. Carries the hold id, space count, quoted price and when the hold expires.
reservation.cancelledA hold ended without being confirmed: it was released, or its time ran out. reason is released or expired. A confirmed hold sends session events instead.
pricing.rule_triggeredA session started at a price set by a rule other than the base rate, such as peak hours, weekends or occupancy. Read from the final price, so every pricing rule sends it, including ones added later.
payment.capturedStripe captured the charge for a session or an extension. One event per captured payment.
payment.refundedA refund settled in Stripe, in full or in part, from the dashboard or POST /api/v1/sessions/{id}/refund. Sent from the settled refund, not the request, once per settled refund.

Alerts and daily stats

Two more events report on your lots as a whole, on Pro and Enterprise. Choose them the same way. They are queued, signed, retried and logged exactly like the events above.

alert.triggeredOne of your alerts started: an occupancy spike, a revenue drop, payment failures or downtime. Carries the alert type, the lot, the value and your alert level. Sent once when an alert starts, and not again until it has cleared. Turn it off for any alert on the Alerts page.
daily_stats.createdYesterday's numbers, sent at 7 AM lot time: sessions, payments, and gross, refunds, revenue, fees and net in cents, by source and by lot. One event per time zone your lots are in. The same numbers as GET /api/v1/reports/daily.

Subscribe via API

Programmatically register a webhook endpoint and choose the events you want to receive:

POST /api/v1/webhooksbash
curl -X POST https://parkgraph.com/api/v1/webhooks \
  -H "Authorization: Bearer pk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/parkgraph",
    "events": ["session.created", "session.ended", "payment.completed"]
  }'

Payload Format

Every event has the same envelope: id (the same on every retry), type, created_at (when the event happened) and data. Amounts are in dollars.

Example: session.createdjson
{
  "id": "evt_3f2a9c1e0b7d4e58a6c1f0d2b9e47a13",
  "type": "session.created",
  "created_at": "2026-04-30T10:00:00Z",
  "data": {
    "session_id": "uuid",
    "session_code": "PG-VV7891",
    "lot_id": "uuid",
    "lot_name": "Vail Village Parking",
    "vehicle_plate": "CO-7891",
    "status": "active",
    "source": "agent",
    "agent_name": "claude",
    "effective_rate": 8.00,
    "rate_unit": "hour",
    "started_at": "2026-04-30T10:00:00Z",
    "expected_end": "2026-04-30T12:00:00Z",
    "duration_hours": 2
  }
}

session.ended carries the same session fields plus ended_at, subtotal, platform_fee, owner_payout, currency and payment_status. session.extended adds extended_count, previous_expected_end and subtotal.

Example: payment.completedjson
{
  "id": "evt_8b1d0e6f2c3a4b7d9e5f1a2c3d4e5f60",
  "type": "payment.completed",
  "created_at": "2026-04-30T11:45:00Z",
  "data": {
    "session_id": "uuid",
    "session_code": "PG-VV7891",
    "lot_id": "uuid",
    "kind": "session",
    "extension_number": null,
    "subtotal": 14.00,
    "platform_fee": 1.40,
    "owner_payout": 12.60,
    "currency": "usd",
    "payment_intent_id": "pi_...",
    "captured_at": "2026-04-30T11:45:00Z"
  }
}

kind is session or extension. payment.failed sends session_id, lot_id, payment_intent_id, failure_code (Stripe's decline code), amount, currency and occurred_at.

Example: occupancy.changedjson
{
  "id": "evt_c4d5e6f7a8b9405162738495a6b7c8d9",
  "type": "occupancy.changed",
  "created_at": "2026-04-30T17:20:00Z",
  "data": {
    "lot_id": "uuid",
    "lot_name": "Vail Village Parking",
    "direction": "above",
    "threshold_pct": 90,
    "occupancy_pct": 92,
    "occupied_spaces": 46,
    "total_spaces": 50,
    "available_spaces": 4
  }
}

direction is above or below. A crossing caused by a lot settings change, such as adding spaces or raising the alert level, carries the new total_spaces and threshold_pct. Changes that leave the lot on the same side send nothing.

Example: lot.updatedjson
{
  "id": "evt_0a1b2c3d4e5f46a7b8c9d0e1f2a3b4c5",
  "type": "lot.updated",
  "created_at": "2026-04-30T09:12:00Z",
  "data": {
    "lot_id": "uuid",
    "lot_name": "Vail Village Parking",
    "changes": ["total_spaces"],
    "lot": {
      "lot_id": "uuid",
      "name": "Vail Village Parking",
      "slug": "vail-village-parking",
      "status": "active",
      "listed": true,
      "agent_bookable": true,
      "address": "241 E Meadow Dr",
      "city": "Vail",
      "state": "CO",
      "zip": "81657",
      "timezone": "America/Denver",
      "total_spaces": 50,
      "ev_spaces": 4,
      "handicap_spaces": 2,
      "retired_at": null
    }
  }
}

A pricing change sends lot.updated with "changes": ["pricing"]; read the new rates from the API.

session.started carries the same session fields as session.created. pricing.rule_triggered adds base_rate, multiplier, effective_rate, rate_unit and currency. reservation.created sends hold_id, lot_id, lot_name, space_count, held_by (api_key or anonymous), quoted_price, created_at and expires_at; reservation.cancelled adds reason and cancelled_at. payment.captured has the same fields as payment.completed.

pricing.rule_triggered also carries rule, the rule that set the price: event, surge_today, busy_lot, peak, weekend or stacked (more than one multiplied together). A flat event price sends it too, with multiplier 1 and rate_unit flat. rule is null only on sessions created before the rule was recorded.

Example: payment.refundedjson
{
  "id": "evt_5e6f7a8b9c0d41e2f3a4b5c6d7e8f9a0",
  "type": "payment.refunded",
  "created_at": "2026-04-30T15:02:00Z",
  "data": {
    "session_id": "uuid",
    "session_code": "PG-VV7891",
    "lot_id": "uuid",
    "kind": "session",
    "extension_number": null,
    "subtotal": 14.00,
    "platform_fee": 1.40,
    "owner_payout": 12.60,
    "currency": "usd",
    "payment_intent_id": "pi_...",
    "captured_at": "2026-04-30T11:45:00Z",
    "amount_refunded": 5.00,
    "total_refunded": 5.00,
    "fully_refunded": false,
    "refund_id": "re_...",
    "refunded_at": "2026-04-30T15:02:00Z"
  }
}

amount_refunded is what this refund gave back; total_refunded is everything refunded on that payment so far.

Signature Verification

Every webhook includes an X-Park-Graph-Signature header whose value is sha256=<hex>. Strip the sha256= prefix, recompute the HMAC over the raw request body, and compare to verify the webhook is authentic. Each delivery also carries X-Park-Graph-Event (the event type) and X-Park-Graph-Delivery (the event id, stable across retries).

import crypto from "crypto";

function verifyWebhook(rawBody, signatureHeader, secret) {
  // Header arrives as "sha256=<hex>" — strip the scheme prefix.
  const received = signatureHeader?.startsWith("sha256=")
    ? signatureHeader.slice("sha256=".length)
    : signatureHeader;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(received),
    Buffer.from(expected)
  );
}

// In your endpoint handler (verify against the RAW request body):
const isValid = verifyWebhook(
  rawBody,
  req.headers["x-park-graph-signature"],
  "whsec_..."
);

Retry Policy

Your endpoint must return a 2xx status within 30 seconds. Anything else, including a redirect, counts as a failure.

A failed delivery is retried up to 6 times, waiting at least 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours between tries.

After 10 consecutive failures, the webhook is automatically paused.

If 10 events in a row fail every attempt, the endpoint is paused. Events still waiting for a retry are cancelled, and events that happen while it is paused are not sent. The account owner gets one email and a dashboard notice for each pause (they can change this in Settings → Notifications). Resume it from the dashboard, or with PATCH /api/v1/webhooks and {"id": "...", "active": true}.

Delivery is at least once: the same event can arrive more than once, so skip any X-Park-Graph-Delivery id you have already handled. Events are not guaranteed to arrive in order; use created_at.

Resend a Missed Event

If your server was down or your handler had a bug, you can send an event again instead of rebuilding the data from the API. An event can be resent when it failed every attempt or was cancelled because the endpoint was paused, for 7 days after it happened. In the dashboard, open an endpoint's delivery log and press Resend on the event.

A resend is one attempt, made right away, with the same body, signature scheme and X-Park-Graph-Delivery id as the original, so a receiver that skips ids it has already handled stays correct. It appears in the delivery log as the event's next attempt. A successful resend marks the event delivered and resets the endpoint's failure count; a failed one is not retried and does not count toward pausing the endpoint. Resume a paused endpoint before resending its events.

From the API, use a key with the webhooks:manage scope. List an endpoint's attempts, newest first. Each one carries its event's current event_status (pending, delivered, failed or cancelled) and resendable. Events cancelled before their first attempt have no attempt to show, so they are listed in unsent_events, also newest first, with their ids:

GET /api/v1/webhooks/deliveriesbash
curl "https://parkgraph.com/api/v1/webhooks/deliveries?webhook_id=<webhook id>&limit=50" \
  -H "Authorization: Bearer pk_live_..."
Responsejson
{
  "deliveries": [
    {
      "id": "uuid",
      "event_id": "evt_3f2a9c1e0b7d4e58a6c1f0d2b9e47a13",
      "event_type": "session.created",
      "attempt": 7,
      "success": false,
      "status_code": 503,
      "duration_ms": 1204,
      "error": "Endpoint answered HTTP 503",
      "next_retry_at": null,
      "created_at": "2026-04-30T07:01:00Z",
      "event_status": "failed",
      "resendable": true
    }
  ],
  "deliveries_next_cursor": "WyJkIiwiMjAyNi0wNC0zMFQwNzowMTowMFoiLCIuLi4iXQ",
  "unsent_events": [
    {
      "event_id": "evt_9b1c47e2d05a4f3c8e6a2d7f10b3c5e8",
      "event_type": "lot.updated",
      "event_status": "cancelled",
      "reason": "Endpoint is paused",
      "created_at": "2026-04-30T07:40:00Z",
      "resendable": true
    }
  ],
  "unsent_events_next_cursor": null
}

Each list returns up to limit items (1–100, default 20) and its own next cursor, null once there is nothing older. To go back further, pass that value as cursor; the response then holds the next page of that list only. Keep paging until the cursor is null to reach every event from the last 7 days. Resending events while you page doesn't make you miss any.

Next pagebash
curl "https://parkgraph.com/api/v1/webhooks/deliveries?webhook_id=<webhook id>&limit=50&cursor=<deliveries_next_cursor>" \
  -H "Authorization: Bearer pk_live_..."

Then resend an event by its id. The call waits for your endpoint (up to 30 seconds) and returns the new attempt; success says whether your endpoint accepted it.

POST /api/v1/webhooks/deliveries/{event_id}/resendbash
curl -X POST https://parkgraph.com/api/v1/webhooks/deliveries/evt_3f2a9c1e0b7d4e58a6c1f0d2b9e47a13/resend \
  -H "Authorization: Bearer pk_live_..."
Responsejson
{
  "delivery": {
    "id": "uuid",
    "event_id": "evt_3f2a9c1e0b7d4e58a6c1f0d2b9e47a13",
    "event_type": "session.created",
    "attempt": 8,
    "success": true,
    "status_code": 200,
    "duration_ms": 312,
    "error": null,
    "created_at": "2026-04-30T09:15:00Z"
  }
}

404 NOT_FOUND: no such event on your endpoints, or it is more than 7 days old. Test events can't be resent.

409 EVENT_STILL_RETRYING: automatic retries haven't finished yet.

409 EVENT_ALREADY_DELIVERED: your endpoint already accepted it.

409 ENDPOINT_PAUSED: resume the endpoint first.

409 RESEND_IN_PROGRESS: another resend of this event is still waiting on your endpoint. Try again in a minute.

429: more than 20 resends in a minute.

Security model

The Park Graph webhooks sits behind the same security perimeter as the dashboard and the public website. The notes below summarise the controls that apply to every webhooks request; a deeper write-up lives in the developer changelog.

  • Authentication

    Every authenticated request carries an API key in the Authorization header (`Bearer pk_live_…`). Keys are issued per project from /dashboard/api-keys. Rotation is an HTTP DELETE + re-create in the dashboard; old keys are revoked immediately. Live and sandbox keys use the `pk_live_` and `pk_test_` prefixes so they cannot be confused in code review.

  • Transport security

    TLS 1.3 with HSTS preloaded on every host (parkgraph.com, *.parkgraph.com). Plaintext requests are rejected at the edge with HTTP 426. Certificates are issued through the public Let's Encrypt CA and pinned in the CT logs.

  • Payment handling

    Card data is tokenised inside Stripe Elements on the driver's device — Park Graph never observes raw PANs and is therefore SAQ-A scope. Sessions reference Stripe PaymentIntents by id only; webhooks are signed by Stripe using HMAC-SHA256 and verified server-side before any state change.

  • Data retention

    Driver email addresses and license plates are retained for the lifetime of the operator's relationship with Park Graph (or 30 days after a verified delete request, whichever comes first). Search-only requests with no booking outcome are anonymised after 24 hours. Aggregated occupancy data has no personal identifiers and is retained indefinitely.

  • Audit trail

    Every write — session create, end, extend, refund, rate change, agent registration — appends an immutable row to the audit_log table with actor type, actor id, and the diff. The log is exposed to operators through the dashboard activity feed and via a per-lot CSV export. Deletes are tombstoned, not hard-removed.

Rate limits

Limits are enforced per API key (or per IP for unauthenticated reads) using a rolling token bucket. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers; 429 responses additionally carry Retry-After. Bursts above the per-minute budget should be backed off with jitter — the official SDK does this automatically.

ScopeBudgetWindow
Public read endpoints (search, lot detail, health)120 requestsper IP per minute
Authenticated read endpoints (sessions, payouts, webhooks)600 requestsper API key per minute
Authenticated write endpoints (create / end / extend session, refunds)120 requestsper API key per minute
Sandbox endpoints (`pk_test_…` keys)30 requestsper key per minute

Need a higher ceiling? Email developers@parkgraph.com with your projected throughput and we will lift the burst budget on a per-key basis.

Error codes

Every error response uses the same envelope: { "error": { "code": "…", "message": "…", "request_id": "req_…" } }. The HTTP status indicates the broad class; the code field disambiguates within a class. Surface the request_id to your support team — we can look up the full server-side trace by id alone.

CodeHTTP statusMeaning
BAD_REQUEST400Required parameter missing or out of range. Response body lists the offending field.
UNAUTHORIZED401Missing or malformed Authorization header. Send `Authorization: Bearer pk_live_…`.
FORBIDDEN403API key is valid but the plan does not include this scope (e.g. agent or intelligence endpoints on Starter).
NOT_FOUND404Lot, session, or webhook id does not exist or has been deleted.
CONFLICT409Idempotent retry of a non-terminal session, or attempt to end an already-completed session.
RATE_LIMITED429Per-key burst budget exceeded. Inspect `Retry-After` and back off.
PAYLOAD_TOO_LARGE413Webhook delivery body or batch upload exceeds 1 MB.
INTERNAL500Unexpected server error. Park Graph automatically opens an incident and retries idempotent writes.
BAD_GATEWAY502Upstream payment processor returned an error. Safe to retry with the same idempotency key.

AI-agent use cases

Park Graph is built for agentic distribution: every commercial endpoint (search, availability, rates, sessions, refunds) is callable by an LLM through MCP, OpenAI Actions, Gemini function declarations, xAI function calling, Perplexity Agent API, or Microsoft Copilot plugins. The grid below maps the most common agent-driven workflows on top of the webhooks.

Conversational booking

An end user asks ChatGPT, Claude, Gemini, Grok, or Perplexity to find parking near a destination. The model calls /lots/search, summarises 3-5 lots with prices and walking distance, then on confirmation calls /sessions to start a session and returns the QR-coded receipt URL.

Travel-app companion

A flight-booking or hotel-booking assistant pre-fetches arrival-airport parking for the trip dates and displays inline lot suggestions inside its itinerary view. The same surface backs in-app booking and a fallback web checkout.

Voice-first ordering

Realtime voice assistants (e.g. gpt-realtime, Gemini Live, Grok Voice) confirm a lot, plate, and duration verbally, then call /sessions and read back the session code. Drivers never touch a screen until they walk away from the car.

Fleet dispatcher

An autonomous-vehicle fleet (rideshare, delivery, robotaxi) holds AV-fleet allocations with /av-fleet/dispatch, then commits the spot at handoff. See /developers/av-fleet for the AV-specific contract.

Procurement / expense agent

An accounts-payable agent reconciles staff parking against company-card statements by querying /sessions/search by plate, then files reimbursements through the agent's own ledger.

City planning analytics

A research agent queries /api/v2/intelligence/market-rate and /api/v2/intelligence/agent-demand for an address corridor, then drafts a report on rate elasticity and AI-agent traffic.

Architecture

Every Park Graph request — whether it arrives from the webhooks, the dashboard, the QR-driven mobile checkout, or an agent — runs through the same Next.js Edge front door, hits the same Postgres-backed core, and shares the same audit log and webhook fan-out. There is no divergent code path between "agent traffic" and "human traffic": anything an agent can do, a human can do, and vice versa.

Park Graph webhooks request architecture: edge proxy, REST and GeoJSON dispatchers, Postgres primary store, Redis cache and locks
webhooks request architecture — edge proxy, REST + GeoJSON dispatchers, Postgres primary store, Redis cache and distributed locks. Identical perimeter for human, agent, and webhook traffic.
Park Graph webhooks authentication and rate-limit pipeline — bearer token, scope check, idempotency, audit log, response
Five-step webhooks request pipeline that runs before any business logic — bearer auth, scope check, idempotency replay window, audit log write, structured response.
Park Graph AI agent integration stack: agent runtimes, dispatcher, public API, attribution analytics
The AI integration stack that sits on top of the public API — agent runtimes (ChatGPT, Claude, Gemini, Grok, Perplexity, Copilot, custom MCP), dispatcher, public API, and attribution analytics.
Park Graph data pipeline: operator dashboard edits flow through ingest validation into Postgres and out to materialised feeds and public surfaces
The data pipeline behind every Park Graph public surface — single ingest path, single source of truth in Postgres, multiple materialised feeds for AI consumers, search engines, and the public site.

Sandbox & getting help

Every operator gets a sandbox key prefixed with pk_test_. Sandbox traffic uses Stripe test mode, synthetic lots, and never charges a real card. Spin one up at /developers/sandbox, then point your client at https://parkgraph.com/api/v1 exactly as you would in production.

For integration help, reach the developer team at developers@parkgraph.com or open an issue on the public GitHub org. Status and incident history live at /developers/changelog; subscribe to the JSON feed for machine-readable updates.

Park Graph Webhooks — Real-Time Events | Park Graph