Added

OnSched 3.16.0 - location daily booking limit, tenant isolation, and pair allocations

Location daily booking limit

  • Location customerDailyBookingLimit is an integer on merchant create/update/GET. 0 or omitted means unlimited.
  • The cap is per customer, per location, across all services. The calendar day is the appointment start in the location timezone (UTC if timezone is unset).
  • Counted statuses are booked (BK) and non-expired holds (IN). Reserved (RS), cancelled (CN), rescheduled-away (RE), and expired holds do not count.
  • Hold, reserve, book, and reschedule enforce the cap when a customer is known. Hold without a customer skips it; booking that hold still enforces it.
  • GET /v3/availability is unchanged. Public Location GET still returns only id and name.
  • Dashboard staff bookings are not exempt. The cap stacks with service, resource, lifetime, and service-wide daily limits.
  • Reschedule excludes the current appointment, so moving off a full day frees it. Landing on a full day returns 400 Customer has reached the daily booking limit for this location.
  • Customers already over a newly lowered cap keep existing appointments; only new hold/book/reschedule is blocked.
  • The Merchant Portal Location form includes Daily appointments per customer (this location).

See Booking Limits.

Machine-token company isolation

  • Machine tokens can read and modify only their bound company and its records. Foreign path IDs return 404; foreign body/query references return 400.
  • Company creation, user-profile changes, migration, support tickets, and dashboard refresh-token generation require a dashboard user token (403 for machine tokens).
  • Company detail excludes a shared user's memberships in other companies. Location, Resource, and Service association payloads omit another company's linked records, including leftover join-table links. OAuth read/write scope enforcement remains in place.

Public booking Origin allowlist

  • /v3/public/* identifies the company from x-client-id. The request Origin must match an Allowed Domain for that company (exact host; http or https). Missing or unmatched Origin returns 403.
  • x-api-key is not required on public routes. Register real hostnames; wildcard * is rejected.
  • Browser widgets that already send Origin from a registered host are unchanged. Scripted callers without Origin, or that relied on another company's domain or a substring match, receive 403.

Webhook destinations

  • Webhook URLs must be public https. http, IP literals, localhost, internal hosts, and cloud-metadata destinations return 400 on create/replace. Deliveries pin to a public resolved address, do not follow redirects, and time out after five seconds.
  • Hop-by-hop and metadata request headers are rejected. Authorization and custom names such as Webhook-Secret still work. Appointment create/cancel/reschedule still succeeds if a stored destination cannot be delivered.
  • Receivers on a private or CGNAT-only network need a public HTTPS ingress. Existing non-https rows remain on GET /v3/webhook until replaced; they are not sent.

Personalized Docs login

  • Logging into docs from the merchant portal now mints an identity-only Docs Handoff Token (name, email, docs version; expires in 15 minutes). It does not include an API key, dashboard JWT, or public client ID.
  • Try It is no longer prefilled after that hop. Paste credentials from company settings. The docs URL may still carry the identity token as auth_token because that is how ReadMe Personalized Docs login works.

Service pair allocations

  • On Service → Allocations, choosing one resource now shows and saves only that service + resource pair. Resource-wide hours from GET /v3/weeklyAllocation are no longer copied into the pair replace payload (which previously created extra or shifted allocation windows).
  • On Resource → Allocations, pair rows remain visible for the aggregate view, but Save sends only resource-wide windows. Pair hours stay on their service instead of being rewritten as resource-wide availability for every service on that resource.
  • Pair startDate/endDate on GET /v3/weeklyAllocation use the resource timezone, matching create and get-by-id. Service-level rows still use the service location timezone.

Rate limits

  • Native /v3 and /v3/public requests are rate limited per company (or per dashboard user when no company is on the request). POST /v3/oauth/token is limited per client id and by a global mint budget. Exceeding a budget returns 429 with Retry-After.
  • Machine and public availability reads share one company availability budget. Appointment list and cursor walks keep the budgets introduced in 3.13.0.
  • Honor Retry-After and retry the same request. Operators who need higher throughput can raise the platform rate-limit environment values.

OAuth scopes

  • POST /v3/oauth/token accepts only OAuth Scopes read and write. Unknown names (including mixed lists such as read admin) return 400 Invalid OAuth scope and do not issue a token.
  • Omit scope, or send a blank value, to receive read write. The JSON response includes the granted scope string.
  • A write token may call GET routes. A read token cannot POST, PUT, PATCH, or DELETE (403 Insufficient scope: write required).

JWT access token profiles

  • Native /v3 Bearer tokens must be a Machine Token or a Dashboard JWT. The API checks HS256, issuer, audience, and expiry on every request.
  • Machine Token: iss onsched:v3-api, aud api. Tokens minted before this change fail until the client calls POST /v3/oauth/token again (about one hour).
  • Dashboard JWT: Supabase access token (aud authenticated). A refresh JWT and a Docs Handoff Token return 401 Token could not be verified.