API and MCP
Two things live here. Anyone can verify a Florida contractor license for free, no account and no key. And if you subscribe to Growth, you can read your own territory straight into your CRM.
There is no endpoint that lists other people's contractors, and there never will be. That is deliberate, and it is the first thing most people ask about, so it is the first thing said here.
Not a developer? You do not need any of this to check someone out. The search on this site gives you the same license record in a couple of seconds, free and without a key. And a Growth subscriber does not have to write code to receive new addresses either: point a webhook at your CRM or at Zapier and they arrive as they land.
Verify a license (free)
One license per call. Status, class, expiry, the qualifier, whether we hold a state workers' comp record, and how many permits we can evidence under that license.
curl "https://floridacontractorcheck.com/api/v1/verify?license=CCC1330911"{
"found": true,
"license_number": "CCC1330911",
"business_name": "P.J. Roofing, Inc",
"license_type_name": "Certified Roofing Contractor",
"status": "Current, Active",
"expires": "2028-08-31",
"flags": {
"active": true,
"lapsed": false,
"probation": false,
"workers_comp_on_record": false,
"workers_comp_source": null
},
"permits": {
"at_least": 204,
"since": "2024-09-12",
"scope": "Florida",
"counties_at_least": 13,
"floor_framed": true,
"phrase": "at least 204 permits on record in Florida in the last 24 months"
},
"profile_url": "https://floridacontractorcheck.com/contractor/..."
}That is a real response, trimmed for width and captured on 12 September 2026 against a real public license record. Every number in it moves. expires changes when the contractor renews. at_least and counties_at_least are counted over a rolling twenty four month window, so since advances by a day every day and both counts move with it. Read the shape, not the values, and never assert against them in a test.
Read the permit number as a floor.
It says "at least" because that is the truth. Florida has hundreds of building departments and we do not hold complete records from all of them. Some of them cannot be reached at all. So the number is what we can evidence, not what the contractor filed, and if you present it as a total your user will eventually catch you out. Use the phrase we give you.
What it costs you
Nothing. With no key at all you get 20 calls a day per IP, which is enough to try it without signing up for anything. A free developer key takes that to 100 a day. Both are capped at 30 a minute.
Not found returns 200 with {"found": false}. A license number without its prefix that matches several license types returns the candidates and asks you to re-send with the prefix, because guessing which one you meant is worse than asking.
MCP
One tool, verify_florida_contractor, over Streamable HTTP at /api/mcp. Point your agent at it and it can check a license mid-conversation.
{
"mcpServers": {
"floridacontractorcheck": {
"url": "https://floridacontractorcheck.com/api/mcp"
}
}
}The manifest is at /.well-known/mcp.json. The tool takes a license number and returns the same shape as the REST endpoint above, so anything you build against one works against the other.
The Growth API
Included with Growth. No separate SKU, no per-seat add-on. Create your key on your billing page and send it as X-API-Key.
Your key sees exactly what your portal sees. Your ZIPs, your trade, your county cluster, your inquiries, your book. Pass a county or a ZIP outside that and we ignore it rather than trusting it, so a typo in your integration cannot quietly widen your reach. Ask for a county that is entirely outside your area and you get a 403 that names the boundary, because answering with your home county instead would just look like that county was empty.
| Endpoint | What it gives you |
|---|---|
| GET /api/v1/me | Your plan, your scope, your caps, your credits. Free. |
| GET /api/v1/usage | Credits and rate state. Free. |
| GET /api/v1/territory | Your territory rows, the same ones the portal renders. |
| GET /api/v1/territory/export.csv | The same rows as a file. Once a day. |
| GET /api/v1/project-leads | Open masters in your county cluster. |
| GET /api/v1/horizon | Pre-permit pipeline in your cluster. |
| GET /api/v1/inquiries | Homeowners who asked for you. Costs no credits. |
| GET /api/v1/book | Your past customers whose system is due again. |
| POST /api/v1/webhooks | Get pushed instead of polling. Deliveries are free. |
Caps
| Limit | Value | Notes |
|---|---|---|
| Record credits per month | 10,000 on a standard ZIP, 20,000 on a dense one | Deduplicated inside the billing month. A record we already served your key this month costs nothing. |
| Requests per minute | 60 | Per key. 429 with a Retry-After header. |
| Requests per day | 5,000 | Per key, resets at UTC midnight. |
| Rows per page | 100 | Ask for more and you get 100. We clamp instead of erroring. |
| CSV exports per day | 1 | One per key. Rows still cost credits. |
| Webhook deliveries | Free | Events never touch your record credits. |
How credits actually work
We bill the record, not the request. The first time your key is served a given row this month it costs one credit. Every time after that, for the rest of the month, it costs zero.
So a nightly re-sync of your whole territory is free after the first run. Only genuinely new addresses cost anything, and a typical lane picks up a few dozen a month.
Watch three headers and you never have to guess: X-Credits-Remaining, X-RateLimit-Remaining and X-RateLimit-Day-Remaining. A page that would take you past your ceiling is refused whole with a 402 rather than trimmed, because half a page and a 200 gives you no way to tell you were cut off.
curl -H "X-API-Key: $FCC_KEY" \
"https://floridacontractorcheck.com/api/v1/territory?page_size=100"
# paging
curl -H "X-API-Key: $FCC_KEY" \
"https://floridacontractorcheck.com/api/v1/territory?cursor=100&page_size=100"Polling and triggers
Every list gives you three things so a polling integration can be built correctly. Each row carries a stable id, which is the only thing you should deduplicate on: it does not change between polls and it is the same key your record credits are counted against. Rows come back newest first by default. And since takes an ISO 8601 date or timestamp and gives you an inclusive lower bound on the row's own date, so you can ask what is new rather than walking the whole set again.
since and cursor work together. since narrows the set and cursor pages inside it. A row we cannot date is left out while since is set, and the response tells you how many in undated_excluded. If we cannot read your since we return a 400 rather than ignoring it, because a filter that is quietly dropped turns your window into a full re-read and nothing in the response would have told you.
order is there when the default is not what you want. Territory also takes lead_score, which is the ranking the portal shows and usually the one a person wants, since territory is a list of properties that are overdue and newest-permit-first is close to the opposite of best-lead-first. Horizon also takes nearest. Book also takes due. Your book releases over time and neither order nor since unlocks a home that has not been released yet.
# everything filed in your counties since Monday, newest first
curl -H "X-API-Key: $FCC_KEY" \
"https://floridacontractorcheck.com/api/v1/project-leads?since=2026-09-08"Your own CRM
The routes above hand you what we found. These hand you what you keep: the contacts, jobs, estimates and invoices sitting in your portal right now. It is the same data the CRM screens render, and you can read it and write it from wherever you actually work.
None of it costs record credits. We bill for records we sourced, and your own customer list is not one of those. Rate limits still apply, so a runaway sync still gets a 429, but a nightly two-way sync of your whole book costs you nothing all month.
| Endpoint | What it gives you |
|---|---|
| GET, POST /api/v1/contacts | Your customers and prospects. |
| GET, PATCH /api/v1/contacts/{id} | One of them. |
| GET, POST /api/v1/jobs | Your pipeline items and where each one sits. |
| GET, PATCH /api/v1/jobs/{id} | One job. PATCH the stage and a webhook fires. |
| GET, POST /api/v1/estimates | Your estimates, with their totals. |
| GET, PATCH /api/v1/estimates/{id} | One estimate. |
| GET, POST /api/v1/invoices | Invoices and what has been paid against them. |
| GET, PATCH /api/v1/invoices/{id} | One invoice. |
| GET /api/v1/events | Everything that has happened, oldest first, by id. Read only. |
There is no DELETE anywhere in here, and that is on purpose rather than unfinished. An integration with a bad loop can empty a CRM in about four seconds, and you would be asking us to restore it from a backup you cannot see. Mark things closed or void instead.
No permit data comes out of these
Some of these rows were born from a permit, so they carry permit fields internally. We do not serve those fields here. A job that came out of your territory gives you the address and the stage and none of the underlying record, and that is a deliberate line rather than an oversight: permit data is sold on its own terms, and a CRM sync is not a side door into it. If you want the permit side, that is what the routes above are for and what your credits buy.
Scopes
Your key can be narrowed when you create it. Send a scopes list to the portal key route and you get a key that can do less: handy when the thing holding the key is a reporting dashboard that has no business writing anything. Scopes only ever narrow. No scope reaches outside your license, and an unknown scope name is a 400 that names it rather than a key handed back with the permission quietly missing.
Ask for something your key was not scoped for and you get a 403 saying which scope you needed. webhooks:write is the one that is off unless you ask for it, because registering a webhook is the only action that makes us send traffic to an address you chose.
Paging here is not paging up there
Worth a minute, because it will bite you otherwise. On the territory and lead routes, cursor is a row offset and you can do arithmetic on it. On these CRM routes it is an opaque token we issue, and the only correct thing to do with it is hand back the next_cursor we gave you. Keep going until next_cursor comes back null.
Send a number here and you get a 400, not a page. We would rather refuse than guess, because guessing means starting a hundred rows in and skipping a hundred of your own contacts with a 200 that looks perfectly healthy. Rows come back newest first, and the token is stable across writes, so a record created while you are paging cannot shuffle the page under you.
curl -H "X-API-Key: $FCC_KEY" \
"https://floridacontractorcheck.com/api/v1/jobs?limit=100"
# then hand back exactly what next_cursor gave you
curl -H "X-API-Key: $FCC_KEY" \
"https://floridacontractorcheck.com/api/v1/jobs?limit=100&cursor=eyJ0IjoiMjAyNi0w..."Getting told instead of asking
Five things in your CRM push an event: a contact gets created, a job changes stage, an estimate goes out, an estimate gets accepted, an invoice gets paid. Register a URL with POST /api/v1/webhooks and we post you the event as it happens.
| contact.created | A new contact landed, whoever put it there. |
| job.status_changed | Carries the old stage and the new one, not just the new one. |
| estimate.sent | Fires on the transition, once. |
| estimate.accepted | Same. |
| invoice.paid | Fires when it is settled, not on a part payment. |
These are transitions, not states. Editing the notes on an estimate that is already sent does not fire estimate.sent again, which matters more than it sounds like it does: the version that fires on state is the one that emails your customer four times.
Every delivery carries X-FCC-Signature as t=<unix>,v1=<hex>. Sign t + "." + body with your hook secret using HMAC SHA-256 and compare in constant time. Reject anything older than five minutes. Check the signature before you parse the body, not after.
If your endpoint is down we retry at one minute, five, thirty, two hours and six hours, then we stop. Every attempt is logged with the status we got back, so when something goes missing there is an answer about whether we sent it. Deliveries are free and always have been.
Missed a window anyway? GET /api/v1/events?after=<id> replays everything since that id in order. Webhooks are the fast path, and that is the one you can trust when the fast path had a bad night.
Zapier, today
There is no FloridaContractorCheck app in the Zapier directory yet, so searching for us in there will not find anything. That is worth saying plainly rather than letting you hunt for it. One is written and waiting on review, which is not the same as you being able to use it, so plan around the version that exists today. You do not have to wait, though: a Zap using Webhooks by Zapier wires us into any of the several hundred tools Zapier already connects to, and it works now.
Two shapes work. Point a Webhooks by Zapier GET action at a list with a since on a schedule trigger and map the rows. Or skip polling entirely and register a webhook with us at POST /api/v1/webhooks pointed at a Catch Hook URL, which pushes rows as they land and never costs a record credit for the delivery.
The CRM events work the same way and are usually the more useful half. Catch Hook, job.status_changed, and now a job moving to Won in your portal can start a job in whatever you run your crews from. Going the other way, Webhooks by Zapier can POST to /api/v1/contacts so a form fill or a call log lands in your CRM here without anybody retyping it.
Set the key as a header on the Zapier step, not in the URL. A query string ends up in logs and in browser history, and this key reads your account.
If you are setting this up for someone else, send them to Connect your tools in the portal. It says the same thing without any of this.
Errors
| 401 | No key, an unknown key, a revoked key, or a key whose license no longer has a live plan. |
| 403 | Wrong plan, or you asked only for counties outside your area. |
| 402 | This month's record credits are spent. Re-reading what you already have is still free. |
| 400 | A since we could not read, or an order that endpoint does not offer. Refused rather than ignored. |
| 429 | Too fast, or the daily request cap. Honour the Retry-After. |
| 503 | We could not meter the request, so we did not serve it. Nothing was charged. Retry after the header says. |
Your key dies the moment the plan does. When you step down from Growth we release your ZIP and revoke the key in the same operation, so there is no window where an old credential still works.
If you need more than your own area
Then this is the wrong product and we would rather say so. FCC sells your territory, not volume. For statewide Florida, other states, or bulk permit data, go to permitintel.ai.
Spec
OpenAPI 3.1 at /openapi.json. Machine-readable site notes at /llms.txt. Questions go to [email protected].
Permit records come from Florida county and municipal building departments under Chapter 119, Florida Statutes.