Access Time Windows
Access Time Windows let a Partner Member carry temporary or recurring access supplied by an external system. This page is the authoritative producer guide for access_time_windows and access_time_windows_revision.
Access Time Windows are additive allow-only permissions. An active window can allow access, but an inactive, expired, stale, or unmapped window does not deny access that a regular rule or permanent grant already allows. User disablement, explicit door blocking, maintenance mode, geofencing, rate limits, and controller availability still apply.
Implementations for particular upstream systems are separate adapters. They must translate their source data into this vendor-neutral contract without changing its field meanings.
Member snapshot fields
-
access_time_windows
arrayoptionalThe complete Access Time Window snapshot for this member and partner source. A snapshot can mix
absoluteandweeklywindows. Omit the field to leave the stored snapshot unchanged; send[]to revoke every window owned by this source. -
access_time_windows_revision
integerrequired with access_time_windowsA monotonically increasing, non-negative JSON-safe integer for this member/source revision stream. It is required whenever
access_time_windowsis present and must not exceed9007199254740991.
Each snapshot accepts at most 50 windows. external_id, scope_key, and label accept at most 120 characters. Absolute windows can span at most 90 days. A weekly day accepts at most eight non-overlapping blocks. Invalid data is rejected rather than truncated or guessed.
Window shapes
An absolute window identifies one exact interval:
{
"external_id": "access-4b1782",
"kind": "absolute",
"from": 1792053000,
"to": 1792058400,
"timezone": "Australia/Perth",
"scope_key": "after_hours_entry",
"label": "Temporary site access"
}
Required fields are external_id, kind, from, and to. timezone, scope_key, and label are optional.
A weekly window identifies recurring local wall-clock intervals, optionally bounded by UTC instants:
{
"external_id": "weekly-early-access",
"kind": "weekly",
"timezone": "America/New_York",
"access_hours": {
"sun": [],
"mon": [["06:00", "08:30"]],
"tue": [["06:00", "08:30"]],
"wed": [["06:00", "08:30"]],
"thu": [["06:00", "08:30"]],
"fri": [["06:00", "08:30"]],
"sat": []
},
"active_from": 1790812800,
"active_until": 1796083200,
"scope_key": "main_entry",
"label": "Weekday early access"
}
Required fields are external_id, kind, and access_hours. timezone, active_from, active_until, scope_key, and label are optional.
Complete mixed request
This request replaces the member's previous source-owned snapshot with one absolute and one weekly window:
curl -X POST https://partner-api.performancehub.co/partner/v1/members \
-u sk_live_YOUR_PARTNER_SECRET_KEY: \
-H "Content-Type: application/json" \
-d '{
"members": [
{
"member_id": "member_11111",
"facility_id": "550e8400-e29b-41d4-a716-446655440000",
"first_name": "John",
"last_name": "Smith",
"access_time_windows_revision": 1842,
"access_time_windows": [
{
"external_id": "access-4b1782",
"kind": "absolute",
"from": 1792053000,
"to": 1792058400,
"timezone": "Australia/Perth",
"scope_key": "after_hours_entry",
"label": "Temporary site access"
},
{
"external_id": "weekly-early-access",
"kind": "weekly",
"timezone": "America/New_York",
"access_hours": {
"sun": [],
"mon": [["06:00", "08:30"]],
"tue": [["06:00", "08:30"]],
"wed": [["06:00", "08:30"]],
"thu": [["06:00", "08:30"]],
"fri": [["06:00", "08:30"]],
"sat": []
},
"active_from": 1790812800,
"active_until": 1796083200,
"scope_key": "main_entry",
"label": "Weekday early access"
}
]
}
]
}'
Use either facility_id or linking_identifier according to your configured linking method. Do not send both.
Time and boundary rules
from, to, active_from, and active_until are JSON integer Unix seconds identifying UTC instants:
- Send an unquoted JSON integer. Numeric strings, floats, RFC 3339 strings, and Unix milliseconds are rejected.
- Every interval is half-open:
[from, to). Access can start exactly atfrom; it is no longer active exactly atto. frommust be less thanto. When both weekly bounds are present,active_frommust be less thanactive_until.- Do not derive epochs from an unzoned local date/time. First resolve the source time to a UTC instant, then divide Unix milliseconds by 1000 and verify the result is an integer.
Epoch conversion
2026-10-15T08:30:00Z converts to 1792053000:
UTC instant Unix milliseconds Unix seconds sent
2026-10-15T08:30:00Z 1792053000000 1792053000
Language-neutral conversion:
instant = parse_source_timestamp_with_explicit_zone(source_timestamp)
milliseconds = instant.to_unix_milliseconds()
assert milliseconds modulo 1000 equals 0
seconds = milliseconds / 1000
assert seconds is a JSON-safe nonnegative integer
If the source has sub-second precision, define a deliberate start/end rounding policy before integration testing. Never pass milliseconds and rely on the API to infer the unit.
Timezones and daylight saving
timezone must be a valid IANA timezone such as UTC, Australia/Perth, or America/New_York. It defaults to UTC when omitted.
- Absolute windows:
fromandtoalready identify exact UTC instants.timezoneis display and audit metadata only; changing it never shifts either instant. - Weekly windows:
timezonedefines the local wall clock and daylight-saving rules used to evaluateaccess_hours. Do not pre-convert weekly blocks to UTC. - During a spring-forward change, a local time that the timezone skips never occurs.
- During a fall-back change, both real instants carrying the repeated local time match the weekly block.
active_fromandactive_untilremain UTC instants even when a weekly window uses a non-UTC timezone.
access_hours uses lowercase sun through sat. Each value is an array of [start, end] pairs using zero-padded, 24-hour HH:mm. Blocks must be non-empty, ordered, and non-overlapping. end is exclusive. A block cannot cross midnight; split an overnight source interval into a late block on the first day and an early block on the next day.
Stable IDs and scope mapping
external_id must be stable for the lifetime of a logical window and unique within the member snapshot. Keep it stable when times or labels change so operators and audit records can correlate revisions. Do not reuse it for an unrelated window.
scope_key is your stable logical access target. It defaults to default. A facility administrator maps each allowed key to Performance Hub facility-wide, door, or Access Scope targets:
Partner scope_key Performance Hub mapping
default Facility's configured default target
main_entry Access Scope "Main entrances"
after_hours_entry Doors approved for after-hours entry
Partners do not send Performance Hub facility, door, or Access Scope IDs inside a window. An unknown, disabled, or unmapped scope_key fails closed for that window; it does not broaden access. Agree and test mappings before launch.
Complete snapshots and revisions
Treat the two fields as a separately versioned partition within the Member upsert:
| Input | Result |
|---|---|
access_time_windows omitted | Existing snapshot remains unchanged; no revision is required |
| Present with a newer revision | Entire stored snapshot for this member/source is replaced |
| Same revision and same normalized payload | Idempotent replay; no snapshot change |
| Same revision and different normalized payload | Conflict; allocate a new revision after reconciling |
| Older revision | Stale snapshot is skipped; it cannot overwrite newer state |
access_time_windows: [] with a newer revision | Explicitly revokes all source-owned windows |
A snapshot is complete, not a patch. If revision 1842 has windows A and B, then revision 1843 containing only B removes A.
Maintain one coordinated revision stream per partner/member across webhook workers, polling, replay, and full reconciliation. A durable database sequence is preferred. A Unix-millisecond value can be used as the revision only when every writer coordinates and guarantees strict ordering and uniqueness; it is a revision value, not an epoch field.
Persist the exact request and revision until the API response is known. Retry the same normalized snapshot with the same revision after a timeout or transient error. Do not allocate a new revision merely because transport delivery is uncertain.
Cancellation, expiry, and upstream failures
- Cancel one window: successfully fetch authoritative source state, remove that window, and send the remaining complete snapshot with a newer revision.
- Cancel all windows: send
access_time_windows: []with a newer revision. - Let an absolute window expire: it stops allowing at
to; send a later reconciliation without the expired entry to keep snapshots clean. - End a weekly window: remove it in a newer snapshot, or use
active_untilwhen the source has a known end instant. - Source fetch fails or is incomplete: do not send
[]and do not publish a partial snapshot. Alert and retry the fetch. Turning an upstream failure into an empty snapshot is an unintended access revocation. - Partner API request fails validation: correct the data and send a newer revision only if the normalized snapshot changes. Retry transient failures with the original revision.
Source freshness is configured per integration from its promised delivery and reconciliation cadence. Before activation, agree the maximum source age, what happens when it is exceeded, alert thresholds, and whether operators may acknowledge relying only on each window's own bounds. A stale source window never becomes a deny rule; regular rules and permanent grants remain fallback paths.
Producer algorithm
Use this language-neutral workflow for every member update:
function publish_member(member_key):
source_result = fetch_authoritative_access_state(member_key)
if source_result.failed or source_result.incomplete:
record_failure(member_key)
schedule_retry(member_key)
return without sending access_time_windows
windows = []
for source_window in source_result.current_windows:
if source_window.is_absolute:
windows.append({
external_id: stable_window_id(source_window),
kind: "absolute",
from: to_integer_unix_seconds(source_window.start_instant),
to: to_integer_unix_seconds(source_window.end_instant),
timezone: source_window.display_timezone or "UTC",
scope_key: stable_scope_key(source_window) or "default",
label: safe_label(source_window)
})
else:
windows.append({
external_id: stable_window_id(source_window),
kind: "weekly",
timezone: valid_iana_zone(source_window.timezone) or "UTC",
access_hours: split_validate_and_sort_weekly_blocks(source_window),
active_from: optional_integer_unix_seconds(source_window.active_start),
active_until: optional_integer_unix_seconds(source_window.active_end),
scope_key: stable_scope_key(source_window) or "default",
label: safe_label(source_window)
})
normalized_snapshot = validate_sort_and_deduplicate(windows)
revision = next_durable_member_revision(member_key)
request = member_upsert_with(normalized_snapshot, revision)
persist_for_retry(member_key, request)
response = post_request(request)
if response.is_transient_or_unknown:
retry_exact_request_with_same_revision(request)
else if response.is_revision_conflict:
reconcile_with_latest_committed_source_state(member_key)
else if response.is_stale_skip:
stop_replaying_this_revision_and_reconcile(member_key)
Certification checklist
Complete these checks in a stage facility using the final scope_key mapping:
- An absolute window is accepted with omitted timezone and is evaluated as UTC.
- A custom-IANA absolute window preserves the same
from/toinstants. - A weekly custom-IANA window follows local wall time across spring-forward and fall-back transitions.
- A mixed absolute/weekly snapshot replaces the prior snapshot completely.
- Access starts exactly at
fromand stops exactly atto; weekly block ends are also exclusive. - Updating times under the same
external_idwith a newer revision takes effect. - Omitting
access_time_windowschanges other Member fields without changing windows. - Replaying the same revision/payload is idempotent.
- Same revision/different payload returns a conflict and does not change the snapshot.
- An older out-of-order revision is reported stale and cannot roll state back.
- A newer
[]revokes source-owned windows; an upstream fetch failure sends nothing. - An unknown or unmapped
scope_keyfails closed and creates an operator-visible warning. - Source-age alerts and the configured stale-source policy work at the agreed threshold.
- Webhook and full reconciliation paths converge on the same snapshot and revision order.
- Stream delivery reaches Door & Gate Access within the agreed target.
- An active window can allow the mapped physical door, but cannot bypass user disablement,
blockAllAccess, maintenance, or geofencing. - Before, outside, and after a window, regular rules and permanent grants still allow users who otherwise qualify.
Minimum test vectors
[
{
"name": "absolute-half-open",
"window": {
"external_id": "tv-absolute",
"kind": "absolute",
"from": 1792053000,
"to": 1792058400
},
"expect": {
"1792052999": "inactive",
"1792053000": "active",
"1792058399": "active",
"1792058400": "inactive"
}
},
{
"name": "weekly-dst",
"window": {
"external_id": "tv-weekly-dst",
"kind": "weekly",
"timezone": "America/New_York",
"access_hours": {
"sun": [["01:00", "03:30"]],
"mon": [],
"tue": [],
"wed": [],
"thu": [],
"fri": [],
"sat": []
}
},
"expect": "Evaluate local wall time: skipped times do not occur; both repeated 01:30 instants qualify."
}
]
Also submit negative vectors for Unix milliseconds, numeric strings, floats, invalid IANA zones, from >= to, duplicate external_id, overlapping blocks, overnight blocks, more than eight blocks in a day, more than 50 windows, and unmapped scope keys.