Skip to content
ParkAttach ParkAttach
urgency conversion ota social-proof

Urgency signals in parking checkout

Six social-proof fields that lift conversion on parking quotes — what they mean, how they're computed, why absence isn't zero, and how to render them well.

P

ParkAttach team

2 min read

OTAs have long since proven that urgency signals lift conversion on flights and hotels. “3 left at this price”, “15 people looking right now”, “booked 4 minutes ago” — the social-proof framing isn’t decorative, it’s load-bearing. ParkAttach ships the same toolkit, designed specifically for parking, with the wire-level contract you’d want as someone building UI on top of it.

This post walks through every urgency field on the quote response: what it means, how it’s computed, what to do when it’s absent, and how to render it without doing more harm than good.

The six per-car-park signals

Every car park in a quote response carries six optional urgency fields. All optional — present when the urgency service has the data, absent when it doesn’t (more on that below). Here’s the catalogue:

spacesRemaining

An object, not a scalar — bookable capacity is split across three independent pools and returned per pool:

"spacesRemaining": { "standard": 47, "accessible": 3, "charger": 0 }
  • standard — bays that are neither accessible nor charger-equipped
  • accessible — accessible bays
  • charger — bays with an EV charger

Each is a non-negative integer, computed live against the operator’s published per-pool capacity minus every reservation and confirmed booking that overlaps the requested window. Sold means sold — a reservation held for the next 10 minutes is functionally unavailable to other shoppers and counts.

This field plays by different rules to the social-proof signals. Capacity is live state the platform always knows, so when the object is present all three keys are present, and an explicit 0 is meaningful — that pool is full, it is not “no signal”. This is the one deliberate exception to the “absence is not zero” contract that governs every other field here (see below). The whole spacesRemaining object is absent only if the live capacity query itself failed — and that, fail-open, means unknown, not zero.

Returning the breakdown rather than a single number is what lets the OTA cross-sell instead of dead-ending a shopper. A request for an accessible bay at a car park with accessible: 0 but standard: 12 is no longer “unavailable” — the platform stopped emitting no_accessible_bays / no_charger_bays for exactly this reason — so you can render “No accessible bays free — 12 standard spaces available” and keep the booking alive.

Computed live, never cached. It rides a Postgres GIST index on prebooking.parking_period and is the one piece of state that has to be exactly right at quote time.

onTheDayPrice

Same Money shape as priceInfo{ amount, currency }. The price the shopper would pay at the garage if they drove up without a prebooking, on the date they’re asking about. No OTA-specific discount, no commission applied — it’s the drive-up rate the operator has on the day.

This is the strongest urgency signal we ship, because it converts the conversion question from abstract scarcity into a concrete monetary anchor: “book now for £12.50, or pay £38 on the day”. Behavioural-economics 101 — losses landed against a reference point hit harder than savings stated in the abstract.

Computed from a separate on-the-day tariff that operators author alongside their prebooking tariff. If the operator hasn’t authored one, the field is absent — that’s a deliberate operator-side choice (some operators don’t want their on-the-day rates surfaced) and partner UIs should handle absence gracefully. When both fields are present, the savings framing tends to outperform any single scarcity signal in our partner pilots.

capacityUtilisationPct

Mirrors spacesRemaining: an object keyed standard / accessible / charger, each a number 0–100 — the percentage of that pool’s prebook capacity already sold for the requested window. It’s partial: a pool’s key is omitted when the car park has zero configured capacity for that pool (utilisation is undefined for a pool that doesn’t exist). Absent entirely when the live capacity query failed. Useful for per-pool “filling up fast” microcopy without doing the maths client-side.

recentSales

A nested object with eight time windows: last10m, last30m, last1h, last2h, last3h, last6h, last12h, last24h. Each is an integer count of reservations that landed in that window. The wire contract is all-or-nothing: either every window is present, or the whole recentSales object is omitted.

The all-or-nothing rule means a car park the urgency service has only been observing for 8 hours won’t half-populate the object. That keeps OTA-side UI logic simple: if the field’s there, every window’s there.

lastBookedAt

ISO 8601 datetime of the most recent confirmed booking. Feeds “last booked 4 minutes ago” microcopy directly.

activeViewers

Non-negative integer. The unique shopper count over a recent window — not a request count.

This one needs a brief detour into how it’s computed.

How activeViewers knows the difference between a refresh and a new shopper

The naive implementation is to count quote requests per car park per window. That number tracks roughly with active traffic but inflates badly on refreshes, polling clients, and OTAs that re-quote on every step of their checkout funnel. The signal becomes noise.

The wire-level fix is customerSessionGuid — an optional opaque UUID on the quote, reserve, and commit request schemas. When present, the urgency service tracks it in a session-dedup map. Refreshes from the same session don’t inflate the count. The funnel can be stitched look-to-book without compromising session privacy.

A few rules for partner implementations:

  • Generate it once per browser session. Not per request, not per page view. Same value across the OTA’s flight selection, hotel pick, and parking quote.
  • Rotate it per session. Don’t make it a stable cross-session identifier. The contract is “ephemeral, session-bound, PII-adjacent” — ParkAttach doesn’t durably persist it, and OTAs shouldn’t either.
  • Don’t pass anything PII-flavoured. A v4 UUID. That’s it.

If it’s absent, the urgency service falls back to per-request counting. The signal still works, it’s just noisier on traffic with high refresh rates.

Venue-level rollups

Alongside the per-car-park signals, the quote response carries a venueSignals object — three of the per-car-park fields aggregated across every car park near the venue: recentSales, lastBookedAt, and activeViewers. We skip spacesRemaining and capacityUtilisationPct at the venue level because there’s no meaningful denominator across heterogeneous lots.

The rollup is across every nearby car park, including operators the calling OTA doesn’t yet have an agreement with. That’s deliberate: a venue with strong demand but no agreement gives the OTA a “high interest at this location” framing without needing to actually quote any of the uncovered supply. The signal is real because the supply is real, even though the OTA can’t sell it yet.

Absence is not zero

Every urgency field is optional on the wire. The contract for partners is unambiguous:

Treat missing fields as “no signal”, not “zero”.

This is load-bearing for several reasons:

  • New fields can ship without breaking older partners — they just don’t render until you update your UI.
  • The urgency service has a fresh-start period (per-window honesty: a freshly-started replica that’s only seen 12 hours of traffic returns absent for last24h, not zero).
  • The HTTP call from the API to the urgency service has a hard 50 ms timeout. If urgency is unreachable, slow, or returning errors, the entire urgency block is absent from the response — and the quote still returns 200.

Rendering “0 sold in the last hour” when the signal is unknown actively damages conversion. Rendering nothing when the signal is unknown is correct. Build your UI around presence, not against zero.

The single exception is spacesRemaining (and its mirror capacityUtilisationPct). That’s live capacity, not a social-proof counter and not part of the urgency block dropped by the 50 ms timeout — it’s a separate query with its own fail-open. So a per-pool 0 there is real and meaningful (that pool is full), and only the absence of the whole object means “unknown”. Every other field on this contract follows presence-not-zero strictly.

”Sold” includes reservations

A subtle but important rule: the urgency service treats a Reserved state as functionally equivalent to Booked for counting purposes. A held reservation is unavailable to other shoppers; that’s what spacesRemaining reflects, and that’s what the recent-sales counters increment on.

Concretely: on a reserve event, the urgency service increments the relevant 10-minute bucket. On a commit event, it updates lastBookedAt but does not re-increment — a commit confirms an existing reservation in the standard flow, and double-counting would inflate “X sold today” by 2× per booking. On a cancel, nothing happens — the urgency service doesn’t subscribe to cancel events, because the original sold state happened and a shopper who saw it can’t un-see it.

That asymmetry is correct. Cancels are real events; the platform records them and partners are notified; but the urgency signal reflects what shoppers experienced, not what eventually settled.

A short wire example

A trimmed quote response, abbreviated to one car park, showing the fields above. Optional fields are present:

{
  "venueGuid": "01HV3...",
  "venueSignals": {
    "recentSales": {
      "last10m": 1, "last30m": 4, "last1h": 7,
      "last2h": 12, "last3h": 17, "last6h": 31,
      "last12h": 54, "last24h": 88
    },
    "lastBookedAt": "2026-05-13T14:22:11Z",
    "activeViewers": 23
  },
  "carParks": [
    {
      "carParkGuid": "cp:apcoa:downtown-garage",
      "name": "Downtown Garage",
      "priceInfo": { "amount": "12.50", "currency": "GBP" },
      "onTheDayPrice": { "amount": "18.00", "currency": "GBP" },
      "spacesRemaining": { "standard": 47, "accessible": 3, "charger": 0 },
      "capacityUtilisationPct": { "standard": 76, "accessible": 40 },
      "recentSales": {
        "last10m": 0, "last30m": 2, "last1h": 3,
        "last2h": 5, "last3h": 7, "last6h": 11,
        "last12h": 17, "last24h": 28
      },
      "lastBookedAt": "2026-05-13T14:18:42Z",
      "activeViewers": 4
    }
  ],
  "carParksWithoutAgreement": []
}

Note the asymmetry in that example: spacesRemaining.charger is 0 but capacityUtilisationPct has no charger key. That car park simply has no charger pool configured — the remaining count still reports 0, but utilisation is undefined for a pool that doesn’t exist, so its key is dropped.

If the urgency service had been unreachable, the response would still return 200 with every urgency field absent — but spacesRemaining and capacityUtilisationPct come from the separate live capacity query, so they’d still be present (absent only if that query had itself failed).

How to render them

A short style guide based on what we’ve seen work in adjacent verticals:

  • “Save £X.XX vs on-the-day” is the difference between priceInfo.amount and onTheDayPrice.amount. Render the saving prominently next to the book-now CTA. This is the single highest-converting framing we’ve measured.
  • “3 left at this price” is the spacesRemaining pool the shopper is actually buying (standard normally, accessible / charger when they requested one) framed as scarcity. Show it under a threshold — 5–10 is typical. Unlike every other signal, a 0 here is real: render it as sold out for that pool, don’t suppress it.
  • “No accessible bays — N standard available” is the cross-sell: when the requested pool (accessible or charger) is 0 but standard is positive, surface the standard count and keep the booking flow alive instead of showing “unavailable”.
  • “Just booked” is lastBookedAt when the timestamp is within ~10 minutes of now.
  • “X sold today” is recentSales.last24h. Don’t use shorter windows for this — a window that often reads zero feels worse than no signal.
  • “X people looking” is activeViewers when above some threshold (probably 3+; lower numbers feel artificial).
  • “High interest at this location” is venue-level recentSales over the venue context, useful before the user has picked a specific car park.

Adding more than two or three of these per car park is usually counterproductive — the signals start to feel performative. Pick the framings that match your brand; the savings frame plus one social-proof signal is usually plenty.


If you’re integrating against ParkAttach, the full schema for the quote response (including every urgency field) is part of the partner contract we share when we kick off a conversation. The wire shape is stable; the urgency service can ship new windows non-breakingly. Removing a window, once it’s shipped, is breaking — so the catalogue above is the contract you can plan against.

Back to Blog
Share:

Follow along

Stay in the loop — new articles, thoughts, and updates.