Documentation
PermitBird API
One determination endpoint and four lookup endpoints over the 2026 nationwide permit ruleset. Every response carries the ruleset it was computed against, and every determination carries its coverage gaps.
Start here, no signup
The sandbox key below is public and works immediately. It is rate limited to 25 calls an hour, which is enough to evaluate the API properly.
curl -s https://permitbird.com/api/v1/determine \
-H "Authorization: Bearer pb_sandbox_publickey" \
-H "Content-Type: application/json" \
-d '{
"state": "CA",
"district": "spk",
"activity": "linear-transportation",
"aquaticResourceType": "wetland",
"permanentImpactAcres": 0.35,
"streamBedLinearFeet": 120
}'Authentication
Send your key as a bearer token. An X-API-Key header is also accepted.
Authorization: Bearer pb_sandbox_publickeyA live key looks like pb_live_<subscription>_<signature>. It encodes the Stripe subscription it was issued against, and every call checks that the subscription is still active, so cancelling genuinely revokes access rather than waiting for a sync job.
If Stripe cannot be reached, the check answers 503 entitlement_check_unavailable rather than quietly treating your key as downgraded. A 503 here means retry; it never means your subscription has ended.
The key is derived from the subscription rather than stored, so it can be reissued at any time from /recover using the subscription id on your Stripe receipt. Losing the page you first saw it on does not lock you out.
Rate limits
Every response carries the current window state:
X-RateLimit-Limit: 25
X-RateLimit-Remaining: 24
X-RateLimit-Reset: 1785312000
X-PermitBird-Tier: sandboxLimits are 25 per hour on the sandbox key and 5,000 per day on Pro. Counting is per serving instance rather than globally coordinated, so treat the headers as a close guide rather than an exact ledger. A 429 always tells you when to retry.
A third tier name, free, can appear in X-PermitBird-Tier with a 50 per day allowance. It is not a plan you can sign up for and none is advertised: it is what a Pro key falls back to once its subscription is no longer active, so an integration keeps working at a reduced allowance instead of failing outright.
The sandbox key is public, so its allowance is counted per calling IP rather than globally. Another evaluator's traffic cannot spend your quota.
Request ids
Every response carries an X-Request-Id header, repeated as meta.requestId on success and error.requestId on failure. On a 5xx it is the one thing worth reporting: quote it and we can find the exact request in the logs. Error bodies deliberately do not include internal exception text, so the id is what replaces it.
Errors
Errors are machine parseable and, where a field failed, carry the catalogue of values that would have worked. An agent that guesses a district code once can correct itself without reading these docs again.
{
"error": {
"code": "invalid_request",
"message": "One or more fields were missing or malformed.",
"fields": [
{ "field": "district", "message": "Required. USACE district code." }
],
"knownValues": {
"district": ["poa", "spa", "nab", "..."],
"activity": ["linear-transportation", "..."]
},
"docs": "/docs/api"
}
}Codes you can branch on: missing_api_key, invalid_api_key, invalid_request, malformed_json, not_found, upgrade_required, batch_too_large, rate_limit_exceeded, internal_error.
Only internal_error and rate_limit_exceeded are worth retrying. The rest describe something about the request that will not change on its own, so an agent that retries them will keep getting the same answer.
POST /api/v1/determine
Screens a project against the nationwide permits mapped to its activity.
Request fields:
state, required. Two-letter state or territory code.district, required. One of 38 USACE district codes.activity, required. One of 15 identifiers, listed byGET /api/v1/activity.aquaticResourceType, required.wetland,stream,open-waterortidal.permanentImpactAcres, required. Permanent loss of waters of the United States.temporaryImpactAcres, optional.streamBedLinearFeet, optional.
The response shape:
{
"data": {
"verdict": "nwp-with-pcn",
"headline": "A nationwide permit looks available, but you must file ...",
"candidates": [
{
"number": 14,
"title": "Linear Transportation Projects",
"eligible": true,
"reasons": [
{
"code": "acreage-within-limit",
"message": "Permanent loss is within the 0.5 acres limit for NWP 14.",
"derivation": "0.35 acres of permanent loss measured against a 0.5 acres limit.",
"sourceUrl": "https://www.federalregister.gov/documents/...",
"confidence": "verified"
}
]
}
],
"pcn": { "required": true, "reviewDays": 45, "triggers": [ ... ] },
"districtLayer": { "code": "spk", "conditionsCompiled": false, ... },
"stateLayer": { "code": "CA", "certificationCompiled": false, ... },
"coverageGaps": [ "Regional conditions for the Sacramento District are not compiled. ..." ],
"provenance": [ { "label": "91 FR 768 ...", "url": "..." } ]
},
"meta": { "input": { ... }, "ruleset": { ... }, "generatedAt": "..." }
}verdict is one of nwp-likely, nwp-with-pcn, individual-permit-likely or insufficient-data. An eligible of null on a candidate means we hold no transcribed limit for that permit, which is different from the project being too large. Never collapse the two.
Identifiers are checked against the catalogues, not just for shape. A district code that does not exist is a 422 listing every valid code, not a verdict computed against something we have never heard of.
POST /api/v1/determine/batch
Screens up to 100 projects in one call. Pro tier: any other key gets 403 upgrade_required. A permitting desk screens a corridor rather than a single crossing, so the useful unit of work is a list.
Each project is validated and decided independently and results keep input order, so you can zip them back onto your own rows. One invalid project does not discard the batch: it comes back with ok: false and its own field errors in place of a verdict. Metering charges one unit for the request, not one per project.
curl -s https://permitbird.com/api/v1/determine/batch \
-H "Authorization: Bearer pb_live_..." \
-H "Content-Type: application/json" \
-d '{"projects":[
{"state":"CA","district":"spk","activity":"linear-transportation",
"aquaticResourceType":"wetland","permanentImpactAcres":0.2},
{"state":"TX","district":"swf","activity":"utility-line-pipeline",
"aquaticResourceType":"stream","permanentImpactAcres":0.05}
]}'{
"data": [
{ "index": 0, "ok": true, "input": { ... }, "determination": { ... } },
{ "index": 1, "ok": true, "input": { ... }, "determination": { ... } }
],
"meta": { "count": 2, "succeeded": 2, "failed": 0, "ruleset": { ... } }
}GET /api/v1/nwp and /api/v1/nwp/{number}
The whole compiled ruleset, or one permit. All 57 permits are titled and sourced. Numbering is not contiguous: NWPs 26, 47 and 56 are reserved and return 404 with the valid set attached.
curl -s https://permitbird.com/api/v1/nwp/60 -H "Authorization: Bearer pb_sandbox_publickey"GET /api/v1/district and /api/v1/district/{code}
All 38 USACE districts. Every record carries conditionsCompiled, which is currently false everywhere. That field exists so an empty regionalConditions array is never mistaken for a district that has no conditions.
curl -s https://permitbird.com/api/v1/district/spk -H "Authorization: Bearer pb_sandbox_publickey"GET /api/v1/state/{code}/401
The Clean Water Act Section 401 record for a state or territory, including whether we have compiled it. A state can condition, waive or deny certification, which changes the outcome of a determination.
curl -s https://permitbird.com/api/v1/state/CA/401 -H "Authorization: Bearer pb_sandbox_publickey"GET /api/v1/activity
The activity identifiers accepted by the determination endpoint, and the permits each is screened against. These mappings are a screening aid and carry confidence: "verify", so confirm the permit selection against the permit text.
Machine-readable descriptions
- /openapi.json, OpenAPI 3.1, generated from the live data so enums cannot drift.
- /llms.txt and /llms-full.txt, the whole ruleset as plain text.
- /pricing.json, with the paid figure read from Stripe.
- /docs/mcp, the hosted MCP server.
What the API will not do
It will not tell you whether your site contains waters of the United States: that is a jurisdictional determination and only a Corps district can make one. It will not apply a district regional condition or a state Section 401 condition, because those are not compiled yet. It will not guess a threshold it does not hold. Where it cannot answer, it says so in coverageGaps and returns null rather than a number.