# PullFirst API — Full Documentation > Minnesota contractor intelligence. Permits, licenses, enforcement, OSHA, parcels — one REST API. ## PullFirst API URL: https://pullfirst.com/docs REST API for Minnesota contractor, permit, and enforcement data. PullFirst normalizes contractor data from every Minnesota agency that publishes it and exposes the union as one REST API. Search by license number or by address. List endpoints return a consistent `PagedResult` envelope across resources. Sign in, create a key, make a request. How to authenticate and manage API keys. Signatures, parameters, and examples for every endpoint. Customer-visible changes to the API — endpoints, response shapes, error envelopes, and authentication. How license, permit, enforcement, and OSHA records join — and what the confidence fields mean. Where each field originates, how it attaches, and how fresh it is. Plans and usage limits for the API. ## Contractors and permits [#contractors-and-permits] These are the core resources. Enforcement, OSHA, and parcel records attach to one of them. ```bash title="Contractor lookup" curl "https://api.pullfirst.com/v1/licenses/search?q=Johnson+Electric" \ -H "Authorization: Bearer sk_live_..." ``` ```bash title="Permits at an address" curl "https://api.pullfirst.com/v1/permits/address?address=123+Main+St&city=Minneapolis" \ -H "Authorization: Bearer sk_live_..." ``` ## For AI agents [#for-ai-agents] PullFirst publishes [llms.txt](/llms.txt) and [llms-full.txt](/llms-full.txt) for coding assistants that honor the convention. Point an agent at those files and it can generate an integration against the current spec. ## Quickstart URL: https://pullfirst.com/docs/quickstart Sign in, create a key, make a request. ### Sign in [#sign-in] Google sign-in creates a sandbox account with no credit card required. [Sign in](/signup). ### Create a key [#create-a-key] From the [dashboard](/dashboard), click **Create API key**. The full key is shown exactly once; copy and store it immediately. Keys start with `sk_live_`. ```bash sk_live_a1b2c3d4e5f6... ``` ### Run a query [#run-a-query] Send the key in the `Authorization` header using the Bearer scheme. List endpoints return a `PagedResult` envelope. ```bash title="Contractor lookup" curl "https://api.pullfirst.com/v1/licenses/search?q=Example+Electric" \ -H "Authorization: Bearer sk_live_..." ``` ```bash title="Permits at an address" curl "https://api.pullfirst.com/v1/permits/address?address=123+Main+St&city=Minneapolis" \ -H "Authorization: Bearer sk_live_..." ``` Example response: ```json { "data": [ { "licenseNumber": "EA000000", "name": "EXAMPLE ELECTRICAL CONTRACTOR INC", "status": "Issued", "city": "MINNEAPOLIS", "state": "MN", "licenseType": "Electrical", "licenseSubtype": "Class A Electrical Contractor", "expirationDate": "2028-02-29", "hasEnforcementAction": false } ], "page": 1, "pageSize": 25, "totalCount": 1, "totalPages": 1 } ``` ### Integrate [#integrate] Handle the error envelope documented in [errors](/docs/errors). Watch the rate-limit headers documented in [rate limits](/docs/rate-limits). The full endpoint list is in the [API reference](/docs/api). ## Authentication URL: https://pullfirst.com/docs/authentication API keys via the Authorization header. Authenticate with an API key passed in the `Authorization` header using the Bearer scheme. Keys are 72 characters total: the `sk_live_` prefix followed by 64 hex characters. HTTPS is required; plaintext requests are redirected. ## Sending the key [#sending-the-key] ```bash curl "https://api.pullfirst.com/v1/licenses/search?q=Roofing" \ -H "Authorization: Bearer sk_live_..." ``` The header value is the literal string `Bearer`, a single space, and the full key. ## Key management [#key-management] Keys are created, listed, and revoked from the [dashboard](/dashboard). The full value is displayed exactly once at creation. The stored record keeps a SHA-256 hash of the full key plus a 12-character prefix (`sk_live_` plus four hex characters) used to identify the key in the dashboard. If the full key is lost, rotate: create a new one, deploy it, then revoke the old one. ## Tier caps on active keys [#tier-caps-on-active-keys] Each account can hold a limited number of active keys simultaneously: | Tier | Max active keys | | ---------- | --------------- | | Sandbox | 1 | | Builder | 3 | | Production | 10 | | Enterprise | Unlimited | ## Revocation [#revocation] Revoked keys return `401 unauthorized`. Validation is cached for up to 60 seconds per node, so a just-revoked key may continue to work briefly before the cache expires. ## Public endpoints [#public-endpoints] A small set of endpoints does not require a key: * `/v1/billing/plans` — plan listings * `/v1/coverage` — coverage summary * `/v1/permit-downloads/*` — permit-download editions, samples, and purchase flow * `/v1/sitemap/*` — sitemap data for crawlers * `/v1/licenses/suggest` — license-name autocomplete * `/v1/permits/address-suggest` — address autocomplete * `/v1/match-disputes` — data-quality dispute submission Every other endpoint requires a valid key. ## Rate limits URL: https://pullfirst.com/docs/rate-limits Per-minute and monthly quotas per API key. Each API key has two rate-limit windows: a per-minute burst cap and a calendar-month total. Exceeding either returns `429 rate_limit.exceeded` with a `Retry-After` header indicating when the window opens again. See the [errors reference](/docs/errors#rate_limit.exceeded) for the response envelope. ## Tiers [#tiers] | Tier | Monthly | Per minute | Active keys | Price | | ---------- | --------- | ---------- | ----------- | ------ | | Sandbox | 500 | 10 | 1 | Free | | Builder | 10,000 | 60 | 3 | $19/mo | | Production | 100,000 | 200 | 10 | $79/mo | | Enterprise | Unlimited | Unlimited | Unlimited | Custom | ## Headers [#headers] Every response carries the counters for the active window: ```http X-RateLimit-Limit: 10000 X-RateLimit-Remaining: 8470 X-RateLimit-Reset: 1711756800 ``` `X-RateLimit-Reset` is a Unix timestamp indicating when the window opens. On a `429`, the response also includes `Retry-After` in seconds. When the per-minute window is the one that was breached, the reported `X-RateLimit-Limit` reflects the per-minute cap rather than the monthly cap. ## Staying under quota [#staying-under-quota] Cache responses where data freshness allows. Permits and enforcement records change on the order of hours, not seconds, so caching across requests is usually safe. Use list endpoints with filters to pull many records per request instead of iterating key-by-key. On `429`, wait for the `Retry-After` interval rather than retrying tight. Retries inside the window still count against the per-minute bucket. ## Errors URL: https://pullfirst.com/docs/errors Response envelope, error codes, and consumer guidance for every 4xx and 5xx response. Every error response uses one envelope. Switch on `error.code` for routing logic, surface `error.message` to humans, click `error.doc_url` for the spec of any code you receive, and quote `error.request_id` in support tickets. ```json { "error": { "code": "validation.out_of_range", "message": "lat: The field lat must be between -90 and 90.", "doc_url": "https://pullfirst.com/docs/errors#validation.out_of_range", "request_id": "00-3f9a8c1d4b2e7f6a-9c8e7d6f5a4b3c2d-00", "details": [ { "field": "lat", "issue": "out_of_range", "received": "200", "expected": { "min": -90, "max": 90 } } ] } } ``` ## Envelope fields [#envelope-fields] `code` is the stable machine-readable identifier. Switch on this. Codes use dotted namespaces (`validation.out_of_range`, `query.unknown_param`) so prefix matching works (`error.code.startsWith("validation.")`). `message` is human-readable. Safe to render to operators and end users; never includes stack traces, internal field names, or secrets. `doc_url` links to the section below for the specific code. Click-through is the fastest way to recover from an unfamiliar error. `request_id` matches the `X-Request-Id` response header. Quote this in support requests; it correlates to logs. `details[]` is per-field breakdown for validation errors. Each entry has `field` (parameter name), `issue` (short kind), and may include `received` (the value you sent), `expected` (the constraint), and `suggestion` (a hint when the field name is recognizable). ## Headers [#headers] Every error response also carries: * `X-Request-Id` — same value as `error.request_id`. Set on every response, not just errors. * `X-Error-Code` — same value as `error.code`. Useful for log scrapers, CDN routing, and dashboard aggregation without parsing the body. * `Retry-After` — seconds until the window opens, on `429` and some `503` responses. ## Multiple violations in one response [#multiple-violations-in-one-response] When more than one parameter is invalid, the top-level `code` is `validation.multiple` and `details[]` carries every violation in one response. Fix them all at once instead of round-tripping for each. ```json { "error": { "code": "validation.multiple", "message": "3 parameters were invalid.", "doc_url": "https://pullfirst.com/docs/errors#validation.multiple", "request_id": "00-...", "details": [ { "field": "lat", "issue": "required" }, { "field": "lng", "issue": "out_of_range", "received": 0, "expected": { "min": -180, "max": 180 } }, { "field": "pageSize", "issue": "out_of_range", "received": 500, "expected": { "min": 1, "max": 100 } } ] } } ``` ## Error codes [#error-codes] Codes are stable across minor releases. New codes may be added; existing ones will not be renamed or removed without a major version bump. ### Validation [#validation] #### `validation` [#validation-1] Fallback code when a more specific validation code does not apply. Status: `400`. Action: read `details[].issue` for the specific failure type. #### `validation.required` [#validationrequired] A required parameter was missing or null. Status: `400`. Action: include the named field on the next request. Applies to parameters marked `[BindRequired]`, `[Required]`, or non-nullable primitives without a default. ```bash curl -i "https://api.pullfirst.com/v1/parcels/containing?lat=44.948" \ -H "Authorization: Bearer sk_live_..." # HTTP/1.1 400 Bad Request # X-Error-Code: validation.required # { # "error": { # "code": "validation.required", # "message": "lng: A value for the 'lng' parameter or property was not provided.", # "details": [{ "field": "lng", "issue": "required" }] # } # } ``` #### `validation.out_of_range` [#validationout_of_range] A numeric parameter was outside its declared `[Range]`. Status: `400`. The `expected` object carries `min` and `max`; `received` echoes the value you sent. Action: clamp client-side or surface the bounds to the user. #### `validation.length` [#validationlength] A string parameter violated its `[StringLength]` minimum or maximum. Status: `400`. The `expected` object carries `min` and/or `max` character counts. Action: trim or expand to the declared bounds. #### `validation.format` [#validationformat] A parameter could not be parsed into its declared type. Most commonly: a string that is not a valid date, number, or enum value. Status: `400`. Action: send the value in the format the docs specify (ISO-8601 for dates, plain decimal for numbers). #### `validation.multiple` [#validationmultiple] More than one parameter failed validation. Top-level `code` is `validation.multiple`; per-field codes appear in `details[].issue`. Status: `400`. Action: walk `details[]` and fix every entry. #### `validation.semantic` [#validationsemantic] The request was syntactically valid but semantically incoherent. Cross-field constraints land here: a bounding box where `maxLat < minLat`, a date range where `endDate < startDate`, mutually-exclusive fields both supplied. Status: `422 Unprocessable Entity`. Action: re-derive the relationship the message describes; `details[0].expected` summarizes the rule. ### Query [#query] #### `query.unknown_param` [#queryunknown_param] A query parameter was sent that the endpoint does not declare. Status: `400`. The error often includes a `suggestion` field for typos and well-known aliases (`lon` → `lng`, `latitude` → `lat`, `q` → `query`). Action: rename or remove the offending key. The first unknown key is named in `error.message`; all unknown keys appear in `details[]`. ```bash curl -i "https://api.pullfirst.com/v1/parcels/near?lat=44.948&lon=-93.090&radiusMeters=300" \ -H "Authorization: Bearer sk_live_..." # HTTP/1.1 400 Bad Request # X-Error-Code: query.unknown_param # { # "error": { # "code": "query.unknown_param", # "message": "Unknown query parameter 'lon'. Did you mean 'lng'?", # "details": [{ "field": "lon", "issue": "not_recognized", "received": "-93.090", "suggestion": "lng" }] # } # } ``` The whitelist of always-allowed parameters is `api_key` and `apikey`; everything else must be declared on the action. #### `query.duplicate_param` [#queryduplicate_param] Reserved for future use — the same query key appearing more than once with conflicting semantics. Currently the API accepts duplicate keys per ASP.NET binding rules. ### Body [#body] #### `body.malformed_json` [#bodymalformed_json] The request body is not valid JSON, or its top-level shape does not match the documented contract. Status: `400`. Action: validate the payload against the OpenAPI schema before retrying. ### Filter [#filter] #### `filter.parse_error` [#filterparse_error] The Gridify filter expression in `?filter=` could not be parsed. Status: `400`. The `message` carries the parser's diagnostic. Action: see [Filtering](/docs/filtering) for grammar. #### `filter.unknown_field` [#filterunknown_field] The filter expression references a field that is not exposed for filtering on this endpoint. Status: `400`. Action: see the per-endpoint OpenAPI page for the list of filterable fields. ### Authentication [#authentication] #### `auth.missing` [#authmissing] The `Authorization: Bearer ...` header is absent. Status: `401`. Action: include a valid API key. See [Authentication](/docs/authentication). #### `auth.invalid` [#authinvalid] The supplied key is malformed, expired, deactivated, or does not have access to the requested resource. Status: `403`. Action: rotate or upgrade the key on your dashboard. ### Resource [#resource] #### `not_found` [#not_found] The resource at the requested path does not exist or is not visible to your key. Status: `404`. Action: confirm the identifier and your key's plan. #### `method_not_allowed` [#method_not_allowed] The path is valid but does not accept the HTTP method you used. Status: `405`. Action: see the OpenAPI page for the supported methods. #### `conflict` [#conflict] The request would violate a constraint or precondition (duplicate identifier, race against concurrent state, payment not yet completed). Status: `409`. Action: refresh state and retry; the message describes the specific conflict. #### `unsupported_media_type` [#unsupported_media_type] The request's `Content-Type` is not one this endpoint accepts. Status: `415`. Action: send `application/json`. ### Throttling [#throttling] #### `rate_limit.exceeded` [#rate_limitexceeded] The per-minute or monthly quota was exceeded. Status: `429`. The response carries `Retry-After` in seconds; respect that value in place of your own backoff. Action: see [Rate limits](/docs/rate-limits) for tier ceilings. ### Server [#server] #### `internal.error` [#internalerror] An unexpected error occurred on our side. Status: `500`. The `message` is intentionally generic; `request_id` is the correlation key for support. Action: retry with exponential backoff (one second, doubling, capped at sixty); if it persists, contact support with the `request_id`. #### `upstream.bad_gateway` [#upstreambad_gateway] A required upstream service returned an unparseable response. Status: `502`. Action: retry with backoff; if it persists, the upstream is degraded — check status.pullfirst.com. #### `upstream.unavailable` [#upstreamunavailable] A required upstream service is unreachable or returning errors. Status: `503`. Action: retry with backoff; honor `Retry-After` if present. #### `upstream.timeout` [#upstreamtimeout] A required upstream service did not respond within the timeout window. Status: `504`. Action: retry with backoff. ### Generic fallback [#generic-fallback] #### `bad_request` [#bad_request] A 4xx response that does not fit any more specific code. Status: `400`. Action: read `error.message` for the specific failure. ## Status code → category [#status-code--category] | HTTP | Default code | Notes | | ---- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | 400 | `bad_request` or specific `validation.*` / `query.*` / `filter.*` | Single-field issues land in `validation.*`; structural issues land in `query.*` or `body.*`. | | 401 | `auth.missing` | Bearer header absent. | | 403 | `auth.invalid` | Bearer header present but rejected. | | 404 | `not_found` | Resource or route. | | 405 | `method_not_allowed` | Wrong verb on a known route. | | 409 | `conflict` | Resource state collision. | | 415 | `unsupported_media_type` | Wrong `Content-Type`. | | 422 | `validation.semantic` | Cross-field validation failure. | | 429 | `rate_limit.exceeded` | Quota exceeded. `Retry-After` set. | | 500 | `internal.error` | Unexpected server error. | | 502 | `upstream.bad_gateway` | Upstream returned an invalid response. | | 503 | `upstream.unavailable` | Upstream unreachable. | | 504 | `upstream.timeout` | Upstream timed out. | ## Retry guidance [#retry-guidance] 4xx responses are deterministic — fix the request before retrying. The exception is `429`, which is recovered by waiting out `Retry-After`. 5xx responses are worth retrying with exponential backoff: start at one second, double each attempt, cap at sixty seconds. Stop after five attempts and surface the `request_id` to the user; persistent failures usually need investigation rather than more retries. Always honor `Retry-After` when present, in place of your own backoff. ## Stability promise [#stability-promise] Codes are part of the API contract: * New codes may be added. * Existing codes will not be renamed. * Existing codes will not be removed without a major version bump. * Fields on the envelope may be added; existing fields will not be removed or have their type changed. Switch on `error.code` with confidence. ## Filtering URL: https://pullfirst.com/docs/filtering Gridify syntax for filtering, sorting, and combining conditions on list endpoints. List endpoints accept `filter` and `orderBy` query parameters using [Gridify](https://alirezanet.github.io/Gridify/) syntax. The operators and examples below apply uniformly to paged endpoints on the API. ## Operators [#operators] | Operator | Meaning | Example | | ----------------- | ---------------- | ---------------------------------- | | `=` | Equals | `status=Issued` | | `!=` | Not equals | `status!=Expired` | | `=*` | Contains | `name=*Roofing*` | | `^` | Starts with | `licenseNumber^BC` | | `$` | Ends with | `name$LLC` | | `>` `<` `>=` `<=` | Comparison | `expirationDate>2025-01-01` | | `,` | AND | `city=Minneapolis,status=Issued` | | `\|` | OR | `status=Revoked\|status=Suspended` | | `/i` | Case-insensitive | `name=*roofing/i` | ## Examples [#examples] Active contractors in Minneapolis: ``` filter=city=Minneapolis,status=Issued ``` Enforcement actions with penalties over $10k, newest first: ``` filter=penaltyAmount>10000&orderBy=orderDate desc ``` OSHA inspections with willful violations at roofing companies: ``` filter=establishmentName=*Roofing*,willfulViolations>0 ``` ## Sorting [#sorting] `orderBy` takes a field name with optional `asc` or `desc`: ``` orderBy=expirationDate desc ``` Chain sorts with commas: ``` orderBy=status asc,expirationDate desc ``` ## Pagination [#pagination] Paged endpoints accept `page` and `pageSize`. Defaults are page 1 at 25 records; the maximum `pageSize` is 100. The response envelope carries `totalCount` and `totalPages`. ## API Reference URL: https://pullfirst.com/docs/api Endpoints grouped by resource. Reference pages are generated from the OpenAPI spec. Each page lists the request signature, parameters, response shape, and example payloads. License records from MN DLI. Search by name, license number, city, type, or enforcement flag. Building and trade permits across Minnesota jurisdictions. Search by address, block, or contractor. Coverage and per-county field-quality (contractor name, lat/lng, description fill rates) live at [/coverage](/coverage). Property parcels with permit linkage. Search by PIN, address, bounding box, or point-in-polygon. DLI disciplinary orders, revocations, and penalties. Workplace safety inspections, violations, and accidents. Attorney General lawsuits, settlements, and consent decrees. ## Match tiers [#match-tiers] Enforcement, OSHA, and AG records attach to contractor records via a match pipeline. Each match carries a tier: * **VERIFIED** — high-confidence match (exact license number, or address plus name). * **LIKELY** — strong fuzzy match, manually auditable. * **POSSIBLE** — weaker fuzzy match; use with judgment. * **AMBIGUOUS** — multiple candidate contractors. Treat the match as a pointer rather than a conclusion. For aggregate reporting, filter to VERIFIED and LIKELY unless you have a specific reason to include the rest. --- # Endpoint Reference ## Get AG enforcement actions matched to a specific license number URL: https://pullfirst.com/docs/api/agenforcement/v1/ag-enforcement/by-license/licensenumber/get Returns Minnesota AG consumer-protection actions matched to this license number via fuzzy name matching (AG records rarely carry a license number natively). Each record carries a tier: VERIFIED (name plus geographic corroboration), LIKELY (unambiguous name match), POSSIBLE (weaker fuzzy score), AMBIGUOUS (name collides with multiple licensees — cannot be uniquely attributed). Callers must handle AMBIGUOUS. Returns Minnesota AG consumer-protection actions matched to this license number via fuzzy name matching (AG records rarely carry a license number natively). Each record carries a tier: VERIFIED (name plus geographic corroboration), LIKELY (unambiguous name match), POSSIBLE (weaker fuzzy score), AMBIGUOUS (name collides with multiple licensees — cannot be uniquely attributed). Callers must handle AMBIGUOUS. ## Search AG enforcement actions with filtering, sorting, and paging URL: https://pullfirst.com/docs/api/agenforcement/v1/ag-enforcement/get Uses Gridify syntax for filtering. Examples: - `companyName=*Construction*` - company name contains - `city=Minneapolis` - exact city match - `orderType=Settlement` - filter by order type - `penaltyAmount>1000000` - penalty greater than $1M - `orderDate>2022-01-01` - orders after date Order types: Lawsuit, Settlement, Judgment, Consent Decree Uses Gridify syntax for filtering. Examples: - `companyName=*Construction*` - company name contains - `city=Minneapolis` - exact city match - `orderType=Settlement` - filter by order type - `penaltyAmount>1000000` - penalty greater than $1M - `orderDate>2022-01-01` - orders after date Order types: Lawsuit, Settlement, Judgment, Consent Decree ## Get a specific AG enforcement action by ID URL: https://pullfirst.com/docs/api/agenforcement/v1/ag-enforcement/id/get ## Get recent AG enforcement actions URL: https://pullfirst.com/docs/api/agenforcement/v1/ag-enforcement/recent/get ## Get AG enforcement statistics URL: https://pullfirst.com/docs/api/agenforcement/v1/ag-enforcement/stats/get Returns aggregated AG enforcement data including: - Total cases and penalties - Cases by year - Cases by order type Returns aggregated AG enforcement data including: - Total cases and penalties - Cases by year - Cases by order type ## Get enforcement actions matched to a specific license number URL: https://pullfirst.com/docs/api/enforcement/v1/enforcement/by-license/licensenumber/get Returns DLI disciplinary orders matched to this license number. When the order document names a license number explicitly, the match is VERIFIED. When the order only names a company and the normalized name is unique across licensees, the match is LIKELY. When the name collides with multiple licensees, the match is AMBIGUOUS and cannot be uniquely attributed — callers must handle that tier. Every record carries a tier field on the response. Returns DLI disciplinary orders matched to this license number. When the order document names a license number explicitly, the match is VERIFIED. When the order only names a company and the normalized name is unique across licensees, the match is LIKELY. When the name collides with multiple licensees, the match is AMBIGUOUS and cannot be uniquely attributed — callers must handle that tier. Every record carries a tier field on the response. ## Search enforcement actions with filtering, sorting, and paging URL: https://pullfirst.com/docs/api/enforcement/v1/enforcement/get Uses Gridify syntax for filtering. Examples: - `companyName=*Roofing*` - company name contains - `city=Minneapolis` - exact city match - `orderType=Revocation` - filter by order type - `penaltyAmount>5000` - penalty greater than $5000 - `orderDate>2024-01-01` - orders after date Order types: Consent Order, Administrative Order, Revocation, Cease and Desist Uses Gridify syntax for filtering. Examples: - `companyName=*Roofing*` - company name contains - `city=Minneapolis` - exact city match - `orderType=Revocation` - filter by order type - `penaltyAmount>5000` - penalty greater than $5000 - `orderDate>2024-01-01` - orders after date Order types: Consent Order, Administrative Order, Revocation, Cease and Desist ## Get a specific enforcement action by ID URL: https://pullfirst.com/docs/api/enforcement/v1/enforcement/id/get ## Get recent enforcement actions URL: https://pullfirst.com/docs/api/enforcement/v1/enforcement/recent/get ## Get enforcement trends over time URL: https://pullfirst.com/docs/api/enforcement/v1/enforcement/trends/get Returns aggregated enforcement data grouped by: - Year (count and total penalties) - Order type (Consent Order, Administrative Order, etc.) - Violation type (electrical, plumbing, etc.) Returns aggregated enforcement data grouped by: - Year (count and total penalties) - Order type (Consent Order, Administrative Order, etc.) - Violation type (electrical, plumbing, etc.) ## /v1/licenses/batch URL: https://pullfirst.com/docs/api/licenses/v1/licenses/batch/get ## Get top cities by license count URL: https://pullfirst.com/docs/api/licenses/v1/licenses/cities/get ## /v1/licenses/city/{city} URL: https://pullfirst.com/docs/api/licenses/v1/licenses/city/city/get ## /v1/licenses/featured URL: https://pullfirst.com/docs/api/licenses/v1/licenses/featured/get ## Advanced filtering with Gridify syntax URL: https://pullfirst.com/docs/api/licenses/v1/licenses/get Uses Gridify syntax for filtering. Examples: - `city=Minneapolis` - exact match - `name=*Roofing*` - contains - `status=Issued,status=Expired` - OR condition - `city=Minneapolis,status=Issued` - AND condition - `expirationDate>2025-01-01` - date comparison Sortable fields: name, city, status, licenseType, expirationDate Uses Gridify syntax for filtering. Examples: - `city=Minneapolis` - exact match - `name=*Roofing*` - contains - `status=Issued,status=Expired` - OR condition - `city=Minneapolis,status=Issued` - AND condition - `expirationDate>2025-01-01` - date comparison Sortable fields: name, city, status, licenseType, expirationDate ## Get the full contractor aggregate for a license URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/full/get Resolves the license to its parent contractor entity and returns every member license plus permits, enforcement, OSHA records, and Google Places for the whole group. Permits are deduplicated by permit id even when multiple member licenses matched them. Match records carry a tier field (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS); AMBIGUOUS rows cannot be uniquely attributed and must be handled explicitly by callers. When the license has no resolved contractor (edge case), only that single license is returned with `contractorId = null` and no aggregated data. Resolves the license to its parent contractor entity and returns every member license plus permits, enforcement, OSHA records, and Google Places for the whole group. Permits are deduplicated by permit id even when multiple member licenses matched them. Match records carry a tier field (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS); AMBIGUOUS rows cannot be uniquely attributed and must be handled explicitly by callers. When the license has no resolved contractor (edge case), only that single license is returned with `contractorId = null` and no aggregated data. ## Get a specific license by its license number URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/get ## /v1/licenses/{licenseNumber}/osha URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/osha/get ## Get OSHA inspection history matched to a contractor URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/osha/summary/get Returns OSHA inspections matched to this license number via fuzzy name matching. Each inspection carries a tier (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS) describing match strength. VERIFIED requires geographic corroboration; AMBIGUOUS means the establishment name collides with multiple licensees and cannot be uniquely attributed. Callers must handle AMBIGUOUS. Summary aggregations (inspection counts, violation totals, penalty sums) use VERIFIED matches only. Returns OSHA inspections matched to this license number via fuzzy name matching. Each inspection carries a tier (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS) describing match strength. VERIFIED requires geographic corroboration; AMBIGUOUS means the establishment name collides with multiple licensees and cannot be uniquely attributed. Callers must handle AMBIGUOUS. Summary aggregations (inspection counts, violation totals, penalty sums) use VERIFIED matches only. ## /v1/licenses/{licenseNumber}/permits URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/permits/get Returns permits matched to this license number. Each permit carries a tier (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS): VERIFIED requires name plus geographic corroboration; LIKELY is name-alone when the normalized name is unique across licensees; POSSIBLE is a weaker fuzzy score; AMBIGUOUS means the name collides with multiple licensees and cannot be uniquely attributed. Callers must handle AMBIGUOUS. Match quality indicators (score, method, confidence) are returned alongside the tier. Returns permits matched to this license number. Each permit carries a tier (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS): VERIFIED requires name plus geographic corroboration; LIKELY is name-alone when the normalized name is unique across licensees; POSSIBLE is a weaker fuzzy score; AMBIGUOUS means the name collides with multiple licensees and cannot be uniquely attributed. Callers must handle AMBIGUOUS. Match quality indicators (score, method, confidence) are returned alongside the tier. ## /v1/licenses/{licenseNumber}/permits/locations URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/permits/locations/get ## Get permit records matched to this contractor URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/permits/summary/get ## Find contractors related by shared address or phone number URL: https://pullfirst.com/docs/api/licenses/v1/licenses/licensenumber/related/get Helps identify contractors operating from the same location or using the same contact info. Useful for detecting contractors who may have had licenses revoked and reopened under a new name. Helps identify contractors operating from the same location or using the same contact info. Useful for detecting contractors who may have had licenses revoked and reopened under a new name. ## Flexible search across name, DBA, and license number with optional filters URL: https://pullfirst.com/docs/api/licenses/v1/licenses/search/get Normalizes input (strips punctuation, handles spacing) and searches: - Company/person name - DBA (doing business as) name - License number Can be combined with filters for license type, status, city, and entity type. Results sorted by enforcement actions first, then alphabetically. If no filters are provided, returns all licenses paginated. Normalizes input (strips punctuation, handles spacing) and searches: - Company/person name - DBA (doing business as) name - License number Can be combined with filters for license type, status, city, and entity type. Results sorted by enforcement actions first, then alphabetically. If no filters are provided, returns all licenses paginated. ## Get database statistics for display URL: https://pullfirst.com/docs/api/licenses/v1/licenses/stats/get ## Get search suggestions (autocomplete) URL: https://pullfirst.com/docs/api/licenses/v1/licenses/suggest/get Returns contractor names matching the query prefix for autocomplete. Fast and lightweight - returns only name, license number, and type. Respects license type filter if provided. Returns contractor names matching the query prefix for autocomplete. Fast and lightweight - returns only name, license number, and type. Respects license type filter if provided. ## Get all license subtypes with their parent type URL: https://pullfirst.com/docs/api/lookup/v1/license-subtypes/get Subtypes include: Residential Building Contractor, Residential Roofer, etc. Subtypes include: Residential Building Contractor, Residential Roofer, etc. ## Get all license types (e.g., Residential Contractors, Electrical, Plumbing) URL: https://pullfirst.com/docs/api/lookup/v1/license-types/get Use the Code field for filtering licenses by type. Use the Code field for filtering licenses by type. ## Get all license statuses URL: https://pullfirst.com/docs/api/lookup/v1/statuses/get Statuses include: Issued, Expired, Revoked, Suspended, Voluntary Termination, etc. Use isActive to filter for currently valid licenses. Statuses include: Issued, Expired, Revoked, Suspended, Voluntary Termination, etc. Use isActive to filter for currently valid licenses. ## Get detailed inspection information by activity number URL: https://pullfirst.com/docs/api/osha/v1/osha/activitynumber/get Retrieves full inspection details including: - Establishment info and address - NAICS code and industry classification - Inspection type, scope, and dates - All violations with penalties - Accident records (if any) - Contractor matches, each with a tier (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS) The activity number uniquely identifies each OSHA inspection. An inspection may carry AMBIGUOUS matches when the establishment name collides with multiple licensees; callers must handle that tier. Retrieves full inspection details including: - Establishment info and address - NAICS code and industry classification - Inspection type, scope, and dates - All violations with penalties - Accident records (if any) - Contractor matches, each with a tier (VERIFIED / LIKELY / POSSIBLE / AMBIGUOUS) The activity number uniquely identifies each OSHA inspection. An inspection may carry AMBIGUOUS matches when the establishment name collides with multiple licensees; callers must handle that tier. ## Get OSHA inspections matched to a contractor by license number URL: https://pullfirst.com/docs/api/osha/v1/osha/by-license/licensenumber/get Returns OSHA inspections matched to the specified license number via fuzzy name matching. Each inspection carries a tier field describing match strength: VERIFIED (name plus geographic corroboration), LIKELY (name match, name is unique across licensees), POSSIBLE (fuzzy name match with weaker score), AMBIGUOUS (name collides with multiple licensees — cannot be uniquely attributed). Callers must handle AMBIGUOUS; typical handling is to surface the collision or exclude from contractor-specific views. Aggregation (counts, totals) is computed against VERIFIED matches only. Only matches with confidence score >= 60 are returned. Returns OSHA inspections matched to the specified license number via fuzzy name matching. Each inspection carries a tier field describing match strength: VERIFIED (name plus geographic corroboration), LIKELY (name match, name is unique across licensees), POSSIBLE (fuzzy name match with weaker score), AMBIGUOUS (name collides with multiple licensees — cannot be uniquely attributed). Callers must handle AMBIGUOUS; typical handling is to surface the collision or exclude from contractor-specific views. Aggregation (counts, totals) is computed against VERIFIED matches only. Only matches with confidence score >= 60 are returned. ## /v1/osha/cities URL: https://pullfirst.com/docs/api/osha/v1/osha/cities/get ## Advanced filtering with Gridify syntax URL: https://pullfirst.com/docs/api/osha/v1/osha/get Uses Gridify syntax for filtering. Examples: - `siteCity=Minneapolis` - exact city match - `establishmentName=*Roofing*` - name contains - `totalPenalty>5000` - penalty greater than $5000 - `openDate>2024-01-01` - inspections after date - `totalSeriousViolations>0` - has serious violations Sortable fields: establishmentName, siteCity, openDate, totalPenalty, totalCitedViolations Uses Gridify syntax for filtering. Examples: - `siteCity=Minneapolis` - exact city match - `establishmentName=*Roofing*` - name contains - `totalPenalty>5000` - penalty greater than $5000 - `openDate>2024-01-01` - inspections after date - `totalSeriousViolations>0` - has serious violations Sortable fields: establishmentName, siteCity, openDate, totalPenalty, totalCitedViolations ## Search OSHA inspections by establishment name, city, or other criteria URL: https://pullfirst.com/docs/api/osha/v1/osha/search/get Searches local OSHA inspection data for Minnesota establishments. Data comes from OSHA's Integrated Management Information System (IMIS) and includes both Federal OSHA and State Plan inspections. Searches local OSHA inspection data for Minnesota establishments. Data comes from OSHA's Integrated Management Information System (IMIS) and includes both Federal OSHA and State Plan inspections. ## Get OSHA database statistics URL: https://pullfirst.com/docs/api/osha/v1/osha/stats/get Returns aggregate statistics about the OSHA inspection database including: - Total inspections and violations - Serious and willful violation counts - Fatality count - Total penalties assessed - Date range of data - Breakdown by violation type Returns aggregate statistics about the OSHA inspection database including: - Total inspections and violations - Serious and willful violation counts - Fatality count - Total penalties assessed - Date range of data - Breakdown by violation type ## The single parcel whose geometry contains the given point, or 404. URL: https://pullfirst.com/docs/api/parcels/v1/parcels/containing/get ## Search parcels by owner, address, or PIN. URL: https://pullfirst.com/docs/api/parcels/v1/parcels/get ## Get a single parcel with its joined permits. URL: https://pullfirst.com/docs/api/parcels/v1/parcels/id/get ## Paginated permits for a parcel. URL: https://pullfirst.com/docs/api/parcels/v1/parcels/id/permits/get ## Parcels within a radius (meters) of a point, ordered by distance. URL: https://pullfirst.com/docs/api/parcels/v1/parcels/near/get ## Parcels whose geometry intersects the given bounding box (SRID 4326). URL: https://pullfirst.com/docs/api/parcels/v1/parcels/within-bbox/get ## /v1/permit-downloads/editions URL: https://pullfirst.com/docs/api/permiteditions/v1/permit-downloads/editions/get ## /v1/permit-downloads/editions/{scopeType}/{scopeSlug}/{periodType}/{periodKey} URL: https://pullfirst.com/docs/api/permiteditions/v1/permit-downloads/editions/scopetype/scopeslug/periodtype/periodkey/get ## /v1/permit-downloads/editions/{scopeType}/{scopeSlug}/{periodType}/{periodKey}/sample URL: https://pullfirst.com/docs/api/permiteditions/v1/permit-downloads/editions/scopetype/scopeslug/periodtype/periodkey/sample/get ## /v1/permits/address-suggest URL: https://pullfirst.com/docs/api/permits/v1/permits/address-suggest/get ## /v1/permits/address URL: https://pullfirst.com/docs/api/permits/v1/permits/address/get ## /v1/permits/area-locations URL: https://pullfirst.com/docs/api/permits/v1/permits/area-locations/get ## /v1/permits/block URL: https://pullfirst.com/docs/api/permits/v1/permits/block/get ## List cities by permit count URL: https://pullfirst.com/docs/api/permits/v1/permits/cities/get ## /v1/permits/density URL: https://pullfirst.com/docs/api/permits/v1/permits/density/get ## Search stored permit records URL: https://pullfirst.com/docs/api/permits/v1/permits/get Searches locally stored permit data across Minnesota jurisdictions. Supports fuzzy contractor name matching, normalized city filter, permit type filter, and Gridify advanced filtering. Examples: - `filter=City=Saint Paul` - exact city match - `filter=Value>50000` - value greater than $50,000 - `filter=PermitType=*Electrical` - permit type contains - `filter=IssueDate>2025-01-01,Status=Issued` - multiple conditions (AND) Filterable fields: Id, PermitNumber, City, State, ContractorName, ContractorAddress, PropertyAddress, PermitType, WorkType, Status, Value, Description, IssueDate, CompleteDate, ApprovedDate, ExpirationDate. Searches locally stored permit data across Minnesota jurisdictions. Supports fuzzy contractor name matching, normalized city filter, permit type filter, and Gridify advanced filtering. Examples: - `filter=City=Saint Paul` - exact city match - `filter=Value>50000` - value greater than $50,000 - `filter=PermitType=*Electrical` - permit type contains - `filter=IssueDate>2025-01-01,Status=Issued` - multiple conditions (AND) Filterable fields: Id, PermitNumber, City, State, ContractorName, ContractorAddress, PropertyAddress, PermitType, WorkType, Status, Value, Description, IssueDate, CompleteDate, ApprovedDate, ExpirationDate. ## Permit density heatmap with optional filters URL: https://pullfirst.com/docs/api/permits/v1/permits/heatmap/get Returns grid clusters (~0.05° cells, identical math to /density) with the applied filters echoed back. Unfiltered calls return the same clusters and total as /density. Each cluster carries both a permit count and a summed declared value, regardless of the active weight — only the sort order changes when weight=value. Time axis: pass either ?year= OR ?from= and ?to=, never both. permitType has no dedicated index — pair it with a time, city, or contractor filter to stay under the 20s p95 ceiling on a state-wide query. Returns grid clusters (~0.05° cells, identical math to /density) with the applied filters echoed back. Unfiltered calls return the same clusters and total as /density. Each cluster carries both a permit count and a summed declared value, regardless of the active weight — only the sort order changes when weight=value. Time axis: pass either ?year= OR ?from= and ?to=, never both. permitType has no dedicated index — pair it with a time, city, or contractor filter to stay under the 20s p95 ceiling on a state-wide query. ## /v1/permits/{id} URL: https://pullfirst.com/docs/api/permits/v1/permits/id/get ## /v1/permits/points URL: https://pullfirst.com/docs/api/permits/v1/permits/points/get ## Get permit database statistics URL: https://pullfirst.com/docs/api/permits/v1/permits/stats/get Returns aggregate statistics across all stored permits including totals, the issued-date span, summed declared value, geocoding coverage, distinct city count, and the top permit types by count. Returns aggregate statistics across all stored permits including totals, the issued-date span, summed declared value, geocoding coverage, distinct city count, and the top permit types by count. ## /v1/permits/viewport-contractors URL: https://pullfirst.com/docs/api/permits/v1/permits/viewport-contractors/get ## Changelog URL: https://pullfirst.com/docs/changelog Customer-visible changes to the PullFirst API — endpoints, response shapes, error envelopes, and authentication. ## Matching pipeline URL: https://pullfirst.com/docs/guides/matching How PullFirst joins public contractor records across licenses, permits, enforcement, and OSHA, and what the confidence fields on each link mean. ## Data sources URL: https://pullfirst.com/docs/guides/sources Where each field on a PullFirst contractor record originates, how it attaches to the record, and how fresh it is.