Errors & Rate Limiting Guide
Handle errors gracefully and keep your integration running smoothly.
Error Response Format
All errors return RFC 7807 Problem Details:
{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Validation Error",
"status": 400,
"detail": "Email format is invalid",
"instance": "/api/v1/network/contacts",
"errors": {
"email": ["The email field is not a valid e-mail address."]
}
}
HTTP Status Codes
| Status | Meaning | What To Do |
|---|---|---|
| 200 | Success | Process response |
| 201 | Created | Entity created successfully |
| 204 | No Content | Delete succeeded |
| 400 | Validation Failed | Check errors for field details |
| 401 | Unauthorized | Token expired—refresh and retry |
| 403 | Forbidden | Check permissions, or the type below |
| 404 | Not Found | Entity doesn't exist |
| 409 | Conflict | A problem body: read its type — a lost race (/help/concurrency-conflict): refetch and retry; a rule of the record (/help/operation-not-allowed): a retry answers the same. A likely duplicate on POST /api/v1/comps carries possibleDuplicates[] instead (see below) |
| 412 | Precondition Failed | Stale If-Match / ETag—refetch and retry |
| 422 | Unprocessable | A business rule refused the request (e.g. a completion before the signing); fix the request, don't retry it as is |
| 429 | Rate Limited | Wait for Retry-After seconds |
| 500 | Server Error | Retry with backoff |
| 503 | Service Unavailable | Wait for Retry-After seconds |
Problem Types
The type member tells you why a request was refused, so you can branch on it instead of reading detail.
/help/plan-required
403. The feature belongs to a plan your organisation is not on. The response names the plan in requiredPlan. Transactions (comps) and every plan gate added from now on answer with this type; a few older Professional features still answer a plain 403 without a type.
{
"type": "/help/plan-required",
"title": "Plan required",
"status": 403,
"detail": "This feature is part of the Professional plan, which your organisation is not on.",
"requiredPlan": "professional"
}
- What Professional includes: among other things Transactions (market transactions — the deals of your closed projects and the deals your team records). Plus does not include Professional.
- Whose plan counts: the organisation the request acts for — the caller's active organisation. A member of several organisations reaches only the active organisation's deals, and only while that organisation is on Professional; another of their organisations being on it does not help.
- How to upgrade: choose Upgrade where the app shows the Professional offer (for example on the Transactions page), or contact Propstreet. The same request succeeds once the organisation is on the plan; retrying before that answers the same 403.
- It is different from a
403without this type (your user or token lacks the permission) and from/help/operation-not-allowed(the record does not allow the operation in its current state).
/help/operation-not-allowed
403 or 409. The record does not allow the operation for you or in its current state, and repeating the request answers the same. title and detail say which rule. On comps, for example: hiding a deal or correcting an own property's facts when you are not the project's or the property's owner or an admin (403); changing a field a closed project owns, deleting a deal from a closed project, hiding a recorded deal, or merging two projects' deals (409).
/help/concurrency-conflict
409. Another change won a race for the same record — for example a project close or close correction that lost the race for its deal. Read the record again and retry.
Likely duplicates
409 on POST /api/v1/comps, with a body that is not a problem: { "possibleDuplicates": [ { "compId", "name", "dealDate", "reason", "differingFields" } ] }. Nothing was recorded; send the request again with allowDuplicate: true to record it anyway (see Comps).
/help/idempotency-conflict and /help/idempotency-outcome-unknown
422 when an Idempotency-Key is reused with a different body; 409 when an earlier request with the key may or may not have been saved — check the resource, then retry with a new key. See Idempotency.
Rate Limiting
Limits
| Authentication | Limit | Best For |
|---|---|---|
| OAuth Client (Bot User) | 600 req/min | Production sync |
| Personal Access Token | 60 req/min | Testing |
Response Headers
Every response includes rate limit info:
RateLimit-Limit: 600
RateLimit-Remaining: 542
RateLimit-Reset: 45
| Header | Meaning |
|---|---|
RateLimit-Limit |
Your limit for this window |
RateLimit-Remaining |
Requests left |
RateLimit-Reset |
Seconds until reset |
Retry-After |
Seconds to wait (on 429 and 503) |
Handling 429 Responses
When you hit the limit:
{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit exceeded. Please retry after 12 seconds."
}
Just wait for Retry-After seconds, then continue.
Retry Strategy
For transient errors (429, 500, 502, 503, 504), use exponential backoff:
JavaScript
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.ok) return response.json();
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("Retry-After") || "60");
await sleep(retryAfter * 1000);
continue;
}
if (response.status >= 500) {
await sleep(Math.pow(2, attempt) * 1000);
continue;
}
throw new Error(`Request failed: ${response.status}`);
}
throw new Error("Max retries exceeded");
}
C#
public async Task<T> FetchWithRetryAsync<T>(string url, int maxRetries = 3)
{
for (int attempt = 0; attempt < maxRetries; attempt++)
{
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<T>();
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(60);
await Task.Delay(retryAfter);
continue;
}
if ((int)response.StatusCode >= 500)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
continue;
}
response.EnsureSuccessStatusCode();
}
throw new Exception("Max retries exceeded");
}
Best Practices
- Check
RateLimit-Remainingbefore making requests - Respect
Retry-Afterwhen you get a 429 - Use exponential backoff for server errors
- Log 4xx errors—they indicate bugs in your integration
- Use Bot Users for production—10x the rate limit of PATs
Large Network Sync
For networks with 100k+ contacts:
- Use max
page_size=500to minimize requests - Implement delta sync—don't re-pull everything
- If you consistently hit rate limits, contact feedback@propstreet.com
Webhook Delivery Retries
Webhook deliveries use exponential backoff with jitter (±20%):
- 9 total attempts (1 initial + 8 retries) over ~35 hours
- Delays: 3s, 10s, 30s, 5m, 30m, 2h, 8h, 24h
- Auto-disable after 10 consecutive delivery failures
For complete webhook documentation, see the Webhooks Guide.