Errors
Every error code the API emits, when it happens, and what to do about it.
Every error has the same shape, and every one carries a docsUrl pointing at its own entry on this page.
What a request you cannot afford looks like
curl 'https://api.boardwalkai.com/api/v1/projects/search?states=UT&assetClasses=Apartment%20Building&dateRange=all&limit=100' \
-H 'Authorization: Bearer bwk_live_YOUR_KEY'Response · HTTP 402
{
"error": {
"code": "insufficient_credits",
"message": "This request would deliver 100 records and cost 100 credits. You have 40.",
"param": null,
"details": {
"quote": {
"matchCount": 5000,
"matchCountIsExact": true,
"creditRate": 0,
"creditsRequired": 100,
"creditsRemaining": 40,
"affordableRecordCount": 0,
"countUrl": "https://api.boardwalkai.com/api/v1/projects/count?states=UT&assetClasses=Apartment%20Building&dateRange=all"
}
},
"docsUrl": "https://boardwalkai.com/docs/api/errors#insufficient_credits",
"requestId": "req_01K1QF3M0000EXAMPLE0001"
}
}The response shape on this page is the one the endpoint builds — the keys, their nesting and their types are asserted against the shipping code by a test. The numbers inside it are the scenario being walked through, not a measurement of the corpus.
| Field | Meaning |
|---|---|
error.code | A stable machine token. Branch on this. |
error.message | Human copy. May be reworded; do not parse it. |
error.param | The offending parameter, where there is one. |
error.details | Structured context — the quote on a 402, the problem list on a 400. |
error.docsUrl | A link to the entry below. |
error.requestId | The same id as the X-Request-Id header, our logs and your usage row. Quote it to support. |
You can send your own X-Request-Id (characters A-Z a-z 0-9 _ . : -, up to 255 of them) and we will echo it back as requestId, which lets you join our logs to yours. When you do not send one, Boardwalk creates an id beginning req_ — so a requestId without that prefix is one you or your infrastructure supplied, not one we generated.
Every code
account_disabledHTTP 401
The key resolved, but the owning account has been disabled.
What to do: Ask a team owner or Boardwalk support to restore the account. Rotating the key does not change the account state.
alert_geography_requiredHTTP 403
A create or update left an alert with no geography at all, on a plan whose licensed geography is narrower than nationwide. A blank geography is a nationwide standing instruction, which is almost never what a blank field meant. Nothing was saved and nothing was charged. Pools whose plan grants nationwide geography — API Only and National — are exempt and may hold a nationwide alert.
What to do: Name at least one state, county or city in filters. If you genuinely want nationwide standing coverage, that needs a plan whose licensed geography is nationwide.
alert_geography_too_broadHTTP 403
An alert named more states, or more counties and cities, than a narrower plan allows in one rule. error.details.maxStates and error.details.maxLocalities state the ceilings. Nothing was saved and nothing was charged. API Only and National pools are exempt — on those plans one alert may span as many states as you like.
What to do: Split the rule into several alerts, each within the ceiling, or move to a plan whose licensed geography is nationwide. The 25-active-alert cap still applies to everyone, so splitting is not free of limits.
alert_limit_reachedHTTP 403
Your team already holds the maximum number of active alerts. The ceiling counts every alert on the account — including ones not shared with you — because it is a billing cap, not a visibility question.
What to do: Pause or delete one. error.details.maxActiveAlerts states the ceiling; GET /alerts reports meta.activeAlerts — how many the whole account holds, which can be more than the alerts your key can see.
credits_exhaustedHTTP 402
The balance cannot pay for even one record, and the response would have billed at least one. No limit could make such a page affordable, so there is nothing to quote and the response carries no quote — that is what separates it from insufficient_credits, which refuses one specific page and names the limit to retry with. Records you already own and that have not changed are not billable, so even at exactly 0 credits you keep reading them back free — a page or a record that bills 0 is served, not refused. The body is the standard error envelope. Nothing was delivered and nothing was charged.
What to do: Wait for the paid reset or add credits — no narrower filter or smaller limit changes the answer. Paid balances refill on the 1st of the calendar month (meta.credits.periodEnd / resetsAt on /account/credits name the exact moment); trial credits are a one-off allowance. The free count endpoint still works, so you can price the next spend before the balance refills, and re-reads of unchanged records you already purchased (including include=contacts&contactOptions.cachedOnly=true read-backs) keep working at 0 credits.
enrichment_allowance_exceededHTTP 402
A POST /enrichments job would push the pool past its monthly enrichment allowance (500 attempts per calendar month on the API Only plan). The request is refused whole — nothing is queued, no allowance is consumed and no credits are involved, because enrichment of purchased projects never spends credits. error.details.requested is the attempt count that did not fit, and error.details.allowance carries used, limit, remaining and resetsAt.
What to do: Send fewer projectIds — error.details.allowance.remaining is the number that still fits this month — or wait for resetsAt, the first of the next calendar month. IDs already enriched recently are skipped free as skipped.alreadyEnriched, so re-submitting a mixed batch does not burn allowance on the ones we already looked up.
enrichment_incompatible_representationHTTP 400
include=contacts was combined with a representation that carries no contact fields — asking to pay for enrichment and then not receive it.
What to do: Use standard or full.
enrichment_requires_asyncHTTP 400
include=contacts was sent to a synchronous JSON route without contactOptions.cachedOnly=true. Finding contact info calls out to third-party sources and cannot finish inside a request.
What to do: Re-run the search without include=contacts, then send the ids it returns to POST /enrichments — enriching projects the pool has already bought is free and charges no credits. If you do not yet have the ids, queue POST /projects/ai-search/jobs with include: "contacts", or GET /projects/search?format=csv&include=contacts. To read contacts we already hold, keep the GET and add contactOptions.cachedOnly=true. error.details names every endpoint.
enrichment_requires_paid_planHTTP 402
include=contacts was sent on an unpaid plan (no live trial, no paid seat). Finding contact info is a paid-plan or live-trial toggle. Record credits may still remain.
What to do: Drop include=contacts and keep searching or exporting, start a free trial, or upgrade to a paid plan. Names, titles and companies from the filing still ship without the toggle. A live trial spends the 5-lookup grant via POST /enrichments. Nothing was charged.
export_expiredHTTP 409
The file lived out its retention window and was swept. expiresAt on the job is the deadline, and it had passed.
What to do: Run the search again with format=csv to build a fresh file. Records you already hold unchanged are free to receive again, so a rebuild usually costs less than the original — price it first with /projects/count.
export_not_readyHTTP 409
The export job exists but has not finished building. format=csv is asynchronous — the queue response is a 202 with an id, not a file.
What to do: Poll GET /api/v1/exports/{id} until status is ready, then download. The job carries creditsReserved from the moment it is queued, so there is nothing further to spend.
forbiddenHTTP 403
The authenticated account is not permitted to perform this operation, and no more specific policy code applies.
What to do: Check the key's account and plan permissions. Nothing was charged.
geographic_access_deniedHTTP 403
A requested state or county falls outside the geography licensed to this pool.
What to do: Remove the denied geography or expand the plan. details.deniedStates and details.allowedStates carry the id, name and abbreviation of each, so you do not have to resolve ids to answer "what can I query instead?" (deniedStateIds and allowedStateIds remain alongside them). To avoid the error entirely, call the free GET /locations/states and read the entitled flag on each state before you build a filter.
idempotency_in_flightHTTP 409
The same Idempotency-Key is currently being processed by another request.
What to do: Wait for Retry-After seconds and retry. Do not fan out the same key in parallel; the point of the key is that only one of them charges.
idempotency_key_reuseHTTP 409
An Idempotency-Key was reused with a different request body or filter set. Replaying a key is safe; changing the request under it is not.
What to do: Use a fresh key for a different request. Keys are scoped to the credit pool, so a teammate's key cannot collide with yours by accident and cannot be replayed by you either.
insufficient_creditsHTTP 402
The page this request would deliver costs more credits than the pool has left. A paid page is delivered whole or refused whole — never trimmed to fit — so the refusal is about one page, not the whole match set, and on the JSON search route limit caps that page's cost at 100 credits. Nothing was delivered and nothing was charged — the charge is attempted inside a transaction that is rolled back, so a 402 leaves your balance exactly where it was.
What to do: Read error.details.quote. affordableRecordCount is the limit to retry with — that page fits the balance and delivers the affordable part. creditsRequired is the refused page's cost (not the set's), creditsRemaining shows the gap, and countUrl re-prices this exact filter set for free. An agent can recover from this response without a human, which is why the URL is in the body.
internal_errorHTTP 500
Something failed on our side.
What to do: Retry. Nothing was charged — 4xx and 5xx responses never spend credits. Quote the X-Request-Id header when you contact support; it is the same id in your response body, in our logs and on the usage row.
invalid_cursorHTTP 400
A /projects/sync cursor was malformed, or was issued against a different filter set or a different entitlement scope than the one now being requested. A cursor is only lossless for the query it was created for.
What to do: error.details.recovery says it: repeat the request without the cursor. Records you already hold are not re-billed, so restarting a walk is cheap.
invalid_filterHTTP 400
One or more filter parameters were rejected: an unknown parameter name, a value outside the enum, a non-numeric range bound, or a place name that resolved ambiguously. Unknown parameters are refused, never ignored — silently dropping one would bill you for a broader query than you asked for.
What to do: error.details.problems[] names every offending parameter with its own message. The filter reference lists every accepted name, alias and value.
invalid_requestHTTP 400
A parameter was present but unusable — an unknown representation, a sort on the sync feed, a malformed body — or a required header was missing: POST /enrichments and POST /alerts/{alertId}/run refuse with this code and error.param: "Idempotency-Key" when the key is absent, because a key-less retry of either is indistinguishable from a second submission. error.param names the offending parameter or header.
What to do: Read error.param and error.details.supported, which lists the accepted values where there is a closed set. When error.param is Idempotency-Key, resend with the header — any opaque string up to 255 characters of A-Z a-z 0-9 _ . : -.
key_expiredHTTP 401
A rotated key reached the end of its overlap window — 1, 24 (the default) or 168 hours, whichever was chosen at rotation.
What to do: Switch to the replacement key issued at rotation. The old key cannot be reactivated, but no account or subscription change is required.
manual_runs_exhaustedHTTP 429
A POST /alerts/{alertId}/run after the alert already spent its daily manual-run allowance (3 per alert per calendar day of the service clock, US Mountain Time). Nothing ran, nothing was charged and the alert's cadence is untouched. Distinct from rate_limit_exceeded: that is a per-minute transport bucket, this is a product allowance that refills at that clock's next midnight — read error.details.resetsAt rather than computing the boundary yourself. Replaying an Idempotency-Key that already ran does not spend an attempt, and neither does a run that answered already_running.
What to do: Wait for error.details.resetsAt (also the Retry-After header) — or stop triggering manually: a cadence (cadence.frequency) is the intended way to run an alert repeatedly, and scheduled runs are not limited by this allowance. error.details.limit and error.details.used carry the arithmetic.
method_not_allowedHTTP 405
The path exists, but the request used an HTTP method that route does not accept.
What to do: Read the Allow response header and retry with one of the listed methods. The request was not processed and nothing was charged.
missing_api_keyHTTP 401
No credential was presented, or the value was not a Boardwalk API key.
What to do: Send Authorization: Bearer bwk_live_... or X-API-Key: bwk_live_.... A web session cookie or a login JWT is not a credential for this API; there is exactly one, and it is an API key.
rate_limit_exceededHTTP 429
More requests than the applicable allowance in a trailing 60 seconds. Valid keys are limited per key; missing credentials use a source-IP bucket and unknown or unusable Boardwalk-shaped keys use a credential-digest bucket.
What to do: Back off for Retry-After seconds. X-RateLimit-Remaining and X-RateLimit-Reset are on every response, so a well-behaved client never needs to see this code.
resource_not_foundHTTP 404
The project, document, alert, list or job id does not exist, or is not visible to this key. Both cases return 404 — an id that exists but that this key's user holds no membership on must not be distinguishable from one that does not exist. For alerts and lists, visibility follows the key's user: their own, plus shares that include them.
What to do: Check the id. If a teammate can see the alert or list and you cannot, ask them to share it with you (share.mode), or call with a key created by a user who is a member. If you are following a documents[].meetingDocumentId from a record, use that value rather than documents[].id.
subscription_requiredHTTP 402
The key is valid and the account has no active plan or trial — usually a lapsed subscription. The key is not revoked. It keeps working the moment the plan is reactivated.
What to do: Reactivate the plan. You do not need to re-issue keys or redeploy.
sync_tie_unresolvedHTTP 503
More records share one last-updated timestamp than a single sync page can position, so advancing the cursor could not be done without risking a skip.
What to do: error.details.suggestion: split the walk by geography and run one cursor per slice. Nothing was charged.
Warnings: a `200` that is telling you something
A successful response can carry meta.warnings[], each entry {code, message, params}. A warning never fails a request and never changes what you were charged — but two of them below change which records you got, which is the same thing by the time it reaches your pipeline. Branch on code; the message is prose.
| Code | What happened | Changed the result set? |
|---|---|---|
default_date_window_applied | You sent no dateRange and no meetingDateFrom/To, so the default last-12-months window was applied for you. It is also echoed in meta.appliedDefaults. | Yes — older records were excluded. Send dateRange=all if you meant everything. |
taxonomy_filter_rolled_up | You sent a parent Building Type alongside one of its children; the parent supersedes the child, so the narrower value was dropped. | Yes — the result set is the parent's, wider than the pair you sent. |
geography_narrowed_to_smallest_scope | You sent states together with counties/cities. The narrower scope supersedes; the states are not searched in full. | Yes — check meta.appliedFilters before paging, especially with numeric ids, which skip name resolution. |
limit_clamped_to_page_size | Your limit was above the maximum page size and was clamped to it. Emitted on /projects/search and /projects/sync. | No — you got a full page, just a smaller one. Keep paging. |
sync_records_unpositionable | GET /projects/sync only: some candidate records could not be positioned in the cursor ordering and were skipped for this page. | Yes, for this page. The walk still terminates; keep following the cursor. |
filter_evidence_unconfirmed | A filter you sent currently matches the full index because its attribute could not be confirmed on every record. params names the filters. | Yes — a returned record with a null value for that attribute is one we could not confirm, not one that matched. |
budget_exceeds_monthly_allowance | On alert create/update: the maxCredits budget you set is larger than your plan's whole monthly allowance. | No — the alert was saved. It is a warning that one run could spend the month. |
alert_nl_enrichment_failed | On alert create/update with specificRequirement: the natural-language criteria could not be enriched, so the alert falls back to its structured filters. | Yes, on every run — the NL half of the criteria is not being applied. |
Ready to make a call?
A free Boardwalk trial includes API access, 50 record-export credits (search, CSV, MCP), and 5 Find Contact Info lookups. Counting, taxonomy, location lookups and the analytics plane cost nothing, so you can evaluate the data before you spend a credit.