Skip to main content
PermitCore enforces per-tier monthly request + export ceilings on every quota-gated endpoint. The current tier model is Free / Pro / Enterprise — see permitcore.io/pricing for the buy flow.

Tier matrix

TierPriceRequest quotaExport quota
FreeFree1,000 / month (up to 100/day)5,000 / month (up to 500/day)
Pro89/monthor89 / month or 890 / year100,000 / monthUnlimited
EnterpriseContact for pricingCustomCustom
All tiers include all 18 cohorts and full history. Webhook event delivery is on the roadmap and not yet available.

Per-response rate-limit headers

Every 2xx response from /v1/permits (and other quota-gated endpoints) carries the following headers so your client can self-throttle:
HeaderExampleMeaning
X-RateLimit-TierproCanonical tier serving the request: free, pro, or enterprise.
X-RateLimit-Limit-Requests100000Ceiling for the currently-binding quota window.
X-RateLimit-Remaining-Requests99997Requests remaining in the binding window before the next 429.
X-RateLimit-Quota-KindmonthlyWhich dimension is binding right now: daily or monthly. Free is bound by either (1K/month or 100/day) — header reports whichever 429s next.
X-RateLimit-Reset2026-06-22When the binding quota next has capacity. Semantics depend on X-RateLimit-Quota-Kind — see below. Absent when the binding dimension is unlimited (Pro requests, Pro/Enterprise exports).

X-RateLimit-Reset semantics (kind-aware)

The reset surface is interpreted via the X-RateLimit-Quota-Kind header (or the 429 body’s quota_kind field — they always agree):
X-RateLimit-Quota-KindX-RateLimit-Reset meaning
dailyNext UTC midnight — the full daily ceiling restores at once.
monthlyThe earliest moment usage ages out of the trailing 30-day window. Monthly quotas restore progressively as old requests roll off, so the reset timestamp is when at least one slot becomes available, not when the full ceiling does.
(header absent)The binding dimension is unlimited — there is no reset to surface.
Branch on X-RateLimit-Quota-Kind (or quota_kind in the 429 body) if you need to display the right narrative to a user. Otherwise the retry guidance below works identically for both kinds.

429 response shape (RFC 9457 problem+json)

When a key exceeds its quota, the API returns 429 Too Many Requests with a Content-Type: application/problem+json body conforming to RFC 9457:
HTTP/1.1 429 Too Many Requests
Retry-After: 3600
Content-Type: application/problem+json

{
  "type": "https://api.permitcore.io/errors/monthly-requests-quota-exceeded",
  "title": "Monthly request quota exceeded",
  "detail": "You have used 100000 of 100000 requests for the current monthly window.",
  "retry_after_seconds": 3600,
  "reset_at": "2026-06-22",
  "limit": 100000,
  "quota_kind": "monthly",
  "tier_name": "pro",
  "tier_quota_daily": null,
  "tier_quota_monthly": 100000,
  "legacy_code": "rate_limited"
}

Field map

FieldTypeContractual?Meaning
typeURI stringYESStable identifier. Pattern: https://api.permitcore.io/errors/{daily|monthly}-{requests|exports}-quota-exceeded. Branch your error-handling on this URI, not on title/detail.
titlestringNO — illustrative onlyShort human-readable summary. Not contractual — prose may change without notice.
detailstringNO — illustrative onlyLonger human-readable explanation, often with the binding numbers inlined. Not contractual.
retry_after_secondsintegerYESSeconds to wait before the next request is likely to succeed. Equals the Retry-After header. Matches X-RateLimit-Reset (kind-aware).
reset_atISO 8601 date or datetimeConditional / kind-awareWhen the binding quota next has capacity. For quota_kind: "daily" the next UTC midnight; for quota_kind: "monthly" the earliest moment usage ages out of the trailing 30-day window (monthly quotas restore progressively). Absent when the binding dimension is unlimited (Pro requests, Pro/Enterprise exports). The field name is reset_at (singular) — there is no resets_at.
limitintegerYESThe ceiling for the currently-binding quota window — matches X-RateLimit-Limit-Requests.
quota_kind"daily" | "monthly"YESWhich dimension was binding when the 429 fired.
tier_namestringYESCanonical tier name (free, pro, enterprise).
tier_quota_dailyinteger | nullYESThe tier’s daily ceiling. null means unlimited on that dimension (Pro + Enterprise have no daily ceiling).
tier_quota_monthlyinteger | nullYESThe tier’s monthly ceiling. null means unlimited.
legacy_codestringDEPRECATED — removed after 2026-06-27Legacy error.code value (rate_limited) emitted for clients written against the pre-RFC-9457 envelope. Migrate to branching on type. Field will be removed entirely after 2026-06-27.
grandfather_untilISO 8601 timestampConditional / rarePresent only when an account is mid-migration between tier configurations. Most responses omit this field.
Title and detail are illustrative — the example body shows representative prose, but title/detail may change without notice. Always branch on type or legacy_code (until removed); never on the prose fields. Sleep on retry_after_seconds (or the Retry-After header, or X-RateLimit-Reset) and retry — the timer is kind-aware on both daily and monthly quotas. All three surfaces agree. For monthly quotas the timer points at the next slot in the trailing 30-day window (not the full ceiling restoring at once), so a successful retry consumes that newly-available slot — back-to-back retries against a fully-exhausted monthly key will still 429.
Node.js
async function permitcoreWithRetry(path, attempt = 1) {
  const res = await fetch(`https://api.permitcore.io${path}`, {
    headers: { Authorization: `Bearer ${process.env.PERMITCORE_API_KEY}` },
  });
  if (res.status === 429) {
    const body = await res.json();
    const retryAfter = Number(
      res.headers.get("retry-after") ?? body.retry_after_seconds ?? 0,
    );

    // Sleep the indicated duration and retry. Same logic for daily +
    // monthly — quota_kind only matters for the user-facing narrative.
    if (attempt < 3 && retryAfter > 0) {
      await new Promise((r) => setTimeout(r, retryAfter * 1000));
      return permitcoreWithRetry(path, attempt + 1);
    }
    throw new Error(
      `Rate limited (quota_kind=${body.quota_kind}, tier=${body.tier_name}). ` +
      `Limit ${body.limit}; check usage at /account/api-keys.`,
    );
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}
If you want to surface a user-facing message that distinguishes “daily quota — comes back at midnight UTC” from “monthly quota — slots restore progressively over the next 30 days,” branch on quota_kind for the narrative. The retry mechanics are the same either way.

Other guidance

  1. Cache responses when you can — most endpoints declare Cache-Control headers (e.g., cohort distribution caches 1 hour).
  2. Bulk over loop. Where the API supports bulk parameters, prefer them.
  3. Watch X-RateLimit-Remaining-Requests on 2xx responses — back off before the 429 fires.

Migration: legacy_code removal 2026-06-27

The legacy_code field ("rate_limited") was added at launch for clients written against a pre-RFC-9457 error envelope shape. It will be removed on 2026-06-27. Before that date:
  • Migrate any client logic branching on error.code === "rate_limited" to branching on type (URI) instead.
  • The type URI pattern is stable: …/errors/{daily|monthly}-{requests|exports}-quota-exceeded.
  • If you can’t migrate by 2026-06-27, fall back to branching on HTTP status 429 plus the X-RateLimit-* headers.
Watch the changelog for the removal notice.