{"openapi":"3.0.0","paths":{"/v1":{"get":{"description":"Returns basic API info","operationId":"PublicApiController_getRoot","parameters":[],"responses":{"200":{"description":"API info"}},"summary":"API root","tags":["General"]}},"/v1/status":{"get":{"description":"Returns current system status and version. Requires a valid API key.","operationId":"PublicApiController_getStatus","parameters":[],"responses":{"200":{"description":"System status","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"operational"},"version":{"type":"string","example":"1.0.0"},"timestamp":{"type":"string","example":"2026-04-07T00:00:00.000Z"}}}}}},"401":{"description":"Missing or invalid API key"},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"System status","tags":["General"]}},"/v1/data-submissions/providers":{"get":{"description":"Returns a list of supported data providers and their basic info. The registry is small and always returned in a single page; `meta.per_page` reflects the returned row count rather than a client-configurable page size.","operationId":"DataSubmissionsController_listProviders","parameters":[],"responses":{"200":{"description":"List of providers","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"type":"object","required":["provider_id","name","description"],"properties":{"provider_id":{"type":"string","example":"premise-hq"},"name":{"type":"string","example":"PremiseHQ"},"description":{"type":"string"}}}},"meta":{"type":"object","required":["page","per_page","total","total_pages"],"description":"Page-based pagination metadata (API_DESIGN.md §4).","properties":{"page":{"type":"integer","example":1},"per_page":{"type":"integer","example":50},"total":{"type":"integer","example":1247},"total_pages":{"type":"integer","example":25}}}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"List available providers","tags":["Data Submissions"]}},"/v1/data-submissions/schema":{"get":{"description":"Returns the expected JSON field schema for a specific provider. Use this to understand what fields to include in your submission items.","operationId":"DataSubmissionsController_getSchema","parameters":[{"name":"provider","required":true,"in":"query","description":"Provider ID (e.g., \"premise-hq\")","schema":{"example":"premise-hq","type":"string"}}],"responses":{"200":{"description":"Provider schema with field definitions","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["provider_id","name","description","fields","limits","example_item","submission_format"],"properties":{"provider_id":{"type":"string","example":"premise-hq"},"name":{"type":"string","example":"PremiseHQ"},"description":{"type":"string"},"fields":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","example":"incentive_name"},"type":{"type":"string","example":"string"},"required":{"type":"boolean"},"description":{"type":"string"},"example":{}}}},"limits":{"type":"object","properties":{"max_items_per_request":{"type":"number","example":500},"max_body_size":{"type":"string","example":"5MB"},"note":{"type":"string"}}},"example_item":{"type":"object","description":"A complete example item built from field examples"},"submission_format":{"type":"object","properties":{"description":{"type":"string"},"example":{"type":"object"}}}}}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"resource_not_found"},"message":{"type":"string","example":"Resource not found."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Get provider schema","tags":["Data Submissions"]}},"/v1/data-submissions":{"post":{"description":"Accept a batch of incentive program records from a provider. The submission is queued for async processing, matching, and review.\n\n**Limits:** Maximum 500 items per request, 5MB body size. For larger datasets, split into multiple requests — each gets its own submission ID. Use the `Idempotency-Key` header to safely retry on failure.\n\nUse `GET /v1/data-submissions/schema?provider={id}` to see expected item fields.","operationId":"DataSubmissionsController_createSubmission","parameters":[{"name":"idempotency-key","in":"header","description":"Optional client-supplied idempotency key to prevent duplicate submissions. If omitted, a SHA-256 hash of the request body is used.","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"description":"Provider data submission","content":{"application/json":{"schema":{"type":"object","required":["provider","items"],"properties":{"provider":{"type":"string","description":"Provider ID. Use GET /v1/data-submissions/providers to list available values.","example":"premise-hq"},"items":{"type":"array","description":"Array of incentive records (max 500). See GET /v1/data-submissions/schema?provider={id} for field definitions.","minItems":1,"maxItems":500,"items":{"type":"object"}}}}}}},"responses":{"200":{"description":"Duplicate submission (idempotent) — returns existing submission info","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["submission_id","item_count","status"],"properties":{"submission_id":{"type":"string","example":"dsub_abc123xyz"},"item_count":{"type":"number","example":45},"status":{"type":"string","example":"completed"}}}}}}}},"201":{"description":"Submission accepted and queued for processing","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["submission_id","item_count","status"],"properties":{"submission_id":{"type":"string","example":"dsub_abc123xyz"},"item_count":{"type":"number","example":45},"status":{"type":"string","example":"pending"}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"unauthorized"},"message":{"type":"string","example":"Invalid or expired API key."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"403":{"description":"API key lacks the required permission for this resource","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"forbidden"},"message":{"type":"string","example":"API key does not have access to 'incentives'."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"413":{"description":"Request body exceeds 5MB size limit"},"422":{"description":"Request validation failed","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string","example":"Request validation failed."},"details":{"nullable":true,"example":{"sort_field":["Invalid enum value. Expected 'admin_name' | 'incentive_name' | 'updated_at' | 'start_date' | 'end_date', received 'bogus'"]}}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Submit provider data","tags":["Data Submissions"]}},"/v1/normalize/properties":{"post":{"description":"Accepts raw property rows from a customer spreadsheet (with arbitrary column names) and uses an LLM to extract a normalized {project_name, address_raw, asset_type, confidence} for each row. The `asset_type` is constrained to the canonical list of 31 IncentiFind verify-report asset types — the LLM will not invent new values. The caller is responsible for downstream address verification (e.g. SmartyStreets) and final human review.\n\n**Limits:** Maximum 100 rows per request, 5MB body size. Rows are processed in parallel internal batches of 50.","operationId":"PropertyNormalizeController_normalizeProperties","parameters":[],"requestBody":{"required":true,"description":"Raw property rows and target schema type","content":{"application/json":{"schema":{"type":"object","required":["type","rows"],"properties":{"type":{"type":"string","enum":["residential","commercial"],"example":"commercial"},"rows":{"type":"array","description":"Array of raw rows. Each row is an object with arbitrary string keys (usually the spreadsheet headers) and unknown values.","minItems":1,"maxItems":100,"items":{"type":"object","additionalProperties":true}}}}}}},"responses":{"200":{"description":"Normalized rows and per-row issues","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["normalized_rows","issues","model"],"properties":{"normalized_rows":{"type":"array","items":{"type":"object","required":["source_row_index","project_name","address_raw","asset_type","confidence","notes"],"properties":{"source_row_index":{"type":"number","example":0},"project_name":{"type":"string","nullable":true,"example":"Marriott Austin"},"address_raw":{"type":"string","nullable":true,"example":"304 E Cesar Chavez, Austin, TX 78701"},"asset_type":{"type":"string","nullable":true,"enum":["Agricultural","Commercial (Air Conditioned Storage)","Commercial (Data Center)","Commercial (Distribution Centers)","Commercial (Hospitality)","Commercial (Industrial)","Commercial (Laboratory)","Commercial (Manufacturing)","Commercial (Master-Planned Community)","Commercial (Mixed-Use)","Commercial (Multi-Family)","Commercial (Office)","Commercial (Private Education Dormitory)","Commercial (Private Education Facility)","Commercial (Private Hospital)","Commercial (Private Medical Office Building)","Commercial (Retail)","Commercial (Senior Housing)","Commercial (Warehouse)","House Of Worship","Land/Parks","Museum","Public Education Dormitory","Public Facility (Office)","Public Facility (Other)","Public Higher Education","Public Hospital","Public K-12 School","Public Medical Office Building","Residential (Single Family)","Vehicles/Fleets"],"example":"Commercial (Hospitality)"},"confidence":{"type":"string","enum":["high","medium","low"],"example":"high"},"notes":{"type":"string","nullable":true}}}},"issues":{"type":"array","items":{"type":"object","required":["row_index","message"],"properties":{"row_index":{"type":"number"},"message":{"type":"string"}}}},"model":{"type":"string","example":"gemini-2.5-flash"}}}}}}}},"400":{"description":"`bad_request`: the body is malformed JSON, or JSON that is not an object (such as an array)"},"401":{"description":"Missing or invalid API key"},"403":{"description":"API key lacks property-normalize permission"},"413":{"description":"Request body exceeds 5MB size limit"},"422":{"description":"`validation_error`: a required field is missing or invalid, or `rows` has more than 100 entries. `details` names each offending field."},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Normalize messy property rows","tags":["Property Normalization"]}},"/v1/incentives/{id}":{"get":{"description":"Returns the full detail of a single incentive program along with a `calculation_rules` summary block describing whether the program has an approved, structured calculation rule set (and, if so, how many measures and rates it contains, when it was last reviewed, and its effective date range). Use this before calling `/v1/incentives/{id}/calculation-rules` to know whether a structured rule set is available.\n\nInactive programs, and programs that do not exist, both surface as `404 resource_not_found` — except a program archived as a duplicate of exactly one live program. That id answers with the live program's detail, `incentive_program_id` unchanged and `successor_program_id` naming the program the body describes, as `/calculation-rules` and `/calculate` do on the same id. A program archived onto several programs, or for any other reason, still 404s.","operationId":"IncentiveCalculationRulesController_getProgram","parameters":[{"name":"id","required":true,"in":"path","description":"Numeric incentive program ID.","schema":{"example":256478,"type":"number"}}],"responses":{"200":{"description":"Incentive program detail with calculation-rules summary.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"description":"A single incentive program with its calculation-rules summary block.","properties":{"data":{"type":"object","required":["incentive_program_id","incentive_name","admin_name","incentive_description","active","is_entire_state","updated_at","start_date","end_date","end_date_description","state_id","implementing_sector_id","implementing_sector_name","program_type_id","program_type_name","new_or_existing","asset_categories","technology_categories","eligible_scope","sectors","technologies","counties","next_steps","minimum_funds","maximum_funds","minimum_funds_amount","maximum_funds_amount","website_url","document_url","dates_source","successor_program_id","calculation_rules"],"properties":{"incentive_program_id":{"type":"integer","example":88038,"x-icf-stability":"contract"},"incentive_name":{"type":"string","example":"Commercial Energy Efficiency Rebate Program","x-icf-stability":"presentation"},"admin_name":{"type":"string","nullable":true,"example":"Austin Energy","x-icf-stability":"presentation"},"incentive_description":{"type":"string","nullable":true,"example":"Rebates for qualifying commercial energy efficiency upgrades including HVAC, lighting, and building envelope improvements.","x-icf-stability":"presentation"},"active":{"type":"boolean","example":true,"x-icf-stability":"contract"},"is_entire_state":{"type":"boolean","example":false,"x-icf-stability":"contract"},"updated_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-03-22T10:00:00Z","x-icf-stability":"contract"},"start_date":{"type":"string","format":"date","nullable":true,"description":"Program start date. When the program has an approved calculation rule set that carries an effective window, this derives from the NEAREST such window (today-effective → next upcoming → most recently ended) — the attested window supersedes the legacy catalog listing. Otherwise it is the legacy listing passthrough. `dates_source` names the authority. Null = we hold no start date.","example":"2026-01-01","x-icf-stability":"contract"},"end_date":{"type":"string","format":"date","nullable":true,"description":"Program end date — same derivation and authority rules as `start_date`.\n\nRead null together with `dates_source`, because it means two different things: under `calculation_rules` the attested window has no end, so the program is genuinely open-ended; under `program_listing` the catalog simply holds no end date, so it is unknown. A date in the past is served as it stands rather than suppressed — it is what we hold. `active` is a separate catalog flag, maintained independently of these dates.","example":"2026-12-31","x-icf-stability":"contract"},"dates_source":{"type":"string","enum":["calculation_rules","program_listing"],"description":"Which layer produced `start_date`/`end_date`: `calculation_rules` = the approved rule-set effective window (attested at approval time, the authoritative fact wherever it exists); `program_listing` = the legacy program catalog passthrough. Always present.\n\nAn approved rule set that carries no effective bounds at all asserts nothing about dates, so it never appears as `calculation_rules` here — such a program reports `program_listing` and the dates the catalog holds. A `calculation_rules` row therefore always carries at least one of `start_date` / `end_date`.","example":"calculation_rules","x-icf-stability":"contract"},"end_date_description":{"type":"string","nullable":true,"example":"Ongoing","x-icf-stability":"presentation"},"state_id":{"type":"integer","nullable":true,"example":44,"x-icf-stability":"contract"},"implementing_sector_id":{"type":"integer","nullable":true,"example":3,"x-icf-stability":"contract"},"implementing_sector_name":{"type":"string","nullable":true,"description":"Denormalized implementing sector name (e.g., \"State\", \"Utility\").","example":"Utility","x-icf-stability":"presentation"},"program_type_id":{"type":"integer","nullable":true,"example":5,"x-icf-stability":"contract"},"program_type_name":{"type":"string","nullable":true,"description":"Denormalized program type name (e.g., \"Rebate Program\", \"Grant Program\", \"Tax Credit\").","example":"Rebate Program","x-icf-stability":"presentation"},"new_or_existing":{"type":"string","nullable":true,"example":"Existing Buildings","x-icf-stability":"presentation"},"asset_categories":{"type":"array","items":{"type":"string"},"description":"Program category names.","example":["Financial Incentive","Other"],"x-icf-stability":"presentation"},"technology_categories":{"type":"array","items":{"type":"string"},"description":"Umbrella technology category names (deduped).","example":["Energy Efficiency","Renewables"],"x-icf-stability":"presentation"},"eligible_scope":{"type":"string","nullable":true,"example":"All commercial facilities within Austin Energy service territory.","x-icf-stability":"presentation"},"sectors":{"type":"array","description":"Eligible asset types (sectors). Empty array if program applies to all.","items":{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","example":45},"name":{"type":"string","example":"Commercial (Office)"}}},"example":[{"id":45,"name":"Commercial (Office)"},{"id":42,"name":"Commercial (Industrial)"}],"x-icf-stability":"contract"},"technologies":{"type":"array","description":"Eligible technologies.","items":{"type":"object","required":["id","name","category_id"],"properties":{"id":{"type":"integer","example":10},"name":{"type":"string","example":"LED Lighting"},"category_id":{"type":"integer","nullable":true,"example":1}}},"x-icf-stability":"contract"},"counties":{"type":"array","description":"Counties the program applies to. Empty array when the program is state-wide or federal.","items":{"type":"object","required":["id","name","state_id"],"properties":{"id":{"type":"integer","example":2938},"name":{"type":"string","example":"Travis County"},"state_id":{"type":"integer","nullable":true,"example":44}}},"x-icf-stability":"contract"},"next_steps":{"type":"string","nullable":true,"example":"Complete pre-approval form and submit to program administrator.","x-icf-stability":"presentation"},"minimum_funds":{"type":"string","nullable":true,"description":"Minimum funds available — free-text description (API_DESIGN.md §7.5). May include ranges, match requirements, or caveats.","example":null,"x-icf-stability":"presentation"},"maximum_funds":{"type":"string","nullable":true,"description":"Maximum funds available — free-text description (API_DESIGN.md §7.5).","example":"Grants shall not exceed $500,000 for any project. A 15% match is required.","x-icf-stability":"presentation"},"minimum_funds_amount":{"type":"string","nullable":true,"description":"Minimum funds as a semi-parseable amount string (API_DESIGN.md §7.5). May include commas.","example":"0","x-icf-stability":"contract"},"maximum_funds_amount":{"type":"string","nullable":true,"description":"Maximum funds as a semi-parseable amount string (API_DESIGN.md §7.5).","example":"500,000","x-icf-stability":"contract"},"website_url":{"type":"string","nullable":true,"example":"https://austinenergy.com/ee/commercial","x-icf-stability":"contract"},"document_url":{"type":"string","nullable":true,"example":"https://austinenergy.com/programs/commercial-rebates.pdf","x-icf-stability":"contract"},"successor_program_id":{"type":"integer","nullable":true,"description":"Non-null only when the program id you requested is archived as a duplicate of exactly one live program: the id keeps answering with that program's detail, and this names it. Every other field, `calculation_rules` included, describes the successor, except `incentive_program_id`, which stays the id you requested. `null` whenever the requested program answers for itself. Store the successor id for future calls.","example":null,"x-icf-stability":"contract"},"calculation_rules":{"type":"object","required":["has_approved","interaction_mode","requires_measure_selection","measure_count","rate_count","last_reviewed_at","effective_start","effective_end"],"description":"Summary of the approved calculation rules attached to a program. When `has_approved` is true and `measure_count > 0`, fetch the full structured rule set via `GET /v1/incentives/{id}/calculation-rules`.","properties":{"has_approved":{"type":"boolean","description":"True if any approved rule set exists for the program — even one that is not currently effective. Useful as a coarse \"is this program modeled?\" signal.","example":true,"x-icf-stability":"contract"},"interaction_mode":{"type":"string","enum":["calculator","menu","source_only"],"nullable":true,"description":"How a client drives this program — what UI to build. Derived mechanically from the currently-selected rule set, precedence top-down: `menu` = at least one priced_menu group exists; `calculator` = no menu, and at least one rate is computable given inputs/selections (any non-custom_formula / non-informational rate, including pick_one members and lesser_of/capped/tiered legs); `source_only` = neither — every rate is custom_formula/informational. A static property of the rule set, NOT of a request, and distinct from `value_details.computability` (how much of the value computes). Null when no rule set is selected (none approved, or none effective today).","example":"calculator","x-icf-stability":"contract"},"requires_measure_selection":{"type":"boolean","description":"True when this program needs a `measure_selections[]` entry before `POST /v1/incentives/{id}/calculate` will value it. Check it BEFORE you calculate: on a true, the naive first call — inputs only — answers `200` with `calculation_status: \"requires_selection\"` and `estimate: null`, however complete your `required_inputs` are. Call `GET /v1/incentives/{id}/calculation-rules` first and read `selection_groups[]` for the groups and the codes to choose between.\n\n`interaction_mode` does not answer this and cannot be made to: `menu` always needs a claimed line, while `calculator` covers both \"send inputs, get money\" and \"pick one of 48 alternatives first\". Measured on production 2026-08-31, every computable program on a Houston commercial multi-family address needed a selection on the naive first call — both `calculator` and `menu` programs were in that set — and every one paid out once a selection was supplied.\n\nFalse when no rule set is selected (`has_approved` false, or none effective today) — there is nothing to select from. Read a true as \"your first calculate needs measure_selections\", not as \"no number is possible\": a rule set pairing a group with ungrouped computable rates still returns a partial estimate.","example":true,"x-icf-stability":"contract"},"measure_count":{"type":"integer","example":1,"x-icf-stability":"contract"},"rate_count":{"type":"integer","example":1,"x-icf-stability":"contract"},"last_reviewed_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-05-13T17:22:10Z","x-icf-stability":"contract"},"effective_start":{"type":"string","format":"date","nullable":true,"example":"2026-01-01","x-icf-stability":"contract"},"effective_end":{"type":"string","format":"date","nullable":true,"example":"2026-12-31","x-icf-stability":"contract"}}}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"unauthorized"},"message":{"type":"string","example":"Invalid or expired API key."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"403":{"description":"API key lacks the required permission for this resource","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"forbidden"},"message":{"type":"string","example":"API key does not have access to 'incentives'."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"resource_not_found"},"message":{"type":"string","example":"Resource not found."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Get an incentive program","tags":["Incentives"]}},"/v1/incentives/{id}/calculation-rules":{"get":{"description":"Returns the full structured calculation rule set for a program: every measure with its eligibility criteria and typed rate definitions (per-unit, flat, percentage-of-cost, tiered, capped, or custom-formula). Monetary values inside `rates[].parameters` use `*_micros` precision (1¢ = 10,000 micros) — see API_DESIGN.md §7.5 carve-out.\n\n**Selectors** (mutually exclusive in practice; the default applies when neither is provided):\n- Default → the unique serving rule set whose `[effective_start, effective_end]` contains today.\n- `?as_of=YYYY-MM-DD` → the serving rule set effective on that date (for stable historical quotes). Never a `superseded` one: a date no serving set covers answers `404`.\n- `?rule_set_id=irs_…` → that exact rule set even if its status is `superseded` or `pending_reapproval`.\n\n**Every response names `current_rule_set_id`** — the rule set a request with no selector returns today (`null` when none is effective). A set a later approval replaced comes back `superseded: true`, for seeing what an earlier quote was built from: its figures are not what the program pays now, and `POST /v1/incentives/{id}/calculate` refuses it with `409 conflict`.\n\n**Serving** means `approved`, or `pending_reapproval` — a rule set whose source document changed after approval. Those come back with `stale: true` and the figures last approved from that source: a changed source is a disclosure, not a reason to stop answering. Rule sets in `draft` or `retired` status are never exposed, and a `superseded` one only by an explicit `rule_set_id`.\n\n**An archived program id** answers `404`, except one archived as a duplicate of exactly one live program: that id keeps answering with the live program's rule set, `incentive_program_id` unchanged and `successor_program_id` naming the program the rules belong to. Selectors then apply to the successor.","operationId":"IncentiveCalculationRulesController_getCalculationRules","parameters":[{"name":"id","required":true,"in":"path","description":"Numeric incentive program ID.","schema":{"example":256478,"type":"number"}},{"name":"rule_set_id","required":false,"in":"query","description":"Specific rule set ID (prefix `irs_`). Returns that rule set even if its status is `superseded` — the one status the default selector will not return — marked `superseded: true`, with `current_rule_set_id` naming the set the program serves today. Useful for seeing what an earlier quote was built from; `POST /v1/incentives/{id}/calculate` will not price a superseded ID.","schema":{"example":"irs_01J9XYZABC"}},{"name":"as_of","required":false,"in":"query","description":"Date in `YYYY-MM-DD` form. Returns the serving rule set effective on that date, never a `superseded` one. Mutually exclusive with `rule_set_id`.","schema":{"example":"2026-09-01"}}],"responses":{"200":{"description":"Structured calculation rule set.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"description":"Full structured calculation rule set for an incentive program. Monetary fields inside `rates[].parameters` use `*_micros` precision (1¢ = 10,000 micros) — see API_DESIGN.md §7.5 carve-out. The example below mirrors Georgia Power program 256478 (heat pump water heater rebate, multifamily existing-building schedule), as corrected #1372.","properties":{"data":{"type":"object","required":["rule_set_id","rule_revision","incentive_program_id","successor_program_id","interaction_mode","source","effective_start","effective_end","approved_at","stale","superseded","current_rule_set_id","measures","requires_measure_selection","selection_groups","disclosures"],"properties":{"rule_set_id":{"type":"string","example":"irs_01J9XYZABC","x-icf-stability":"contract"},"rule_revision":{"type":"integer","example":5,"x-icf-stability":"contract"},"incentive_program_id":{"type":"integer","example":256478,"x-icf-stability":"contract"},"successor_program_id":{"type":"integer","nullable":true,"description":"Non-null only when the program id you requested is archived as a duplicate of exactly one live program: the id keeps answering on that program's rule set, and this names it. `incentive_program_id` stays the id you requested; `rule_set_id`, `current_rule_set_id` and every figure belong to the successor. `null` whenever the requested program answers for itself. Store the successor id for future calls.","example":null,"x-icf-stability":"contract"},"interaction_mode":{"type":"string","enum":["calculator","menu","source_only"],"description":"How a client drives this rule set — what UI to build. Derived mechanically, precedence top-down: `menu` = at least one priced_menu group exists; `calculator` = no menu, and at least one rate is computable given inputs/selections (any non-custom_formula / non-informational rate, including pick_one members and lesser_of/capped/tiered legs); `source_only` = neither — every rate is custom_formula/informational. A static property of the rule set, NOT of a request; distinct from `value_details.computability` on /calculate, which answers \"how much of the value computes\". `menu` does not imply the absence of plain calculator inputs — render input collection whenever `required_inputs[]` is non-empty.","example":"calculator","x-icf-stability":"contract"},"source":{"type":"object","required":["document_id","document_url","document_sha256","section"],"properties":{"document_id":{"type":"string","example":"idoc_01J9PQRSTU"},"document_url":{"type":"string","example":"https://www.georgiapower.com/content/dam/georgia-power/pdfs/programs/heip-heat-pump-water-heater.pdf"},"document_sha256":{"type":"string","description":"Snapshot hash of the source document at approval time. Use this to detect drift if you cache responses — when it changes, our rule set will have flipped to `pending_reapproval` (and the default response will 404 until re-approval).","example":"b7d8e9c0a1f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8"},"section":{"type":"string","nullable":true,"example":"Let's talk about rebates!"}},"x-icf-stability":"contract"},"effective_start":{"type":"string","format":"date","nullable":true,"example":"2026-08-01","x-icf-stability":"contract"},"effective_end":{"type":"string","format":"date","nullable":true,"example":"2026-12-31","x-icf-stability":"contract"},"approved_at":{"type":"string","format":"date-time","example":"2026-05-13T17:22:10Z","x-icf-stability":"contract"},"stale":{"type":"boolean","example":false,"description":"True if the rule set is `pending_reapproval` (source PDF changed since approval). False otherwise. A stale rule set keeps serving — these are the figures last approved from that source, pending a re-read of the changed document.","x-icf-stability":"contract"},"superseded":{"type":"boolean","example":false,"description":"True when a later approval replaced this rule set, so its figures are NOT what the program pays today. Only an explicit `?rule_set_id=` returns one, for seeing what an earlier quote was built from; `current_rule_set_id` names the set that serves now. `POST /v1/incentives/{id}/calculate` refuses a superseded set with `409 conflict`.","x-icf-stability":"contract"},"current_rule_set_id":{"type":"string","nullable":true,"example":"irs_01J9XYZABC","description":"The rule set a request with no selector returns for this program today; `null` when none is effective today. Equal to `rule_set_id` on a default read. When the two differ, this response is not the program's current rules — refetch without a selector before quoting.","x-icf-stability":"contract"},"measures":{"type":"array","items":{"type":"object","required":["measure_id","name","description","source_section","source_page_start","source_page_end","eligibility","rates","alternative_group_id","alternative_group_label","measure_code","group_kind"],"properties":{"measure_id":{"type":"string","minLength":1,"maxLength":30,"description":"PR-C1 §2 — stable public measure ID. Opaque string ≤30 chars (the `irm_` prefix is a debugging nicety, not contract — never parse, never assume format). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across rule-set generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].measure_id` and `menu_groups[].items[].measure_id` on /calculate.","example":"irm_d95b06bc77","x-icf-stability":"contract"},"name":{"type":"string","example":"Heat Pump Water Heater","x-icf-stability":"presentation"},"description":{"type":"string","nullable":true,"example":"Rebate for installing a heat pump water heater (replaces a traditional tank electric water heater) under the Home Energy Improvement Program.","x-icf-stability":"presentation"},"source_section":{"type":"string","nullable":true,"example":"Let's talk about rebates!","x-icf-stability":"presentation"},"source_page_start":{"type":"integer","nullable":true,"example":1,"x-icf-stability":"contract"},"source_page_end":{"type":"integer","nullable":true,"example":2,"x-icf-stability":"contract"},"eligibility":{"type":"array","items":{"type":"object","required":["text","structured_tags","source_page","source_excerpt"],"properties":{"text":{"type":"string","example":"Existing tank electric water heater being replaced"},"structured_tags":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Descriptive labels for this criterion. Informational only: they are not evaluated, so a tagged criterion does not change the amount POST /v1/incentives/{id}/calculate returns. Eligibility the calculation DOES apply is carried in two other places, both narrower than they look. (1) A rate `applies_when` predicate — it binds only the rate it sits on, so an ungated sibling rate still pays. (2) A `disclosures[]` entry that is BOTH gated (`gate` non-null) AND program-wide (`measure_ids` null): v1 enforces only that combination, and a decidably-false gate yields calculation_status=not_eligible. A `disclosures[]` entry that is measure-scoped (`measure_ids` non-null) or ungated (`gate` null) is advisory and, like these tags, is not applied.\n\n**Canonical values.** Match these exactly:\n- `customer_type:commercial` — commercial customer\n- `customer_type:residential` — residential customer\n- `customer_type:industrial` — industrial customer\n- `building_type:multifamily` — multifamily building\n- `building_type:single_family` — single-family building\n- `is_existing_building:true` — existing building\n- `is_new_construction:true` — new construction\n- `income_qualified` — income-qualified program\n\nThe form is `namespace:value`: lowercase, `snake_case` value, `:` delimiter, no space after it. `income_qualified` is bare by design — it is a status, not a namespaced pair.\n\n**This list is not closed, and a client that treats it as closed will silently under-report.** Canonical means these are the spellings we write going forward and are migrating the corpus onto. It does not mean they are the only values you will receive: programs still carry pre-canonical variants of the same facts — `customer_type:multifamily` for the multifamily building tag, `customer_type: commercial` with a space, a bare `is_existing_building`, title-cased prose such as `Multi-family (4+ units)`. Matching only the eight values above will therefore miss programs that do carry the fact, and nothing will tell you so, because a tag you did not match is indistinguishable from a program nobody tagged. Read a canonical match as a positive signal and its absence as unknown, never as a negative.\n\n**Negation is not part of the vocabulary, and the corpus has not caught up.** A canonical tag only ever asserts what a program IS for; a program that excludes a segment is simply not tagged with it. Pre-canonical `not_*` and `…:not_eligible` values do still appear in the data today, so a substring or prefix match reads them backwards — `not_multifamily` contains `multifamily`. Compare whole tag values, never substrings.\n\n**No server-side filtering.** No request parameter on any endpoint accepts a tag; these are returned so you can filter in your own code over the rows you already have. Tag filtering is on our roadmap and is not built.","example":null},"source_page":{"type":"integer","nullable":true,"example":1},"source_excerpt":{"type":"string","nullable":true,"example":"If you already have a tank electric water heater, you're potentially a good fit for this upgrade."}}},"x-icf-stability":"presentation"},"rates":{"type":"array","items":{"oneOf":[{"type":"object","description":"Linear rate per unit of saving (e.g., $/kWh). Money in micros.","required":["kind","parameters","rate_id"],"properties":{"kind":{"type":"string","enum":["per_unit"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["amount_micros","unit"],"properties":{"amount_micros":{"type":"integer","description":"1¢ = 10,000 micros; $1.00 = 1,000,000 micros.","example":8500000},"unit":{"type":"string","example":"kWh","description":"Must be present in `admin_incentive_canonical_units`."}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Fixed dollar amount per applicable event (e.g., $75/device).","properties":{"kind":{"type":"string","enum":["flat"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["amount_micros"],"properties":{"amount_micros":{"type":"integer","example":750000}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Fraction of project cost, optionally capped (`max_micros`) or floored (`min_micros`). `pct` is in [0, 1] (50% = 0.5). `basis` names WHICH cost the fraction multiplies, and is the key you must supply in `inputs`; it appears in `required_inputs` too.","properties":{"kind":{"type":"string","enum":["percentage_of_cost"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["pct"],"properties":{"pct":{"type":"number","minimum":0,"maximum":1,"example":0.5},"max_micros":{"type":"integer","example":350000000},"min_micros":{"type":"integer"},"basis":{"type":"string","enum":["installed_cost_cents","eligible_cost_cents"],"default":"installed_cost_cents","description":"The cost input this percentage multiplies. Omitted means `installed_cost_cents` — the whole installed cost. `eligible_cost_cents` means the programme pays on a NARROWER base than total installed cost, and `unit_label` quotes the administrator's own definition of it (material cost only, incremental cost over a baseline, cost excluding tax or labour, and so on). Supplying total installed cost under `eligible_cost_cents` will over-state the rebate.","example":"eligible_cost_cents"}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Stepwise rate banded by an input quantity. `tiers` are ascending by `threshold`; each tier carries its own per-unit `amount_micros` and optional unit. `tier_mode` distinguishes utility-rebate \"banded\" math (input selects a single tier whose rate applies to the entire input) from income-tax \"graduated\" math (segment-by-segment accumulation).","properties":{"kind":{"type":"string","enum":["tiered"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["tier_mode","tiers"],"properties":{"tier_mode":{"type":"string","enum":["banded","graduated"],"description":"banded = single tier applies to the entire input (utility rebate norm). graduated = each segment computes at its tier rate and the results sum.","example":"banded"},"tiers":{"type":"array","minItems":1,"items":{"type":"object","required":["threshold","amount_micros"],"properties":{"threshold":{"type":"number","example":1.5},"amount_micros":{"type":"integer","example":10000000000},"unit":{"type":"string","example":"ton"}}}}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Composes a base rate with a per-rate cap (`cap_micros`).","properties":{"kind":{"type":"string","enum":["capped"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["cap_micros","base"],"properties":{"cap_micros":{"type":"integer","example":250000000},"base":{"type":"object","description":"Inner rate (per_unit / flat / percentage_of_cost / tiered)."}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Escape hatch for rates the calculator cannot evaluate deterministically. Carries the verbatim formula text and the variables it references.","properties":{"kind":{"type":"string","enum":["custom_formula"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["description"],"properties":{"description":{"type":"string","example":"Calculated via IPMVP Option C methodology with measured pre/post baseline."},"variables":{"type":"array","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"unit":{"type":"string","nullable":true},"description":{"type":"string","nullable":true}}}}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Returns the min of 2+ math-only alternatives. Optional `cap_micros` applies after the min. Inner alternatives must NOT carry rate metadata.","properties":{"kind":{"type":"string","enum":["lesser_of"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["alternatives"],"properties":{"alternatives":{"type":"array","minItems":2,"description":"Inner rates (per_unit / flat / percentage_of_cost / tiered / capped). Math-only nodes — no rate-level metadata.","items":{"oneOf":[{"type":"object","required":["kind","parameters"],"properties":{"kind":{"type":"string","enum":["per_unit"]},"parameters":{"type":"object","x-icf-stability":"contract","required":["amount_micros","unit"],"properties":{"amount_micros":{"type":"integer","description":"1¢ = 10,000 micros; $1.00 = 1,000,000 micros.","example":8500000},"unit":{"type":"string","example":"kWh","description":"Must be present in `admin_incentive_canonical_units`."}}}}},{"type":"object","required":["kind","parameters"],"properties":{"kind":{"type":"string","enum":["flat"]},"parameters":{"type":"object","x-icf-stability":"contract","required":["amount_micros"],"properties":{"amount_micros":{"type":"integer","example":750000}}}}},{"type":"object","required":["kind","parameters"],"properties":{"kind":{"type":"string","enum":["percentage_of_cost"]},"parameters":{"type":"object","x-icf-stability":"contract","required":["pct"],"properties":{"pct":{"type":"number","minimum":0,"maximum":1,"example":0.5},"max_micros":{"type":"integer","example":350000000},"min_micros":{"type":"integer"},"basis":{"type":"string","enum":["installed_cost_cents","eligible_cost_cents"],"default":"installed_cost_cents","description":"The cost input this percentage multiplies. Omitted means `installed_cost_cents` — the whole installed cost. `eligible_cost_cents` means the programme pays on a NARROWER base than total installed cost, and `unit_label` quotes the administrator's own definition of it (material cost only, incremental cost over a baseline, cost excluding tax or labour, and so on). Supplying total installed cost under `eligible_cost_cents` will over-state the rebate.","example":"eligible_cost_cents"}}}}},{"type":"object","required":["kind","parameters"],"properties":{"kind":{"type":"string","enum":["tiered"]},"parameters":{"type":"object","x-icf-stability":"contract","required":["tier_mode","tiers"],"properties":{"tier_mode":{"type":"string","enum":["banded","graduated"],"description":"banded = single tier applies to the entire input (utility rebate norm). graduated = each segment computes at its tier rate and the results sum.","example":"banded"},"tiers":{"type":"array","minItems":1,"items":{"type":"object","required":["threshold","amount_micros"],"properties":{"threshold":{"type":"number","example":1.5},"amount_micros":{"type":"integer","example":10000000000},"unit":{"type":"string","example":"ton"}}}}}}}},{"type":"object","required":["kind","parameters"],"properties":{"kind":{"type":"string","enum":["capped"]},"parameters":{"type":"object","x-icf-stability":"contract","required":["cap_micros","base"],"properties":{"cap_micros":{"type":"integer","example":250000000},"base":{"type":"object","description":"Inner rate (per_unit / flat / percentage_of_cost / tiered)."}}}}}],"discriminator":{"propertyName":"kind"}}},"cap_micros":{"type":"integer","example":1000000000}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Time-bounded percentage discount on a monthly utility bill. Math: pct × monthly_bill_cents × duration_months.","properties":{"kind":{"type":"string","enum":["utility_rate_discount"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["pct","duration_months","applies_to"],"properties":{"pct":{"type":"number","minimum":0,"maximum":1,"example":0.12},"duration_months":{"type":"integer","minimum":1,"example":60},"applies_to":{"type":"string","enum":["monthly_bill"],"description":"Phase 3 supports `'monthly_bill'` only. Demand/energy charge discounts require new canonical inputs not yet in the registry."}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Loan program (informational at Phase 3). Surfaces principal terms; the calculator does not amortize.","properties":{"kind":{"type":"string","enum":["loan_program"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["amount_per_unit_micros","description"],"properties":{"amount_per_unit_micros":{"type":"integer","example":5000000000},"max_per_application_micros":{"type":"integer","example":250000000000},"term_months":{"type":"integer","minimum":1,"example":84},"interest_rate_pct":{"type":"number","minimum":0,"maximum":1},"description":{"type":"string"},"variables":{"type":"array","nullable":true,"description":"Variable names referenced in `description` so the chat layer can echo the formula faithfully.","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"unit":{"type":"string","nullable":true},"description":{"type":"string","nullable":true}}}}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Loan guarantee (informational at Phase 3). Surfaces guarantee terms; the calculator does not evaluate.","properties":{"kind":{"type":"string","enum":["loan_guarantee"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["pct_of_principal","max_guarantee_micros","description"],"properties":{"pct_of_principal":{"type":"number","minimum":0,"maximum":1,"example":0.8},"max_guarantee_micros":{"type":"integer","example":100000000000},"description":{"type":"string"},"variables":{"type":"array","nullable":true,"description":"Variable names referenced in `description` so the chat layer can echo the formula faithfully.","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"unit":{"type":"string","nullable":true},"description":{"type":"string","nullable":true}}}}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}},{"type":"object","required":["kind","parameters","rate_id"],"description":"Tax credit / exemption (informational at Phase 3). At least one of `pct` / `amount_micros` is required.","properties":{"kind":{"type":"string","enum":["tax_credit"],"x-icf-stability":"contract"},"parameters":{"type":"object","x-icf-stability":"contract","required":["description"],"properties":{"pct":{"type":"number","minimum":0,"maximum":1,"example":0.3},"amount_micros":{"type":"integer","example":50000000000},"max_micros":{"type":"integer","example":250000000000},"basis":{"type":"string","enum":["installed_cost","eligible_cost","tax_liability","property_tax","other"]},"duration_years":{"type":"integer","minimum":1},"description":{"type":"string"},"variables":{"type":"array","nullable":true,"description":"Variable names referenced in `description` so the chat layer can echo the formula faithfully.","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"unit":{"type":"string","nullable":true},"description":{"type":"string","nullable":true}}}}}},"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"Stable public rate ID — opaque string ≤30 chars (never parse the prefix). Stable for the lifetime of its `rule_set_id` (approved rule sets are math-immutable); reapproval/supersede mints a new rule set with NEW IDs — preservation across generations is best-effort, not guaranteed. Scope cache keys by `rule_set_id`. Matches `breakdown[].rate_id` on /calculate.","example":"irt_06bc77f55a"},"applies_when":{"type":"object","description":"Optional predicate. When set and evaluating to false the rate is skipped at calculation time. Null = unconditional.","example":{"input":"secondary_steam_eligible","equals":true},"nullable":true,"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","description":"Canonical input keys the calculator needs to evaluate this rate.","example":["installed_cost_cents"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}},"unit_label":{"type":"string","nullable":true,"x-icf-stability":"presentation","description":"Verbatim copy of the rate phrasing from the source PDF. PRESENTATION — do NOT parse a unit out of it. The source commonly quotes a different denomination from the one the API accepts (a `/kW` label against a `W` input); `required_input_details[].unit` is the machine-readable answer.","example":"50% of cost, up to $1,000"},"notes":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":null},"source_page":{"type":"integer","nullable":true,"x-icf-stability":"contract","example":1},"source_excerpt":{"type":"string","nullable":true,"x-icf-stability":"presentation","example":"We offer a rebate of 50% of cost up to $1,000 for installing a heat pump water heater"},"source_url":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"source_sha256":{"type":"string","nullable":true,"x-icf-stability":"contract","example":null},"benefit_form":{"type":"string","enum":["rebate","tax_credit","tax_exemption","loan_program","loan_guarantee","direct_install","rate_discount","external_schedule","engineered_per_customer","other"],"x-icf-stability":"contract","description":"Benefit form (orthogonal to `kind`). Defaults to `rebate` when omitted."}}}],"discriminator":{"propertyName":"kind"}},"x-icf-stability":"contract"},"alternative_group_id":{"type":"string","nullable":true,"description":"Phase B — set when this measure belongs to an alternative group; null otherwise.","x-icf-stability":"contract"},"alternative_group_label":{"type":"string","nullable":true,"description":"Phase B — set when this measure belongs to an alternative group; null otherwise.","x-icf-stability":"presentation"},"measure_code":{"type":"string","nullable":true,"description":"Phase B — per-member identifier within an alternative group; null when the measure is not grouped.","x-icf-stability":"contract"},"group_kind":{"type":"string","nullable":true,"enum":["pick_one","priced_menu"],"description":"Catalog Increment 1 — how the alternative group behaves. null for a non-grouped measure; `pick_one` values one selected member; `priced_menu` is a multi-select equipment catalog whose members never auto-sum (each appears in /calculate as a status=menu_item row contributing nothing).","x-icf-stability":"contract"}}},"x-icf-stability":"contract"},"requires_measure_selection":{"type":"boolean","description":"True when `selection_groups[]` is non-empty — this rule set needs a `measure_selections[]` entry before `POST /v1/incentives/{id}/calculate` will value the alternatives. The same field, with the same meaning, rides the `calculation_rules` block on `POST /v1/properties/incentives` and `GET /v1/incentives/{id}`, so you can branch on it one request earlier and fetch this response only for the groups themselves.\n\nIt asserts a property of the RULE SET, never of a request — a program requires a selection whether or not the call in hand supplied one. Both fields also ride /calculate's embedded `rules` envelope, where they sit BESIDE `required_selections[]` rather than competing with it: `required_selections[]` is the per-request signal and empties as you satisfy it, while these do not move.","example":true,"x-icf-stability":"contract"},"selection_groups":{"type":"array","items":{"type":"object","required":["group_id","label","kind","option_count"],"description":"One alternative group on this rule set — a choice `POST /v1/incentives/{id}/calculate` needs named in `measure_selections[]` before it will value the alternatives.\n\nThe OPTIONS are the `measures[]` on this same response whose `alternative_group_id` equals `group_id`; each carries the `measure_code` (or `measure_id`) a selection entry names. Only the count is repeated here, so the two can never disagree about membership.\n\nHow many entries a request needs depends on `kind`, and the two rules are NOT the same:\n\n- `pick_one` — conjunctive. EVERY pick_one group needs its own entry; each one left out comes back in `required_selections[]`.\n- `priced_menu` — disjunctive across menus. Claiming ANY line from ANY menu clears the menu blocker; the members you did not claim stay deliberate non-selections (`status: \"menu_item\"`, zero contribution).","properties":{"group_id":{"type":"string","description":"Matches `measures[].alternative_group_id`, and the `group_id` a `measure_selections[]` entry carries.","example":"grp_henc_heat_pump","x-icf-stability":"contract"},"label":{"type":"string","description":"The group's label from the source document; empty string when it has none.","example":"Heat pump efficiency tier","x-icf-stability":"presentation"},"kind":{"type":"string","enum":["pick_one","priced_menu"],"description":"`pick_one` — value exactly one member. `priced_menu` — a multi-select equipment catalog whose members never auto-sum. A group whose members store no kind is a legacy `pick_one` group and is reported as one.","example":"pick_one","x-icf-stability":"contract"},"option_count":{"type":"integer","description":"Selectable members — those carrying a `measure_code`, which is what /calculate lists in `required_selections[].options[]` and `menu_groups[].items[]`.","example":4,"x-icf-stability":"contract"}}},"description":"Always present (empty when the rule set has no alternative groups). Every group on the set, `pick_one` and `priced_menu` alike, ordered by `group_id` — a rule set with two groups gets two entries, and both must be satisfied under the per-kind rules on each entry.","x-icf-stability":"contract"},"disclosures":{"type":"array","items":{"type":"object","required":["code","title","text","measure_ids","citation","gate","cap_micros"],"description":"A set-level program disclosure carried verbatim from its cited source — used for material program-wide rules with no faithful structured home (e.g. a \"$100,000 per building per year up to 50% of equipment cost\" cap, a lifetime per-account claim limit, a funds-exhausted term). Multiple entries are allowed, including multiple entries per code. ADVISORY by default: /calculate does not clamp the estimate to a plain entry — when an estimate-affecting disclosure (`program_cap` / `claim_limit`) exists alongside a non-null estimate, /calculate flags it with one `limitations[]` entry of code `unenforced_program_limits` instead of duplicating the text. Two fields make ONE entry enforced instead: `gate` (eligibility) and `cap_micros` (a dollar ceiling over this entry’s `measure_ids`). An entry carrying `cap_micros` is enforced and therefore does NOT contribute to `unenforced_program_limits`.","properties":{"code":{"type":"string","enum":["program_cap","funding_availability","claim_limit","measurement_requirement","exclusivity_constraint","eligibility_condition","other_material_term"],"description":"Machine-readable disclosure classification, extensible additively. `program_cap` — program-wide dollar cap not structurally enforced. `funding_availability` — first-come / funds-exhausted / annual-capacity terms. `claim_limit` — per-account/per-building/lifetime count or claim limits. `measurement_requirement` — mandated measurement/verification protocol. `exclusivity_constraint` — mutually exclusive paths/options. `eligibility_condition` — material conditional eligibility with no structured predicate home. `other_material_term` — escape hatch.","x-icf-stability":"contract"},"title":{"type":"string","description":"Short human label.","example":"Combined annual rebate cap per building","x-icf-stability":"presentation"},"text":{"type":"string","description":"Verbatim, source-faithful disclosure text (pinned to the cited source; never parse).","example":"Maximum rebate is $100,000 per building per year up to 50% of equipment cost, unless otherwise noted.","x-icf-stability":"presentation"},"measure_ids":{"type":"array","items":{"type":"string","maxLength":30},"nullable":true,"description":"Measure scope: public `measure_id`s (irm_…) of THIS rule set the term applies to; null = program-wide. Never empty.","x-icf-stability":"contract"},"citation":{"type":"object","required":["document_url","document_sha256","section"],"description":"Immutable evidence pair for the verbatim text: the rule set’s source document URL + approval-time snapshot sha by default, or the entry’s own source override URL + its write-time snapshot sha when the term was sourced from a different document (e.g. a T&C PDF).","properties":{"document_url":{"type":"string","format":"uri"},"document_sha256":{"type":"string","description":"sha256 of the cited document snapshot. Always non-null."},"section":{"type":"string"},"page":{"type":"integer","minimum":1,"nullable":true}},"x-icf-stability":"contract"},"gate":{"type":"object","nullable":true,"required":["predicate","inputs"],"description":"Eligibility enforcement (2026-06-26) — the enforced predicate (true = ELIGIBLE; same predicate shape as a rate’s `applies_when`) plus the canonical-input keys it reads. null for advisory disclosures (presence of a gate is the enforceable-vs-advisory switch). When present, /calculate enforces it: a decidably-false result yields calculation_status=not_eligible; an absent gate input computes with an `unverified_eligibility` limitation. v1 enforces program-wide gates only (measure_ids = null).","properties":{"predicate":{"type":"object","description":"Recursive predicate. Operator keys are mutually exclusive at each node. See `phase-1-schema-and-extraction.md` for the full grammar.","example":{"input":"secondary_steam_eligible","equals":true}},"inputs":{"type":"array","items":{"type":"string"},"example":["is_existing_building"]}},"x-icf-stability":"contract"},"cap_micros":{"type":"integer","nullable":true,"example":100000000000,"description":"ENFORCED dollar ceiling over this entry’s `measure_ids`, in micros (1¢ = 10,000 micros; $100,000 = 100000000000). /calculate sums the contributions of those measures, clamps the category to this ceiling ONCE, and only then applies the program-wide ceilings — so a program that publishes \"$100,000 each for lighting and non-lighting projects\" pays up to $100,000 per category rather than $100,000 across both. When it binds, one `limitations[]` entry of code `estimate_capped_at_program_limit` names this entry’s `title` and amount. null = advisory (the default; `measure_ids` is always non-null when this is set).","x-icf-stability":"contract"}}},"description":"Always present (empty for most programs). Honest, citable surface for material program-wide rules (e.g. a per-building annual cap) that have no faithful structured home elsewhere in the rule set.","x-icf-stability":"contract"}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"unauthorized"},"message":{"type":"string","example":"Invalid or expired API key."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"403":{"description":"API key lacks the required permission for this resource","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"forbidden"},"message":{"type":"string","example":"API key does not have access to 'incentives'."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"resource_not_found"},"message":{"type":"string","example":"Resource not found."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"422":{"description":"Request validation failed","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string","example":"Request validation failed."},"details":{"nullable":true,"example":{"sort_field":["Invalid enum value. Expected 'admin_name' | 'incentive_name' | 'updated_at' | 'start_date' | 'end_date', received 'bogus'"]}}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Get structured calculation rules for an incentive program","tags":["Incentives"]}},"/v1/incentives/{id}/calculate":{"post":{"description":"Runs the approved structured calculation rule set against the supplied inputs and returns a per-rate breakdown, applied total, and required-inputs surface.\n\nInputs are validated against the canonical-input registry — wrong data types or out-of-range values return `422 validation_error` before any math runs. Unknown input keys are silently dropped for forward compatibility with newly-added canonical inputs.\n\n**Selectors** (mutually exclusive in practice; default applies when neither is provided):\n- Default → the serving rule set whose `[effective_start, effective_end]` contains today — `approved`, or `pending_reapproval` (its source document changed after approval), which prices from the last approved figures and rides `rules.stale: true`.\n- `?as_of=YYYY-MM-DD` → the serving rule set effective on that date (for stable historical quotes), never a `superseded` one.\n- `?rule_set_id=irs_…` → that exact rule set while it still serves (`approved`, or `pending_reapproval` with `rules.stale: true`). A `superseded` ID — a set a later approval replaced — answers `409 conflict` and computes nothing; `details.current_rule_set_id` names the set to quote against instead (`null` when none is effective today).\n\n`asks_about[]` (in the request body) is the only signal that drives the `not_specified[]` response array — it represents customer questions the program does not answer.\n\n**When we cannot price a program, that is a `200`, not a `404`.** An active program we hold no usable rule set for answers `200` with `calculation_status: \"not_calculable\"`, `estimate: null`, `interaction_mode: null` and a `not_calculable_reason` — the same envelope a reviewed-but-source-only program returns. `404 resource_not_found` is reserved for a program ID that does not exist, an inactive program, and an explicit `?rule_set_id=` that does not resolve. So a `404` means the request is wrong; anything about coverage arrives as a `200` you can read.\n\n**An archived program id** that was archived as a duplicate of exactly one live program is not a `404`: it prices on that program's rule set, exactly as a request for the live program would, with `incentive_program_id` unchanged and `successor_program_id` naming the program that priced. Selectors apply to the successor.","operationId":"IncentiveCalculateController_calculate","parameters":[{"name":"id","required":true,"in":"path","description":"Numeric incentive program ID.","schema":{"example":256478,"type":"number"}},{"name":"rule_set_id","required":false,"in":"query","description":"Specific rule set ID (prefix `irs_`). Prices against that exact rule set, so a quote cannot drift to a different set between your `/calculation-rules` read and this call. A `superseded` ID answers `409 conflict` with `details.current_rule_set_id`: refetch `/calculation-rules` and quote against the current set.","schema":{"example":"irs_01J9XYZABC"}},{"name":"as_of","required":false,"in":"query","description":"Date in `YYYY-MM-DD` form. Selects the serving rule set effective on that date, never a `superseded` one. Mutually exclusive with `rule_set_id`.","schema":{"example":"2026-09-01"}}],"requestBody":{"required":true,"description":"Calculator inputs and customer asks.","content":{"application/json":{"schema":{"type":"object","description":"Calculator inputs and (optionally) customer asks. Reproducibility selectors (`as_of`, `rule_set_id`) live in the query string per spec §1.6 — they describe *which* rule set to use, not what to compute.\n\nInputs are validated against the canonical-input registry before any math runs (spec §1.5.1). Unknown input keys are silently dropped for forward compatibility. Values whose JSON shape mismatches the registry data type (e.g., a decimal supplied for a `cents` input) return `422 validation_error` with per-field `details`.","properties":{"inputs":{"type":"object","description":"Map of canonical-input key → value. Optional — an omitted `inputs` is treated as `{}` (useful for flat-rate programs and selection-only menu claims whose quantities ride on `measure_selections[].inputs`); rates that need inputs then surface the normal `missing_inputs` / `requires_inputs` semantics. Permitted JSON types per `data_type` (spec §1.5.1):\n- `cents`, `integer_count`: integer or integer-shaped string (≥0, ≤2^53).\n- `decimal_quantity`: number or decimal-shaped string with ≤7 fractional digits (numbers ≥ 1e9 MUST be sent as strings to avoid IEEE-754 precision loss).\n- `boolean`: strict JSON `true` / `false`.\n- `string`: trimmed, non-empty, ≤64 chars. Some inputs are enums.","additionalProperties":{"oneOf":[{"type":"number"},{"type":"string"},{"type":"boolean"}]},"example":{"installed_cost_cents":200000,"dwelling_units":1,"customer_type":"residential"}},"asks_about":{"type":"array","description":"Optional customer-supplied metrics. Drives the `not_specified[]` response array when the program has no rate that addresses an asked metric.","items":{"type":"string","minLength":1},"example":["savings_per_kwh"]},"measure_selections":{"type":"array","description":"Selections for alternative groups. Exactly ONE of `measure_code` / `measure_id` names the measure per entry.\n\n**pick_one groups (Phase B)**: one entry per group disambiguates which alternative to value against the global `inputs` axis. `rate_id` and `inputs` are not allowed on pick_one entries.\n\n**priced_menu groups (PR-M)**: each entry CLAIMS one menu line. `inputs` carries line-scoped canonical inputs (same value union and validation as global `inputs`), merged over the global axis when the line evaluates.\n\n**Which keys go here is published, not guesswork.** Read `menu_groups[].items[].rates[].required_input_details[]` (same field, same builder, under `rules.measures[].rates[]` and on /calculation-rules) and branch on `scope`: send the `line` keys here and the `project` keys once in the top-level `inputs`. A project key sent here is rejected with `input_not_allowed` naming the field — whether or not it equals the global value — because a claimed line may not restate a fact about the project. Two families are project-scoped: a key the rate uses only as an `applies_when` predicate (`compliance_path` — a project is on one compliance path, not one per line), and an input that is a project-wide fact even where the rate’s math multiplies by it (`dwelling_units` — how many apartments the building has is not a per-line quantity). The split is not inferable from the key name or `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by.\n\n`rate_id` explicitly selects a rate on multi-rate items (see `menu_groups[].items[].rates[]`); when omitted, the line computes only if exactly one rate matches decidably — otherwise the response carries line-level `requires_selection` rows plus an `ambiguous_rate_selection` limitation. Two entries on the same measure with different `rate_id`s are legal; duplicate `(group, measure, rate)` tuples are rejected.\n\n**Multi-line claims**: when MORE THAN ONE menu line is claimed (request-wide, across groups), each claimed line MUST carry the rate’s math inputs whose `scope` is `line` (e.g. `device_count`, `installed_cost_cents`) inside its own `inputs` — the global-axis fallback applies to single-line claims only, so one shared global quantity is never silently applied to multiple claimed items. Missing ones reject with per-line `menu_line_missing_inputs` entries naming the exact field.\n\nA `project`-scoped key is the exception in BOTH directions: never accepted per line, and never demanded per line. One statement of it feeds every claimed line, which is what a per-dwelling-unit menu means — 25 measures at $N/unit over the same 240 apartments is the honest total, not a double-count. So `scope` alone tells you where each key goes; there is no key that belongs in both places, and none that is demanded in one and refused in the other. The predicate family has one carve-out, already reflected in the published `scope`: where the rate’s own math reads the same key (a rate gated on `hp_rating` that also prices per `hp`) it is a per-line fact and is required per line like any other line input.\n\nInvalid entries return `422 validation_error` whose `details.measure_selections[]` carries per-line `{index, code, field, current_rule_set_id}` with codes: `unknown_id` (unknown/stale group/measure/rate IDs — refetch /calculation-rules), `rate_not_on_measure`, `line_not_calculable`, `inputs_not_allowed_for_pick_one`, `rate_id_not_allowed_for_pick_one`, `duplicate_line`, `input_not_allowed`, `invalid_input`, `menu_line_missing_inputs`.","items":{"type":"object","required":["group_id"],"oneOf":[{"required":["measure_code"],"not":{"required":["measure_id"]}},{"required":["measure_id"],"not":{"required":["measure_code"]}}],"properties":{"group_id":{"type":"string","minLength":1,"maxLength":64},"measure_code":{"type":"string","minLength":1,"maxLength":64},"measure_id":{"type":"string","minLength":1,"maxLength":30,"description":"PR-C1 §2 stable public measure ID — accepted as the alternative to measure_code (exactly one of the two)."},"rate_id":{"type":"string","minLength":1,"maxLength":30,"description":"Explicit rate selector for priced_menu lines. Optional on single-rate items; required to resolve multi-rate items whose predicates cannot discriminate."},"inputs":{"type":"object","description":"Line-scoped canonical inputs (priced_menu lines only). Merged over the global inputs axis for this line. For a SINGLE claimed menu line, missing keys are NOT a 422 — the line surfaces a missing_inputs row and the overall status is partial. When MORE THAN ONE menu line is claimed, each line must carry its rate’s math inputs whose `required_input_details[].scope` is `line`; missing ones reject with `menu_line_missing_inputs`. Keys whose `scope` is `project` (e.g. `compliance_path`, `dwelling_units`) belong in the top-level `inputs`, are never required here, and are rejected here with `input_not_allowed`.","additionalProperties":{"oneOf":[{"type":"number"},{"type":"string"},{"type":"boolean"}]}}},"additionalProperties":false},"example":[{"group_id":"irrigation_device_rebates","measure_code":"drip_eco_indicators","rate_id":"irt_06bc77f55a","inputs":{"device_count":10}}]}},"additionalProperties":false}}}},"responses":{"200":{"description":"Calculation envelope per spec §1.4.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"description":"Calculation response envelope per Phase 3 spec §1.4. `calculation_status` is the global rollup; `breakdown[]` is the per-rate detail; `required_inputs[]` documents the full rule-set input contract, and `blocking_missing_inputs[]` is the chat-prompt subset (keys whose absence currently blocks computation). Money is integer cents at the response boundary (BigInt micros internally).","properties":{"data":{"type":"object","required":["incentive_program_id","successor_program_id","rule_set_id","rule_revision","source_document_sha256","calculation_status","interaction_mode","not_calculable_reason","value_summary","value_details","estimate","breakdown","required_inputs","blocking_missing_inputs","not_specified","limitations","disclosures","rules","source_citations","required_selections","menu_groups","eligibility_failures"],"properties":{"incentive_program_id":{"type":"integer","example":256478,"x-icf-stability":"contract"},"successor_program_id":{"type":"integer","nullable":true,"description":"Non-null only when the program id you requested is archived as a duplicate of exactly one live program: the id keeps pricing on that program's rule set, and this names it. `incentive_program_id` stays the id you requested; `rule_set_id`, the estimate and every other rule-set field belong to the successor — including a `not_calculable` answer when the successor has no rule set to price. `null` whenever the requested program answers for itself. Store the successor id for future calls.","example":null,"x-icf-stability":"contract"},"rule_set_id":{"type":"string","nullable":true,"description":"Null when we hold no approved rule set for this program — there is no rule set to identify. Non-null on every other answer.","example":"irs_654e2c1319","x-icf-stability":"contract"},"rule_revision":{"type":"integer","minimum":0,"nullable":true,"description":"Null alongside a null `rule_set_id`.","example":5,"x-icf-stability":"contract"},"source_document_sha256":{"type":"string","minLength":64,"maxLength":64,"nullable":true,"description":"Snapshot hash captured at approval. Drift signal for cached responses — when it changes, the rule set has flipped to `pending_reapproval` (and the default selector answers `not_calculable` until re-approval). Null alongside a null `rule_set_id`.","example":"b7d8e9c0a1f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8","x-icf-stability":"contract"},"calculation_status":{"type":"string","enum":["calculated","partial","requires_inputs","not_calculable","requires_selection","not_eligible"],"description":"Global rollup (spec §1.1, §1.3 + PR-M §4). `calculated` = every applicable rate ran (claimed menu lines count; UNCLAIMED menu members are a deliberate non-selection, not a blocker); `partial` = at least one applied + at least one blocker (missing inputs, a non-computable leg, an ambiguous menu line, or an entirely unselected menu beside computing rates); `requires_inputs` = no rate could compute because of missing inputs; `not_calculable` = no rate is computable at all (custom_formula / informational only); `requires_selection` = a pick-one group lacks a measure_selection OR a priced_menu has zero claimed lines, and nothing else computes (PR-M moved unselected menus here from not_calculable); `not_eligible` = a program-wide eligibility gate resolved decidably-false, so the project does NOT qualify (estimate null, breakdown empty, `eligibility_failures[]` cites the failed gate(s); HTTP stays 200 — distinct from not_calculable).","example":"calculated","x-icf-stability":"contract"},"interaction_mode":{"type":"string","enum":["calculator","menu","source_only"],"nullable":true,"description":"PR-C1 §1 — echo of the selected rule set’s static interaction mode (same value /calculation-rules returns for this rule set; see that endpoint for the full precedence table). A property of the RULE SET, never of this request: request states stay on `calculation_status` and `required_selections[]`. `null` means we hold no approved rule set for this program at all — the same meaning this field carries on POST /v1/properties/incentives and GET /v1/incentives/{id}/calculation-rules.","example":"calculator","x-icf-stability":"contract"},"not_calculable_reason":{"type":"string","enum":["no_approved_rule_set","no_effective_rule_set","source_only"],"nullable":true,"description":"Why no figure could be produced, when `calculation_status` is `not_calculable`; `null` on every other status, and on a `not_calculable` we cannot attribute further. `source_only` = we reviewed this program and its published rules carry no math. `no_approved_rule_set` = we have not priced it at all. `no_effective_rule_set` = we hold approved rule sets for it and none covers the requested date — which is why `calculation_rules.has_approved` is not a safe precondition for calling this endpoint. None of the three is an error: the program exists, we just cannot put a number on it.","example":"no_approved_rule_set","x-icf-stability":"contract"},"value_summary":{"type":"string","description":"Short one-line digest derived from the rule set (e.g., \"$0.85/kWh, $25/therm\"). Stable surface for chat narrative; independent of inputs. For richer structured signal, see `value_details`.","example":"50% of cost, up to $350 per dwelling unit","x-icf-stability":"presentation"},"value_details":{"type":"object","required":["computability","display_text","primary_value_type","value_types","primary_benefit_form","benefit_forms","primary_basis"],"description":"Null when no rule set was resolved (`interaction_mode: null`) — there is nothing to characterize. Reporting `source_only` here would assert we reviewed a program we have not.","properties":{"computability":{"type":"string","enum":["fully_computable","partially_computable","source_only","not_estimable_externally"],"description":"How much of this program we hold a calculation model for — a property of the RULE SET, not of your request.\n\n`fully_computable` — every rate either computed or could have, given inputs and selections. `partially_computable` — some rates compute and others never will (custom_formula / informational), or inputs are still missing on a rate that would. `source_only` — every rate is custom_formula or informational; no math is possible for this program at all. `not_estimable_externally` — every rate references an undisclosed external schedule.\n\nRates the request did NOT exercise — unclaimed `menu_item` rows, unselected `requires_selection` members — count by their rate kind, not as gaps: declining a menu line is not missing coverage. A fully-priced menu therefore reports the same value whether you claim every line, one line, or none.\n\n**Not the \"is there a number in THIS response\" field.** `fully_computable` can accompany a null `estimate` (a menu with nothing claimed, a pick-one group with nothing selected, a decidably-ineligible project). Read `calculation_status` and `estimate` for that; read this to decide whether building against `/calculate` is worth it for the program.","example":"fully_computable","x-icf-stability":"contract"},"display_text":{"type":"string","description":"System-generated source-grounded one-liner for the whole program. May echo source phrasing but is not a verbatim quote.","example":"Direct rebate up to $100,000, calculated as lesser of $4/ccf saved, installed cost, or the cap","x-icf-stability":"presentation"},"primary_value_type":{"type":"string","enum":["direct_cash","avoided_cost","bill_discount","gross_tax_benefit","available_capital","informational_value"],"example":"direct_cash","x-icf-stability":"contract"},"value_types":{"type":"array","items":{"type":"string","enum":["direct_cash","avoided_cost","bill_discount","gross_tax_benefit","available_capital","informational_value"]},"example":["direct_cash"],"x-icf-stability":"contract"},"primary_benefit_form":{"type":"string","description":"Open-ended classification of the benefit form. Documented common values: 'rebate', 'loan_program', 'loan_guarantee', 'tax_credit', 'rate_discount', 'direct_install', 'informational'.","example":"rebate","x-icf-stability":"contract"},"benefit_forms":{"type":"array","items":{"type":"string"},"example":["rebate"],"x-icf-stability":"contract"},"primary_basis":{"type":"string","description":"Open-ended classification of the dominant calculation basis. Common values match `rate_kind` (per_unit, percentage_of_cost, lesser_of, capped, tiered, utility_rate_discount, loan_program, loan_guarantee, tax_credit, custom_formula, flat).","example":"lesser_of","x-icf-stability":"contract"}},"nullable":true,"x-icf-stability":"contract"},"estimate":{"type":"object","nullable":true,"required":["amount_cents"],"description":"Calculated total in integer cents: each category cap is applied to that category's own subtotal, the `applied` contributions are then summed, any program-wide cap is applied to that sum, and the result is HALF_EVEN rounded at the response boundary. Because caps apply after the lines are totalled and each `breakdown[]` line is rounded on its own, the lines need not add up to this figure; a cap that reduced it is named in `limitations[]` (code `estimate_capped_at_program_limit`). Null when no rate is applied.","properties":{"amount_cents":{"type":"integer","example":35000}},"x-icf-stability":"contract"},"breakdown":{"type":"array","description":"Always present (possibly empty). One row per rate in the rule set, regardless of status — chat tooling reads this for narrative.","items":{"type":"object","required":["measure_id","rate_id","measure_name","rate_kind","contribution_cents","status","rate_parameters","citation","formula_display","inputs_used"],"description":"Per-rate row in the calculation breakdown. `contribution_cents` is present only when `status=applied` (otherwise `null`). For `status=custom_formula` / `informational`, `source_text` and `variables` carry the narrative surface chat tooling reads to explain the rate to the customer.","properties":{"measure_id":{"type":"string","minLength":1,"maxLength":30,"description":"PR-C1 §2 — stable public measure ID. Required and non-null on every row: correlate rows to `/calculation-rules` `measures[].measure_id` (and `menu_groups[].items[]`) without tuple-matching on names. Opaque, ≤30 chars, stable within this `rule_set_id`; new IDs on reapproval/supersede.","example":"irm_d95b06bc77","x-icf-stability":"contract"},"rate_id":{"type":"string","minLength":1,"maxLength":30,"description":"PR-C1 §2 — stable public rate ID. Required and non-null on every row; matches `measures[].rates[].rate_id` on /calculation-rules. Same opacity and stability scope as `measure_id`.","example":"irt_06bc77f55a","x-icf-stability":"contract"},"measure_name":{"type":"string","example":"Heat Pump Water Heater","x-icf-stability":"presentation"},"rate_kind":{"type":"string","description":"Schema kind of the rate (matches `rates[].kind` in /calculation-rules).","example":"percentage_of_cost","x-icf-stability":"contract"},"contribution_cents":{"type":"integer","nullable":true,"description":"Applied contribution in integer cents (HALF_EVEN rounded). Null when status ≠ `applied`.","example":35000,"x-icf-stability":"contract"},"status":{"type":"string","enum":["applied","not_applicable","missing_inputs","custom_formula","informational","requires_selection","menu_item"],"description":"Per-rate outcome. `menu_item` is an UNCLAIMED priced_menu member: it exposes its per-item rate/formula/citation/source_text but contributes nothing (contribution_cents=null) and is never summed. PR-M: members claimed via measure_selections[] evaluate like any rate (applied / missing_inputs / not_applicable), and an ambiguous multi-rate claim emits `requires_selection` rows for every candidate.","example":"applied","x-icf-stability":"contract"},"missing_inputs":{"type":"array","items":{"type":"string"},"description":"Canonical-input keys the rate needs that were absent. Present only when `status=missing_inputs`.","example":["installed_cost_cents"],"x-icf-stability":"contract"},"source_text":{"type":"string","description":"Verbatim PDF excerpt — present for `custom_formula` / `informational` rows so chat narrative has the exact wording. This is the only field on a breakdown row guaranteed to be verbatim source text; `formula_display` is system-generated.","x-icf-stability":"presentation"},"variables":{"type":"array","items":{"type":"object","required":["name","unit","description"],"description":"Variable referenced by a `custom_formula` or `informational` rate. Surfaced verbatim from the rate parameters so chat tooling can render narrative without a second fetch.","properties":{"name":{"type":"string","example":"baseline_kwh"},"unit":{"type":"string","nullable":true,"example":"kWh"},"description":{"type":"string","nullable":true,"example":null}}},"description":"Variables referenced by the rate. Present for non-computable rows that surface a formula. Describes human-facing narrative variables — distinct from `inputs_used`, which is the set of canonical-input keys the calculator/predicate reads.","x-icf-stability":"presentation"},"rate_parameters":{"description":"Full `parameters` JSON for the rate (shape varies by `rate_kind`).","x-icf-stability":"contract"},"benefit_form":{"type":"string","description":"Benefit-form classification (elided when default `'rebate'`).","example":"tax_credit","x-icf-stability":"contract"},"citation":{"type":"object","required":["document_url","document_sha256","section"],"description":"Citation back to the source the rate was extracted from. `document_sha256` is the snapshot hash captured at approval — mismatch with the live document flips the rule set to `pending_reapproval`. For a rate corrected from a live source page (`source_url` override), `document_url` is that live page and `document_sha256` is its captured snapshot hash, or `null` when no snapshot was captured — the two always refer to the same artifact.","properties":{"document_url":{"type":"string","format":"uri","example":"https://www.georgiapower.com/content/dam/georgia-power/pdfs/programs/heip-heat-pump-water-heater.pdf"},"document_sha256":{"type":"string","minLength":64,"maxLength":64,"nullable":true,"example":"b7d8e9c0a1f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8"},"section":{"type":"string","example":"Let's talk about rebates!"},"page":{"type":"integer","minimum":1,"nullable":true,"example":1}},"x-icf-stability":"contract"},"formula_display":{"type":"string","description":"Deterministic system-generated formula representation answering \"how is it calculated?\". Uses operators + canonical-input names (e.g. `\"min($4 × ccf_saved, installed_cost_cents, $100,000)\"`). For `custom_formula` rows where math can't be rendered, falls back to the extractor's `parameters.description` string — source-grounded but NOT guaranteed verbatim. The verbatim contract belongs to `source_text`.","example":"min($4 × ccf_saved, installed_cost_cents, $100,000)","x-icf-stability":"presentation"},"inputs_used":{"type":"array","items":{"type":"string"},"description":"Canonical-input keys this rate's math/predicate reads. Distinct from `variables[]`, which describes human-facing narrative variables. For `custom_formula` rows, this may be empty (or predicate-only) while `variables[]` is populated.","example":["ccf_saved","installed_cost_cents"],"x-icf-stability":"contract"},"alternative_group_id":{"type":"string","minLength":1,"maxLength":64,"description":"Phase B — set when the rate belongs to an alternative group and was not selected via measure_selections[]; null / absent otherwise. Present on rows with status=requires_selection.","x-icf-stability":"contract"},"measure_code":{"type":"string","minLength":1,"maxLength":64,"description":"Phase B — per-member identifier within an alternative group. Set on rows with status=requires_selection; null / absent otherwise.","x-icf-stability":"contract"}}},"x-icf-stability":"contract"},"required_inputs":{"type":"array","items":{"type":"object","required":["key","label","data_type","supplied"],"description":"One entry per canonical input the rule set as a whole references — including inputs tied to rates currently in `not_applicable` status. Use this surface to understand the rule set's full input contract across all rates. For the chat prompt subset — keys that, if supplied, would unblock at least one currently-missing rate — read `blocking_missing_inputs[]` instead.","properties":{"key":{"type":"string","example":"installed_cost_cents"},"label":{"type":"string","example":"Installed Cost"},"data_type":{"type":"string","enum":["cents","integer_count","decimal_quantity","boolean","string"],"example":"cents"},"supplied":{"type":"boolean","example":true}}},"x-icf-stability":"contract"},"blocking_missing_inputs":{"type":"array","items":{"type":"string"},"description":"Subset of canonical-input keys that, if supplied, would unblock at least one rate currently in `missing_inputs` status. Use this to drive \"we need X to compute\" chat prompts. Always sorted and deduped; empty when no rate is missing. Distinct from `required_inputs[]`, which surfaces the full rule-set contract including keys tied to not_applicable rates.","example":["installed_cost_cents","kwh_saved"],"x-icf-stability":"contract"},"not_specified":{"type":"array","items":{"type":"string"},"description":"`asks_about[]` entries with no matching rate. Drives \"we don't cover X\" narrative; never inferred from unit-payload mismatches.","example":[],"x-icf-stability":"contract"},"limitations":{"type":"array","items":{"type":"object","required":["text","citation"],"description":"Program-wide limitation (stacking rules, eligibility windows, cap caveats). Additive — the calculator surfaces these verbatim for chat narrative; they do not affect `estimate.amount_cents` directly. Heuristic-emitted entries (those carrying a `code`) always attach a real citation; if no citation is available the entry is not emitted.","properties":{"text":{"type":"string","x-icf-stability":"presentation"},"citation":{"type":"object","required":["document_url","section"],"properties":{"document_url":{"type":"string","format":"uri"},"document_sha256":{"type":"string","nullable":true,"description":"Immutable snapshot hash paired with `document_url`. Present on `unenforced_program_limits` (PR-C2 — the caveat cites the same evidence pair as the disclosure it points at); other limitation emitters may omit it."},"section":{"type":"string"},"page":{"type":"integer","minimum":1,"nullable":true}},"x-icf-stability":"contract"},"code":{"type":"string","enum":["external_schedule_required","custom_engineering_required","stacking_constraint","tax_liability_required","missing_amount_in_source","estimate_capped_at_program_limit","annual_or_customer_cap_not_fully_enforced","limited_time_promo_available","unenforced_program_limits","ambiguous_rate_selection","unverified_eligibility"],"description":"Optional classification of the limitation pattern. Present on entries produced by post-pass heuristics; omitted on entries pulled verbatim from extracted rule-set notes. `unenforced_program_limits` (PR-C2) is a thin system-generated caveat emitted exactly once when the rule set carries an estimate-affecting disclosure (`program_cap` / `claim_limit`) that is NOT enforced — one carrying `cap_micros` is enforced and does not raise it (#1270) — and `estimate` is non-null: the number is pre-cap — read the verbatim terms from `disclosures[]`. (Verbatim disclosure text never appears under limitations.) `ambiguous_rate_selection` (PR-M) names a claimed multi-rate menu item that could not be resolved to a single rate — resend the line with an explicit `rate_id` from `menu_groups[].items[].rates[]`. (`requires_per_item_quantity` was retired by PR-M: claimed menu lines now compute, and the unselected-menu state lives on `calculation_status='requires_selection'`.) `unverified_eligibility` (2026-06-26) is emitted once per program-wide eligibility gate whose input was ABSENT: the estimate is conditional on that gate — supply the input to confirm (the verbatim condition is in `disclosures[]`).","example":"external_schedule_required","x-icf-stability":"contract"}}},"x-icf-stability":"contract"},"disclosures":{"type":"array","items":{"type":"object","required":["code","title","text","measure_ids","citation","gate","cap_micros"],"description":"A set-level program disclosure carried verbatim from its cited source — used for material program-wide rules with no faithful structured home (e.g. a \"$100,000 per building per year up to 50% of equipment cost\" cap, a lifetime per-account claim limit, a funds-exhausted term). Multiple entries are allowed, including multiple entries per code. ADVISORY by default: /calculate does not clamp the estimate to a plain entry — when an estimate-affecting disclosure (`program_cap` / `claim_limit`) exists alongside a non-null estimate, /calculate flags it with one `limitations[]` entry of code `unenforced_program_limits` instead of duplicating the text. Two fields make ONE entry enforced instead: `gate` (eligibility) and `cap_micros` (a dollar ceiling over this entry’s `measure_ids`). An entry carrying `cap_micros` is enforced and therefore does NOT contribute to `unenforced_program_limits`.","properties":{"code":{"type":"string","enum":["program_cap","funding_availability","claim_limit","measurement_requirement","exclusivity_constraint","eligibility_condition","other_material_term"],"description":"Machine-readable disclosure classification, extensible additively. `program_cap` — program-wide dollar cap not structurally enforced. `funding_availability` — first-come / funds-exhausted / annual-capacity terms. `claim_limit` — per-account/per-building/lifetime count or claim limits. `measurement_requirement` — mandated measurement/verification protocol. `exclusivity_constraint` — mutually exclusive paths/options. `eligibility_condition` — material conditional eligibility with no structured predicate home. `other_material_term` — escape hatch.","x-icf-stability":"contract"},"title":{"type":"string","description":"Short human label.","example":"Combined annual rebate cap per building","x-icf-stability":"presentation"},"text":{"type":"string","description":"Verbatim, source-faithful disclosure text (pinned to the cited source; never parse).","example":"Maximum rebate is $100,000 per building per year up to 50% of equipment cost, unless otherwise noted.","x-icf-stability":"presentation"},"measure_ids":{"type":"array","items":{"type":"string","maxLength":30},"nullable":true,"description":"Measure scope: public `measure_id`s (irm_…) of THIS rule set the term applies to; null = program-wide. Never empty.","x-icf-stability":"contract"},"citation":{"type":"object","required":["document_url","document_sha256","section"],"description":"Immutable evidence pair for the verbatim text: the rule set’s source document URL + approval-time snapshot sha by default, or the entry’s own source override URL + its write-time snapshot sha when the term was sourced from a different document (e.g. a T&C PDF).","properties":{"document_url":{"type":"string","format":"uri"},"document_sha256":{"type":"string","description":"sha256 of the cited document snapshot. Always non-null."},"section":{"type":"string"},"page":{"type":"integer","minimum":1,"nullable":true}},"x-icf-stability":"contract"},"gate":{"type":"object","nullable":true,"required":["predicate","inputs"],"description":"Eligibility enforcement (2026-06-26) — the enforced predicate (true = ELIGIBLE; same predicate shape as a rate’s `applies_when`) plus the canonical-input keys it reads. null for advisory disclosures (presence of a gate is the enforceable-vs-advisory switch). When present, /calculate enforces it: a decidably-false result yields calculation_status=not_eligible; an absent gate input computes with an `unverified_eligibility` limitation. v1 enforces program-wide gates only (measure_ids = null).","properties":{"predicate":{"type":"object","description":"Recursive predicate. Operator keys are mutually exclusive at each node. See `phase-1-schema-and-extraction.md` for the full grammar.","example":{"input":"secondary_steam_eligible","equals":true}},"inputs":{"type":"array","items":{"type":"string"},"example":["is_existing_building"]}},"x-icf-stability":"contract"},"cap_micros":{"type":"integer","nullable":true,"example":100000000000,"description":"ENFORCED dollar ceiling over this entry’s `measure_ids`, in micros (1¢ = 10,000 micros; $100,000 = 100000000000). /calculate sums the contributions of those measures, clamps the category to this ceiling ONCE, and only then applies the program-wide ceilings — so a program that publishes \"$100,000 each for lighting and non-lighting projects\" pays up to $100,000 per category rather than $100,000 across both. When it binds, one `limitations[]` entry of code `estimate_capped_at_program_limit` names this entry’s `title` and amount. null = advisory (the default; `measure_ids` is always non-null when this is set).","x-icf-stability":"contract"}}},"description":"PR-C2 §6 — always present (empty for most programs). Material program terms, same shape as `disclosures[]` on /calculation-rules. Limitations mean \"caveats about THIS computation\"; disclosures mean \"material program terms\". Verbatim disclosure text lives ONLY here — never under `limitations[]`.","x-icf-stability":"contract"},"rules":{"type":"object","description":"Full structured rule set (same shape returned by `GET /v1/incentives/{id}/calculation-rules`, without its `superseded` / `current_rule_set_id` pair: a superseded set never prices, so this is always a serving set). Surfaced so a single /calculate call carries everything chat tooling needs.","x-icf-stability":"contract"},"source_citations":{"type":"array","items":{"type":"object","required":["document_url","document_sha256","section"],"properties":{"document_url":{"type":"string","format":"uri"},"document_sha256":{"type":"string","minLength":64,"maxLength":64},"section":{"type":"string"}}},"x-icf-stability":"contract"},"required_selections":{"type":"array","description":"Phase B — always present. Read CONJUNCTIVELY: every entry is a group you must pick from. Entries are (a) a `pick_one` group with no matching `measure_selections` entry, and (b) #683 — the rule set's `priced_menu` group when it is the ONLY menu and no line is claimed at all; once any line is claimed the menu is a deliberate partial basket, stops blocking, and drops back out. A named menu group also stays in `menu_groups[]`, which carries its per-rate detail. When a rule set has SEVERAL `priced_menu` groups, no menu is named here even with nothing claimed: claiming one line in any ONE of them unblocks all of them, so listing each would wrongly read as \"pick from all of these\". Render those from `menu_groups[]`, which is always populated. Each entry surfaces the group label and the per-member options so chat / B2B clients can ask the user to pick.","items":{"type":"object","required":["group_id","label","options"],"properties":{"group_id":{"type":"string"},"label":{"type":"string"},"options":{"type":"array","items":{"type":"object","required":["measure_code","label"],"properties":{"measure_code":{"type":"string"},"label":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"x-icf-stability":"contract"},"menu_groups":{"type":"array","description":"Always present. Empty unless the rule set has a priced_menu group. Each entry is a selectable priced equipment menu. PR-M: claim lines via `measure_selections[]` (entries carrying scoped `inputs` and, on multi-rate items, an explicit `rate_id`); claimed lines compute into `breakdown` and `estimate`, while UNCLAIMED members stay status=menu_item rows that contribute nothing and are never summed.","items":{"type":"object","required":["group_id","label","items"],"properties":{"group_id":{"type":"string","x-icf-stability":"contract"},"label":{"type":"string","x-icf-stability":"presentation"},"items":{"type":"array","items":{"type":"object","required":["measure_id","measure_code","label","rates"],"properties":{"measure_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","description":"PR-C1 §2 — stable public measure ID; matches `measures[].measure_id` on /calculation-rules and `breakdown[].measure_id`. Opaque, ≤30 chars, stable within this `rule_set_id`; new IDs on reapproval/supersede.","example":"irm_d95b06bc77"},"measure_code":{"type":"string","x-icf-stability":"contract"},"label":{"type":"string","x-icf-stability":"presentation"},"rates":{"type":"array","x-icf-stability":"contract","description":"PR-M §4 — the per-rate disambiguation surface, and the surface you build a claimed line from. Single-rate items carry a one-entry array (uniform shape). `rate_id` keys an explicit line selection; `required_inputs` are the canonical-input keys this rate needs to compute, and `required_input_details[].scope` says WHERE each of them goes — `line` inside this line’s own `inputs`, `project` once in the top-level `inputs`. Copying every `required_inputs` key into every line is rejected with `input_not_allowed` for the project ones.","items":{"type":"object","required":["rate_id","rate_summary","required_inputs","required_input_details"],"properties":{"rate_id":{"type":"string","minLength":1,"maxLength":30,"x-icf-stability":"contract","example":"irt_06bc77f55a"},"rate_summary":{"type":"string","x-icf-stability":"presentation","description":"One-line per-rate digest, e.g. \"$350/device\".","example":"$20/device"},"required_inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","example":["device_count"]},"required_input_details":{"type":"array","x-icf-stability":"contract","description":"Unit and display metadata for each `required_inputs[]` key, in the SAME ORDER — entry *i* describes key *i*, so the two arrays can be zipped without a lookup.\n\n**Read `unit` here, not `unit_label`, to decide what to send.** `unit_label` is presentation, copied verbatim from the source document, and a source often quotes a different denomination from the one the rate is encoded in — a label reading `/kW` against an input encoded in `W`, for instance. Getting that wrong is a factor of 1000 and nothing can catch it for you: the number you sent is a valid quantity, just at the wrong scale. `unit` is read from the rate itself, so it always describes the encoding actually in force.\n\n`unit` is null for inputs that carry no unit (counts, booleans, enums) and for a rate with no canonical unit of its own.\n\n**Read `scope` to decide WHERE to send each key.** A request has two input axes — the top-level `inputs` for facts about the project, and each `measure_selections[].inputs` for the quantities of one claimed line — and `scope` says, per key, which one it belongs on. The split is not guessable from the key name or its `data_type`: `dwelling_units` and `device_count` are both integer counts a rate multiplies by, and they sit on opposite axes. Branch on `scope` rather than sending every key everywhere.","items":{"type":"object","required":["key","label","data_type","unit","unit_display","scope"],"additionalProperties":false,"properties":{"key":{"type":"string","description":"Matches the entry at the same index of `required_inputs`.","example":"watts_reduced"},"label":{"type":"string","example":"Watts reduced"},"data_type":{"type":"string","nullable":true,"description":"Null for a key not in the canonical-input registry.","example":"decimal_quantity"},"unit":{"type":"string","nullable":true,"description":"The canonical unit this rate denominates the input in. Null when the input is unitless.","example":"W"},"unit_display":{"type":"string","nullable":true,"example":"Watts reduced (W)"},"scope":{"type":"string","enum":["project","line"],"nullable":true,"description":"Which request axis this key belongs on.\n\n- `project` — a fact about the whole project (`dwelling_units`, `compliance_path`, `customer_type`). Send it ONCE in the top-level `inputs`. Sending it inside a claimed line’s `inputs` is rejected with `input_not_allowed` — whether or not it equals the global value — and a multi-line claim does NOT repeat it per line.\n- `line` — a quantity of one claimed menu line (`device_count`, `installed_cost_cents`, `sqft`). Accepted per line, and REQUIRED per line once more than one line is claimed.\n\nNull only for a key absent from the canonical-input registry, which input validation rejects anyway; treat null as `project`.","example":"line"}}}}},"additionalProperties":false}}},"additionalProperties":false}}},"additionalProperties":false},"x-icf-stability":"contract"},"eligibility_failures":{"type":"array","description":"Eligibility enforcement (2026-06-26). Always present; empty unless calculation_status is `not_eligible`. Each entry cites a program-wide eligibility gate that resolved decidably-false — the project does not qualify. `reason` is the disclosure’s verbatim source text; `inputs[]` are the canonical input keys the gate reads (flip them to test eligibility). `citation` is the disclosure’s immutable evidence pair.","items":{"type":"object","required":["disclosure_code","title","reason","inputs","citation"],"properties":{"disclosure_code":{"type":"string","x-icf-stability":"contract","example":"eligibility_condition"},"title":{"type":"string","x-icf-stability":"presentation"},"reason":{"type":"string","x-icf-stability":"presentation"},"inputs":{"type":"array","items":{"type":"string"},"x-icf-stability":"contract","example":["is_existing_building"]},"citation":{"type":"object","required":["document_url","document_sha256","section"],"x-icf-stability":"contract","properties":{"document_url":{"type":"string","format":"uri"},"document_sha256":{"type":"string"},"section":{"type":"string"},"page":{"type":"integer","minimum":1,"nullable":true}}}},"additionalProperties":false},"x-icf-stability":"contract"}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"unauthorized"},"message":{"type":"string","example":"Invalid or expired API key."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"403":{"description":"API key lacks the required permission for this resource","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"forbidden"},"message":{"type":"string","example":"API key does not have access to 'incentives'."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"resource_not_found"},"message":{"type":"string","example":"Resource not found."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"409":{"description":"The `?rule_set_id=` names a rule set a later approval superseded, so no figure is computed. `details.current_rule_set_id` is the rule set a request with no selector prices today (`null` when none is effective today): refetch `/calculation-rules` and quote against it.","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"conflict"},"message":{"type":"string","example":"Rule set irs_654e2c1319 has been superseded and no longer prices. The current rule set for this program is irs_a9df31770f."},"details":{"nullable":true,"example":{"rule_set_id":"irs_654e2c1319","current_rule_set_id":"irs_a9df31770f"}}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"422":{"description":"Request validation failed","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string","example":"Request validation failed."},"details":{"nullable":true,"example":{"sort_field":["Invalid enum value. Expected 'admin_name' | 'incentive_name' | 'updated_at' | 'start_date' | 'end_date', received 'bogus'"]}}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Calculate incentive amount","tags":["Incentives"]}},"/v1/properties/incentives":{"post":{"description":"Resolves a free-form US address and returns the incentive programs that apply to it, sorted by administrator name.\n\nEach row carries a `calculation_rules` summary whose `interaction_mode` tells you, before you spend a second request, what kind of client a program needs: `calculator` (collect inputs), `menu` (present priced options to choose from first), or `source_only` (we hold an approved rule set and none of its rates is computable). `null` means no approved rule set is currently effective.\n\nIt is not the computability axis — a `menu` program may or may not carry a computable rate. For \"will `POST /v1/incentives/{id}/calculate` price this?\", use `summary.computable_programs` and `filters.computable_only`.\n\n`data.summary` answers \"how many of these produce a number\" without iterating the array. It describes the **whole match, not this page** — `summary.total` equals `meta.total` — and when `filters.computable_only` is set it describes the filtered match.\n\nRead `summary.computable_programs` for the money question: it is exactly the set `filters.computable_only` returns, from one test evaluated once per program, so under `computable_only: true` it equals `summary.total`. `summary.by_interaction_mode` is a separate axis — what kind of UI each program needs — and **is not a computability breakdown in either direction**, because a `menu` program may or may not carry a computable rate, so `calculator + menu` overcounts and `calculator` alone undercounts.\n\nExpect `computable_programs` to be a small fraction of the total, and most rows to land in `no_rule_set`. That bucket records what we hold, not what the program publishes: we have no approved rule set effective today for it, which is not evidence the administrator publishes no formula. Source documents are on `GET /v1/incentives/{id}/calculation-rules`, not on this response.\n\nEvery row carries `updated_at`: the date we last reviewed that program's record, identical to `updated_at` on `GET /v1/incentives/{id}`. It is provenance, not a freshness or verification guarantee — a recent date means the record was touched, not that the program was re-checked with its administrator. Apply your own freshness bar to it; it is on the row so you can do that across a whole result set without fetching each program.\n\nEvery row also carries `sectors`: the eligible asset-type tags for that program, from the same controlled vocabulary `asset_type` accepts. A program name alone can be misleading — Texas's \"Hard-to-Reach\" is the regulatory term for income-qualified housing — so use `sectors` rather than parsing `incentive_name` to decide whether a program targets a population you care about.\n\nEvery row also carries `structured_tags`: the deduplicated union of the eligibility tags on that program's serving rule set — the same vocabulary `structured_tags` carries per-criterion on `GET /v1/incentives/{id}/calculation-rules`, flattened to one set per program. Filter a whole result on a tag (e.g. `income_qualified`) in this one request rather than a calculation-rules request per program. The tags are descriptive, not enforcing (they never change what `/calculate` returns), and the vocabulary is the raw stored set, not yet canonicalized.\n\nMatching is jurisdictional (city / county / utility / state / federal) AND sector-scoped, so `asset_type` **is required and has no default**: it is the sector filter, and the value you send decides the result set rather than merely trimming it — `commercial_office` and `commercial_multi_family` on the same address return different program sets, not the same set at two sizes. Some administrators publish sector-specific programs, so even the computable subset under one `asset_type` can span more than one administrator. There is no server-side filter for this — every row carries `admin_name`, so group or filter by it client-side once the response is in hand. Answer for the building you actually mean; a request that omits the field is rejected with `422 validation_error` rather than answered under a guess. The value you sent is echoed on `data.property.asset_type`, in the form you sent it.\n\nAsset types are snake_case (`commercial_multi_family`), like every other identifier here. The display strings they replace (`Commercial (Multi-Family)`) are accepted aliases and keep working indefinitely.\n\nAn address that cannot be resolved — or that resolves without a state, county or city, which leaves its jurisdiction undetermined — returns `422 validation_error`, never an empty success. An empty `incentives` array with `200` means we resolved the address and nothing in the catalog applies to it.","operationId":"PropertyIncentivesController_lookup","parameters":[{"name":"per_page","required":false,"in":"query","description":"Rows per page, 1–1000. Default 50.","schema":{"example":50}},{"name":"page","required":false,"in":"query","description":"Page number, 1-based. Default 1.","schema":{"example":1}}],"requestBody":{"required":true,"description":"Address and asset type — both required — plus optional filters.","content":{"application/json":{"schema":{"type":"object","required":["address","asset_type"],"properties":{"address":{"type":"string","maxLength":500,"description":"Free-form US address. Resolved via USPS/Smarty with a Google geocode fallback; an address neither can place returns 422.","example":"1200 Smith St, Houston, TX 77002"},"asset_type":{"type":"string","enum":["agricultural","commercial_air_conditioned_storage","commercial_data_center","commercial_distribution_centers","commercial_hospitality","commercial_industrial","commercial_laboratory","commercial_manufacturing","commercial_master_planned_community","commercial_mixed_use","commercial_multi_family","commercial_office","commercial_private_education_dormitory","commercial_private_education_facility","commercial_private_hospital","commercial_private_medical_office_building","commercial_retail","commercial_senior_housing","commercial_warehouse","house_of_worship","land_parks","museum","public_education_dormitory","public_facility_office","public_facility_other","public_higher_education","public_hospital","public_k_12_school","public_medical_office_building","residential_single_family","vehicles_fleets","Agricultural","Commercial (Air Conditioned Storage)","Commercial (Data Center)","Commercial (Distribution Centers)","Commercial (Hospitality)","Commercial (Industrial)","Commercial (Laboratory)","Commercial (Manufacturing)","Commercial (Master-Planned Community)","Commercial (Mixed-Use)","Commercial (Multi-Family)","Commercial (Office)","Commercial (Private Education Dormitory)","Commercial (Private Education Facility)","Commercial (Private Hospital)","Commercial (Private Medical Office Building)","Commercial (Retail)","Commercial (Senior Housing)","Commercial (Warehouse)","House Of Worship","Land/Parks","Museum","Public Education Dormitory","Public Facility (Office)","Public Facility (Other)","Public Higher Education","Public Hospital","Public K-12 School","Public Medical Office Building","Residential (Single Family)","Vehicles/Fleets"],"description":"The property's asset type. **Required** — matching is sector-scoped, so this IS the filter and the result set depends on it. There is no default: a wrong or absent value would not widen the search, it would narrow it, and silently. As an example, three of the six computable programs in CenterPoint Houston's territory carry no office sector tag, so a multi-family developer answering this field with an office value receives none of them. The value you send is echoed back verbatim on `data.property.asset_type`.\n\n**Use the snake_case form** — `commercial_multi_family`, `commercial_office` — the first 31 values in the enum above, and the only ones this reference documents. The 31 that follow are the display strings they replace (`Commercial (Multi-Family)`): accepted aliases, kept indefinitely for callers written before the snake_case form existed, and listed only so a client generated from this spec can still express them. Each documented value is its display string lowercased with every run of non-alphanumeric characters replaced by a single `_`, so you can derive one from the other without a lookup table.","example":"commercial_multi_family"},"filters":{"type":"object","properties":{"computable_only":{"type":"boolean","default":false,"description":"Restrict to programs whose currently-effective approved rule set carries at least one computable rate — the programs `POST /v1/incentives/{id}/calculate` can return a dollar figure for.","example":true}}}}}}}},"responses":{"200":{"description":"The resolved property and the programs that apply to it.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Calls left in the current quota period, counting this one. Every request that gets past authentication counts, including ones that end in an error. `0` means the next call returns `429 quota_exceeded`.","schema":{"type":"integer","example":99}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"description":"The resolved property, a computability summary of the whole match, and one page of applicable programs ordered by administrator name. An empty `incentives` array means the address resolved and nothing in the catalog applies to it — an address we could not resolve is a 422 instead.","properties":{"data":{"type":"object","required":["property","summary","incentives"],"properties":{"property":{"type":"object","required":["address_raw","formatted_address","city","county","state","postal_code","asset_type","deliverable","resolution_source"],"description":"The address as we resolved it, and the asset type matched on.","properties":{"address_raw":{"type":"string","description":"The address you sent, echoed verbatim.","example":"1200 Smith St, Houston, TX 77002","x-icf-stability":"contract"},"formatted_address":{"type":"string","nullable":true,"description":"Human-readable rendering of the resolved address. Display only — the component fields below are what you parse.","example":"1200 Smith St, Houston, TX 77002-4310","x-icf-stability":"presentation"},"city":{"type":"string","nullable":true,"example":"Houston","x-icf-stability":"contract"},"county":{"type":"string","nullable":true,"description":"County name as the address provider returns it.","example":"Harris","x-icf-stability":"contract"},"state":{"type":"string","nullable":true,"example":"TX","x-icf-stability":"contract"},"postal_code":{"type":"string","nullable":true,"example":"77002-4310","x-icf-stability":"contract"},"asset_type":{"type":"string","enum":["Agricultural","Commercial (Air Conditioned Storage)","Commercial (Data Center)","Commercial (Distribution Centers)","Commercial (Hospitality)","Commercial (Industrial)","Commercial (Laboratory)","Commercial (Manufacturing)","Commercial (Master-Planned Community)","Commercial (Mixed-Use)","Commercial (Multi-Family)","Commercial (Office)","Commercial (Private Education Dormitory)","Commercial (Private Education Facility)","Commercial (Private Hospital)","Commercial (Private Medical Office Building)","Commercial (Retail)","Commercial (Senior Housing)","Commercial (Warehouse)","House Of Worship","Land/Parks","Museum","Public Education Dormitory","Public Facility (Office)","Public Facility (Other)","Public Higher Education","Public Hospital","Public K-12 School","Public Medical Office Building","Residential (Single Family)","Vehicles/Fleets","agricultural","commercial_air_conditioned_storage","commercial_data_center","commercial_distribution_centers","commercial_hospitality","commercial_industrial","commercial_laboratory","commercial_manufacturing","commercial_master_planned_community","commercial_mixed_use","commercial_multi_family","commercial_office","commercial_private_education_dormitory","commercial_private_education_facility","commercial_private_hospital","commercial_private_medical_office_building","commercial_retail","commercial_senior_housing","commercial_warehouse","house_of_worship","land_parks","museum","public_education_dormitory","public_facility_office","public_facility_other","public_higher_education","public_hospital","public_k_12_school","public_medical_office_building","residential_single_family","vehicles_fleets"],"description":"The asset type the match ran under — always the one you sent. Echoed in the FORM YOU SENT: a `commercial_office` request reads back `commercial_office`, and a request using the display alias `Commercial (Office)` reads that back, so you never receive a string your own code does not already use. The enum here therefore carries both forms.","example":"commercial_multi_family","x-icf-stability":"contract"},"deliverable":{"type":"boolean","description":"USPS-deliverable. False for a location placed by geocoding alone (new construction, federal sites) — the match still ran.","example":true,"x-icf-stability":"contract"},"resolution_source":{"type":"string","enum":["override","smarty","google"],"description":"Which source placed the address.","example":"smarty","x-icf-stability":"contract"}}},"summary":{"type":"object","required":["total","computable_programs","by_interaction_mode","program_groups"],"description":"**The example counts throughout this block are illustrative** — round figures sized to show the relationships described here, not a transcript of any one lookup and not tied to the example address on the request. Real counts move with the catalog every time we approve a rule set; make the call to see them.\n\nHow many of the matched programs produce a number, answered without iterating the array. Counts describe the **whole match, not this page** — `total` equals `meta.total`. With `filters.computable_only` set they describe the filtered match, which is the population the rows are drawn from.\n\nRead `computable_programs` for \"how many will `/calculate` price?\" and `by_interaction_mode` for \"what kind of UI do these need?\". They are independent: neither can be computed from the other (see `by_interaction_mode`).\n\nExpect `computable_programs` to be a small fraction of `total`. Most rows land in `no_rule_set`, which records what we hold rather than what the program publishes — we have no approved rule set effective today for it. What every row does carry is the program identity and dates you need to look it up; source documents live on `GET /v1/incentives/{id}/calculation-rules`, not on this response.\n\nRead `program_groups` for \"how many DISTINCT things is this?\" — many rows are one administrator's one document split across catalog rows, and the row count on its own reads as more coverage than that.","properties":{"total":{"type":"integer","description":"Programs matched across every page. Equal to `meta.total`.","example":150,"x-icf-stability":"contract"},"computable_programs":{"type":"integer","description":"Of `total`, how many programs have a currently-selected approved rule set carrying at least one computable rate — the ones `POST /v1/incentives/{id}/calculate` can return a dollar figure for. Exactly the set `filters.computable_only` returns: one test, evaluated once per program, read by both the count and the filter, so they cannot disagree. Under `computable_only: true` it therefore equals `total`.\n\nIt is derived from the rate kinds, not from the buckets below — the example values deliberately do not add up from them.","example":8,"x-icf-stability":"contract"},"by_interaction_mode":{"type":"object","required":["calculator","menu","source_only","no_rule_set"],"description":"A partition of the matched programs by the `interaction_mode` on each row's `calculation_rules`, counted from those very values, so the block cannot disagree with the rows it summarizes. Sums to `total`.\n\n**Not a computability breakdown, in either direction.** A `priced_menu` group whose every rate is `custom_formula` is `menu` and computes nothing, and one carrying a real rate is `menu` and does compute — so `calculator + menu` overcounts and `calculator` alone undercounts. Use `computable_programs` for that.","properties":{"calculator":{"type":"integer","description":"Supply inputs to `POST /v1/incentives/{id}/calculate` and get a dollar figure.","example":5},"menu":{"type":"integer","description":"Priced options — choose from the menu, then calculate. Whether any given one computes is the `computable_programs` question, not this one.","example":4},"source_only":{"type":"integer","description":"We hold an approved rule set for the program, and none of its rates is computable — the award is described rather than calculated.","example":11},"no_rule_set":{"type":"integer","description":"The `interaction_mode: null` bucket — we hold no approved rule set that is effective today for the program. A statement about our extraction coverage, not about the program: it does not mean the administrator publishes no formula, only that we have not approved one that applies today.","example":130}},"x-icf-stability":"contract"},"program_groups":{"type":"object","required":["distinct","multi_program","lifecycle_distinguished","unclassified"],"description":"How much of `total` is the same administrator's same source document, split across catalog rows.\n\nRows are grouped by `(administrator, source document)` and each row carries its group under `program_group_id`, so you can collapse the array yourself. Counts here describe the whole match, like every other count in this block.\n\nMuch of that grouping is deliberate: the catalog splits one program into an `(Existing Buildings)` row and a `(New Construction)` row, and both are real, separately-applied programs. `lifecycle_distinguished` is how many groups that explains, and `unclassified` is the remainder — the groups this response cannot tell you about either way.","properties":{"distinct":{"type":"integer","description":"Distinct `(administrator, source document)` groups across the whole match — the distinct non-null `program_group_id` values on the rows, plus one for each row whose `program_group_id` is `null`. Never greater than `total`; read the two together to see how much of a result is one document counted more than once.","example":100},"multi_program":{"type":"integer","description":"Of `distinct`, how many groups hold more than one row.","example":50},"lifecycle_distinguished":{"type":"integer","description":"Of `multi_program`, how many are fully explained by the catalog's lifecycle convention: every member carries a `lifecycle_stage` and no two members share one. No two rows in such a group are the same stage of the same document, so none of them duplicates another — they are separate programs you can apply to separately.","example":44},"unclassified":{"type":"integer","description":"`multi_program - lifecycle_distinguished`: groups where two or more rows share a lifecycle stage, or a member carries none. **These are the groups we cannot classify for you.** They may be genuinely different programs published on one landing page, or they may be duplicate rows; the catalog does not carry enough to tell, and this number is published rather than guessed at. Inspect `incentive_name` on the rows sharing the `program_group_id`.","example":6}},"x-icf-stability":"contract"}}},"incentives":{"type":"array","items":{"type":"object","required":["incentive_program_id","incentive_name","admin_name","sectors","structured_tags","start_date","end_date","dates_source","updated_at","program_group_id","lifecycle_stage","calculation_rules"],"properties":{"incentive_program_id":{"type":"integer","description":"Pass to `GET /v1/incentives/{id}` for the full record, or to `POST /v1/incentives/{id}/calculate` for a figure.","example":223171,"x-icf-stability":"contract"},"incentive_name":{"type":"string","example":"2026 Commercial High Efficiency Foodservice Program (CHEF) (Existing Buildings)","x-icf-stability":"presentation"},"admin_name":{"type":"string","nullable":true,"example":"CenterPoint Energy","x-icf-stability":"presentation"},"sectors":{"type":"array","description":"Eligible sectors (asset-type tags) for this program — the same `{id, name}` shape as `sectors` on `GET /v1/incentives/{id}`, and the same controlled vocabulary `asset_type` accepts as a display-string alias. Empty array if the program applies to every asset type.\n\nA program's name alone can be misleading — Texas's \"Hard-to-Reach\" is the regulatory term for income-qualified housing, not a description a caller can infer. `sectors` makes that legible without a second request.","items":{"type":"object","required":["id","name"],"properties":{"id":{"type":"integer","example":45},"name":{"type":"string","example":"Commercial (Office)"}}},"example":[{"id":12,"name":"Commercial (Multi-Family)"},{"id":34,"name":"Commercial (Senior Housing)"}],"x-icf-stability":"contract"},"structured_tags":{"type":"array","items":{"type":"string"},"description":"The deduplicated union of the eligibility tags on this program's currently-serving approved rule set — the same set the `calculation_rules` block reflects, and the same tag vocabulary `structured_tags` carries per-criterion on `GET /v1/incentives/{id}/calculation-rules`, flattened here to one program-level set. Filter the rows this one request already returned on a tag in your own code (e.g. `income_qualified` to find income-qualified housing) instead of making a calculation-rules request per program: a program's name alone does not signal it — Texas's \"Hard-to-Reach\" is the regulatory term for income-qualified housing. Empty array when the serving rule set carries no tagged criteria, or the program has no currently-effective approved rule set.\n\nDescriptive, not enforcing: a tag never changes what `POST /v1/incentives/{id}/calculate` returns — it says what a criterion is ABOUT, not that the calculation applied it. The vocabulary is not yet fully canonicalized; branch on a tag being present, but do not assume the set of possible tags is closed or stable.\n\n**Canonical values.** Match these exactly:\n- `customer_type:commercial` — commercial customer\n- `customer_type:residential` — residential customer\n- `customer_type:industrial` — industrial customer\n- `building_type:multifamily` — multifamily building\n- `building_type:single_family` — single-family building\n- `is_existing_building:true` — existing building\n- `is_new_construction:true` — new construction\n- `income_qualified` — income-qualified program\n\nThe form is `namespace:value`: lowercase, `snake_case` value, `:` delimiter, no space after it. `income_qualified` is bare by design — it is a status, not a namespaced pair.\n\n**This list is not closed, and a client that treats it as closed will silently under-report.** Canonical means these are the spellings we write going forward and are migrating the corpus onto. It does not mean they are the only values you will receive: programs still carry pre-canonical variants of the same facts — `customer_type:multifamily` for the multifamily building tag, `customer_type: commercial` with a space, a bare `is_existing_building`, title-cased prose such as `Multi-family (4+ units)`. Matching only the eight values above will therefore miss programs that do carry the fact, and nothing will tell you so, because a tag you did not match is indistinguishable from a program nobody tagged. Read a canonical match as a positive signal and its absence as unknown, never as a negative.\n\n**Negation is not part of the vocabulary, and the corpus has not caught up.** A canonical tag only ever asserts what a program IS for; a program that excludes a segment is simply not tagged with it. Pre-canonical `not_*` and `…:not_eligible` values do still appear in the data today, so a substring or prefix match reads them backwards — `not_multifamily` contains `multifamily`. Compare whole tag values, never substrings.\n\n**No server-side filtering.** No request parameter on any endpoint accepts a tag; these are returned so you can filter in your own code over the rows you already have. Tag filtering is on our roadmap and is not built.","example":["building_type:multifamily","income_qualified"],"x-icf-stability":"contract"},"start_date":{"type":"string","format":"date","nullable":true,"example":"2026-01-01","x-icf-stability":"contract"},"end_date":{"type":"string","format":"date","nullable":true,"example":"2026-12-31","x-icf-stability":"contract"},"dates_source":{"type":"string","enum":["calculation_rules","program_listing"],"description":"Which layer produced the dates above — the attested rule-set window, or the legacy catalog listing. A rule set carrying no effective bounds asserts nothing about dates and never appears here, so a `calculation_rules` row always has at least one of `start_date` / `end_date`, and a null `end_date` under it means genuinely open-ended. Under `program_listing`, a null `end_date` means unknown.","example":"calculation_rules","x-icf-stability":"contract"},"updated_at":{"type":"string","format":"date-time","nullable":true,"description":"When we last reviewed this program's record. The same value and format as `updated_at` on `GET /v1/incentives/{id}`.\n\nRead it as provenance, not as a freshness or verification guarantee: a recent date means the record was touched, not that the program was re-checked against its administrator, and an old one means we have not looked recently. It is on every row so you can apply your own freshness bar across a whole result set without fetching each program.","example":"2026-03-22T10:00:00Z","x-icf-stability":"contract"},"program_group_id":{"type":"string","nullable":true,"description":"Opaque key over `(administrator, source document)`. Rows sharing this value were written from the same administrator's same source; rows with different values were not. Group the array on it to see how many distinct things a result really contains — `summary.program_groups` is the same grouping already counted for you.\n\n\"Source\" is the program document where we hold one and the administrator's page otherwise, which is the same order this API reads sources in. Two programs published on one landing page but backed by different documents are therefore two groups, not one.\n\nSharing a group is not the same as being a duplicate. Read `lifecycle_stage` alongside it: two rows in one group with different stages are the deliberate `(Existing Buildings)` / `(New Construction)` split, and both are real programs.\n\n`null` when the catalog row names no administrator or no source at all; such a row is never grouped with any other. The value is derived from those columns rather than stored, so correcting one changes it — compare it within a response, do not persist it as a durable group identifier.","example":"pgrp_041b782476c8","x-icf-stability":"contract"},"lifecycle_stage":{"type":"string","nullable":true,"enum":["existing_buildings","new_construction"],"description":"Which stage of a building's life this catalog row is written for, read off the trailing marker in `incentive_name` — the catalog's own convention, `… (Existing Buildings)` against `… (New Construction)`. `Existing Homes` and `New Homes` map to the same two values; homes-vs-buildings is a property-type distinction, not a lifecycle one.\n\n`null` means the name carries no marker we recognise: 18,966 of the 38,948 active programs on 2026-09-01, close to half. It does not mean the program covers neither stage, and it does not mean it covers both — only that the catalog name does not say.","example":"existing_buildings","x-icf-stability":"contract"},"calculation_rules":{"type":"object","required":["has_approved","interaction_mode","requires_measure_selection","measure_count","rate_count","last_reviewed_at","effective_start","effective_end"],"description":"Summary of the approved calculation rules attached to a program. When `has_approved` is true and `measure_count > 0`, fetch the full structured rule set via `GET /v1/incentives/{id}/calculation-rules`.","properties":{"has_approved":{"type":"boolean","description":"True if any approved rule set exists for the program — even one that is not currently effective. Useful as a coarse \"is this program modeled?\" signal.","example":true,"x-icf-stability":"contract"},"interaction_mode":{"type":"string","enum":["calculator","menu","source_only"],"nullable":true,"description":"How a client drives this program — what UI to build. Derived mechanically from the currently-selected rule set, precedence top-down: `menu` = at least one priced_menu group exists; `calculator` = no menu, and at least one rate is computable given inputs/selections (any non-custom_formula / non-informational rate, including pick_one members and lesser_of/capped/tiered legs); `source_only` = neither — every rate is custom_formula/informational. A static property of the rule set, NOT of a request, and distinct from `value_details.computability` (how much of the value computes). Null when no rule set is selected (none approved, or none effective today).","example":"calculator","x-icf-stability":"contract"},"requires_measure_selection":{"type":"boolean","description":"True when this program needs a `measure_selections[]` entry before `POST /v1/incentives/{id}/calculate` will value it. Check it BEFORE you calculate: on a true, the naive first call — inputs only — answers `200` with `calculation_status: \"requires_selection\"` and `estimate: null`, however complete your `required_inputs` are. Call `GET /v1/incentives/{id}/calculation-rules` first and read `selection_groups[]` for the groups and the codes to choose between.\n\n`interaction_mode` does not answer this and cannot be made to: `menu` always needs a claimed line, while `calculator` covers both \"send inputs, get money\" and \"pick one of 48 alternatives first\". Measured on production 2026-08-31, every computable program on a Houston commercial multi-family address needed a selection on the naive first call — both `calculator` and `menu` programs were in that set — and every one paid out once a selection was supplied.\n\nFalse when no rule set is selected (`has_approved` false, or none effective today) — there is nothing to select from. Read a true as \"your first calculate needs measure_selections\", not as \"no number is possible\": a rule set pairing a group with ungrouped computable rates still returns a partial estimate.","example":true,"x-icf-stability":"contract"},"measure_count":{"type":"integer","example":1,"x-icf-stability":"contract"},"rate_count":{"type":"integer","example":1,"x-icf-stability":"contract"},"last_reviewed_at":{"type":"string","format":"date-time","nullable":true,"example":"2026-05-13T17:22:10Z","x-icf-stability":"contract"},"effective_start":{"type":"string","format":"date","nullable":true,"example":"2026-01-01","x-icf-stability":"contract"},"effective_end":{"type":"string","format":"date","nullable":true,"example":"2026-12-31","x-icf-stability":"contract"}},"x-icf-stability":"contract"}}}}}},"meta":{"type":"object","required":["page","per_page","total","total_pages"],"description":"Page-based pagination metadata (API_DESIGN.md §4).","properties":{"page":{"type":"integer","example":1},"per_page":{"type":"integer","example":50},"total":{"type":"integer","example":1247},"total_pages":{"type":"integer","example":25}}}}}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"unauthorized"},"message":{"type":"string","example":"Invalid or expired API key."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"403":{"description":"API key lacks the required permission for this resource","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"forbidden"},"message":{"type":"string","example":"API key does not have access to 'incentives'."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"422":{"description":"Request validation failed","content":{"application/json":{"schema":{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"validation_error"},"message":{"type":"string","example":"Request validation failed."},"details":{"nullable":true,"example":{"sort_field":["Invalid enum value. Expected 'admin_name' | 'incentive_name' | 'updated_at' | 'start_date' | 'end_date', received 'bogus'"]}}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}}}},"429":{"description":"Two unrelated limits both answer `429`; branch on `error.code`.\n\n- `rate_limit_exceeded` — the **throughput throttle** (100 requests per 60 seconds). Rejected before the key is metered, so it costs no quota and carries no `X-API-Call-*` headers — nor any `X-RateLimit-*` ones, which the throttler sends only on requests it lets through. Wait out `Retry-After` and the same request succeeds.\n- `quota_exceeded` — your **call budget** for the period is gone. Waiting a minute does not clear it: `X-API-Call-Reset` names the instant the period rolls over, and `Retry-After` is the same instant in seconds. A cap that never resets (`lifetime`) carries neither and needs an operator to lift or reset it. Only capped keys can receive this.","headers":{"X-RateLimit-Limit":{"description":"Requests allowed per 60-second throughput window. Throughput only — this is not your call budget.","schema":{"type":"integer","example":100}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window. **Not** your remaining call quota: it refills every minute, so on a metered key it reads near 100 forever while the budget drains. Read `X-API-Call-Remaining` for the budget.","schema":{"type":"integer","example":99}},"X-RateLimit-Reset":{"description":"Seconds until the throughput window refills.","schema":{"type":"integer","example":60}},"X-API-Call-Limit":{"description":"Calls allowed in the current quota period for this key. Sent only on keys that carry a cap — an uncapped key emits none of the `X-API-Call-*` headers at all, so read their absence as \"no cap\", not as a large number.","schema":{"type":"integer","example":100}},"X-API-Call-Remaining":{"description":"Always `0` on a `quota_exceeded` response. Absent on a `rate_limit_exceeded` one — that request never reached the meter.","schema":{"type":"integer","example":0}},"X-API-Call-Reset":{"description":"ISO-8601 UTC instant at which the quota period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. **Omitted** when the cap never resets (a `lifetime` cap, or a cap with no period configured); those stay exhausted until an operator resets them.","schema":{"type":"string","format":"date-time","example":"2026-09-30T14:05:00.000Z"}},"Retry-After":{"description":"Seconds to wait before retrying. On `rate_limit_exceeded`, until the throughput window refills. On `quota_exceeded`, until the quota period rolls over — omitted when the cap never resets.","schema":{"type":"integer","example":42}}},"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"rate_limit_exceeded"},"message":{"type":"string","example":"Rate limit exceeded. Please retry after the window resets."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}},{"type":"object","required":["error","request_id"],"properties":{"error":{"type":"object","required":["code","message","details"],"properties":{"code":{"type":"string","example":"quota_exceeded"},"message":{"type":"string","example":"API call quota exceeded."},"details":{"nullable":true,"example":null}}},"request_id":{"type":"string","example":"req_9aB3kZ2qP7xL0mN1"}}}]}}}}},"security":[{"api-key":[]}],"summary":"Find incentives for an address","tags":["Properties"]}}},"info":{"title":"IncentiFind API","description":"The IncentiFind API provides programmatic access to building incentive program data, report generation, and data submission for approved partners.\n\n## Stability\n\n> **Early access.** The API is currently in a **pre-stable** phase. Response shapes may change with minimal notice until customers are integrated.\n\nEvery endpoint in this reference is served natively at `/v1/*`. None proxies to IncentiFind's legacy service any more, so the earlier *proxy-stable* layer is retired. Two layers are defined:\n\n- **Pre-stable** — the default today, and the only layer in effect. Shapes may change. Do not build on these without talking to us.\n- **Versioned-stable** — designed, **not yet built**. The design pins each key to a `response_version` (a date string): breaking changes would ship as new `response_version` values, **not** new URL paths, and deprecated versions would run in parallel for at least 6 months before removal. Today no key has a `response_version` and the API reads no version header, so nothing pins a response shape.\n\n`/v1/*` is a permanent surface; there will be no `/v2/`. Until versioned-stable is built, breaking changes land in place under pre-stable.\n\n## Authentication\n\nAll endpoints require an API key. Include it in your request using one of these methods:\n\n- **Header:** `X-API-Key: icf_live_your_key_here`\n- **Bearer token:** `Authorization: Bearer icf_live_your_key_here`\n\nAPI keys are scoped to specific permissions (e.g., `incentives`, `data-submissions`). Contact IncentiFind to get your API key provisioned with the appropriate permissions.\n\n## Rate Limits\n\nTwo independent limits, both answering `429`. Branch on `error.code`, never on the status alone.\n\n### Throughput — `rate_limit_exceeded`\n\n- **100 requests per 60 seconds**, counted per calling IP address and per endpoint, not per key.\n- Every response from an endpoint that takes an API key carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until the window refills), errors such as `401` and `422` included. A request refused before it reaches one gets none of them: malformed JSON, an unknown path, or a `429 rate_limit_exceeded`.\n- On `429`, `Retry-After` gives the seconds to wait; the same request then succeeds.\n- **`X-RateLimit-Remaining` is not your call budget.** It refills every minute, so on a metered key it reads near 100 forever while the budget below drains to zero.\n\n### Call quota — `quota_exceeded`\n\nKeys issued with a call cap (trials, metered plans) also carry a budget for a period. Where a key has one, **every** response it authenticates — successes and errors alike, since both spend a call — carries:\n\n- `X-API-Call-Limit` — calls allowed in the period.\n- `X-API-Call-Remaining` — calls left, counting the one you just made. This is the number to watch.\n- `X-API-Call-Reset` — ISO-8601 UTC instant the period rolls over and the counter returns to zero. A `monthly` cap is a rolling 30 days from the first call of the period, not a calendar month. Omitted on caps that never reset.\n\nWhen the budget is gone the API answers `429` with `\"code\": \"quota_exceeded\"` and a `Retry-After` counting down to the reset. Waiting a minute does not clear it — only the period rolling over, or an operator lifting the cap, does.\n\n**Keys with no cap emit none of the `X-API-Call-*` headers.** Read their absence as \"this key is uncapped\", never as a large remaining balance — a client that assumes the headers are always present reads `undefined`, not a number.\n\n## Data Submissions\n\nPartners who submit incentive data should start with:\n\n1. `GET /v1/data-submissions/providers` — see available provider configurations\n2. `GET /v1/data-submissions/schema?provider={id}` — get field definitions and example payloads\n3. `POST /v1/data-submissions` — submit data for review\n\nSubmitted data goes through an async pipeline (transform, match, diff) and is reviewed by the IncentiFind team before being applied to production data.\n\n**Batch limits:** Maximum **500 items** per request and **5MB** body size. For larger datasets, split into multiple requests. Use the `Idempotency-Key` header on each request so retries are safe.","version":"1.0","contact":{"name":"IncentiFind API Support","url":"https://www.incentifind.com","email":"support@incentifind.com"}},"tags":[],"servers":[],"components":{"securitySchemes":{"api-key":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Your IncentiFind API key (starts with `icf_live_`). Pass via `X-API-Key` header or as a Bearer token in the `Authorization` header."}},"schemas":{}}}