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 array optional

    The complete Access Time Window snapshot for this member and partner source. A snapshot can mix absolute and weekly windows. Omit the field to leave the stored snapshot unchanged; send [] to revoke every window owned by this source.

  • access_time_windows_revision integer required with access_time_windows

    A monotonically increasing, non-negative JSON-safe integer for this member/source revision stream. It is required whenever access_time_windows is present and must not exceed 9007199254740991.

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 at from; it is no longer active exactly at to.
  • from must be less than to. When both weekly bounds are present, active_from must be less than active_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: from and to already identify exact UTC instants. timezone is display and audit metadata only; changing it never shifts either instant.
  • Weekly windows: timezone defines the local wall clock and daylight-saving rules used to evaluate access_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_from and active_until remain 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:

InputResult
access_time_windows omittedExisting snapshot remains unchanged; no revision is required
Present with a newer revisionEntire stored snapshot for this member/source is replaced
Same revision and same normalized payloadIdempotent replay; no snapshot change
Same revision and different normalized payloadConflict; allocate a new revision after reconciling
Older revisionStale snapshot is skipped; it cannot overwrite newer state
access_time_windows: [] with a newer revisionExplicitly 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_until when 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/to instants.
  • 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 from and stops exactly at to; weekly block ends are also exclusive.
  • Updating times under the same external_id with a newer revision takes effect.
  • Omitting access_time_windows changes 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_key fails 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.