Documentation menu
Concepts
Rate limits and errors
Rate limit
- 60 requests a minute per key, counted in fixed one-minute windows. REST and MCP requests share the same limit.
- Every successful response carries
X-RateLimit-LimitandX-RateLimit-Remaining. - Over the limit:
429with coderate_limitedand aRetry-After: 60header. Requests refused this way use no records.
Two different 429s
Check the code field, not just the status:
| code | Cause | What to do |
|---|---|---|
rate_limited | More than 60 requests in a minute from one key (REST and MCP together). | Wait for the number of seconds in the Retry-After header (60), then carry on. No records are used. |
records_exhausted | Not one new record fits in your remaining allowance. Records you already received this period can still be fetched. | Wait for the reset date in resets, or upgrade. Do not retry in a loop: it will not succeed before the reset. |
Error format
Every error is JSON with a human-readable error and a stable code. Branch on code; the wording of error may change.
Error codes
400 bad_request
A parameter is invalid: a date not in YYYY-MM-DD form, a postcode that could not be located, or a cursor that was altered or belongs to another endpoint. Correct the parameter named in error. Pass next_cursor back exactly as returned.
{
"error": "since must be a date in YYYY-MM-DD form.",
"code": "bad_request"
}401 unauthorized
The Authorization header is missing or malformed, the key does not exist, or it has been revoked. Send Authorization: Bearer ps_live_... with an active key from /developers#keys.
{
"error": "Missing or invalid Authorization header. Use: Authorization: Bearer ps_live_...",
"code": "unauthorized"
}403 plan_required
Your plan does not include this endpoint (conditions need API Pro, Leads or National; portfolio endpoints need Portfolio Monitor). Upgrade at upgrade_url, or stay on the endpoints your plan includes.
{
"error": "The conditions endpoints are part of API Pro.",
"code": "plan_required",
"upgrade_url": "https://planningsignal.co.uk/developers#pricing"
}403 suspended
The account that owns the key has been suspended. Contact support.
{
"error": "This account has been suspended.",
"code": "suspended"
}404 not_found
No application has that id, or the id is not a valid pa_ id. Use an id exactly as a list response returned it.
{
"error": "Application not found. Application ids look like pa_ followed by 22 letters and digits.",
"code": "not_found"
}429 rate_limited
More than 60 requests in a minute from one key (REST and MCP together). Wait for the number of seconds in the Retry-After header (60), then carry on. No records are used.
{
"error": "Rate limit exceeded (60 requests/min). Please slow down.",
"code": "rate_limited",
"retry_after_seconds": 60
}429 records_exhausted
Not one new record fits in your remaining allowance. Records you already received this period can still be fetched. Wait for the reset date in resets, or upgrade. Do not retry in a loop: it will not succeed before the reset.
{
"error": "Your record allowance for this month has been used.",
"code": "records_exhausted",
"plan": "starter",
"allowance": 2500,
"used": 2500,
"remaining": 0,
"resets": "2026-11-01T00:00:00.000Z",
"upgrade_url": "https://planningsignal.co.uk/developers#pricing"
}500 server_error
Something failed on our side, or the query took too long (queries stop after 10 seconds). Retry after a short wait. If it keeps happening with the same parameters, narrow the query and tell us.
{
"error": "Query failed.",
"code": "server_error"
}Handling errors in code
async function apiGet(url, attempt = 0) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.PLANNING_SIGNAL_API_KEY}` } });
if (res.ok) return res.json();
const body = await res.json().catch(() => ({}));
if (body.code === "rate_limited" && attempt < 3) {
const wait = Number(res.headers.get("Retry-After") ?? 60);
await new Promise((r) => setTimeout(r, wait * 1000));
return apiGet(url, attempt + 1);
}
if (body.code === "records_exhausted") {
// Not retryable until body.resets (null on the demo): stop and alert someone.
throw new Error(`Allowance used: resets ${body.resets ?? "never (demo)"}`);
}
if (res.status >= 500 && attempt < 3) {
await new Promise((r) => setTimeout(r, 2000 * (attempt + 1)));
return apiGet(url, attempt + 1);
}
throw new Error(`${res.status} ${body.code}: ${body.error}`);
}Questions? Contact us, or see the help centre.