Error Handling
Every error the API returns uses one JSON envelope, so a single handler covers all endpoints.
Error envelope
{
"error": {
"type": "validation_error",
"message": "Email is required",
"details": { "errors": ["Email is required"] },
"request_id": "3f9c2a1e-8b7d-4e5f-9a1b-2c3d4e5f6a7b"
}
}
| Field | Always present | Meaning |
|---|---|---|
type | yes | Machine-readable error type. Switch on this, never on message. |
message | yes | Human-readable explanation. Safe to show to an operator; may change wording. |
details | no | Endpoint-specific object. Validation failures usually carry { "errors": [...] }; bad query parameters carry { "parameter": "sort" }. |
request_id | yes | A req_… id. Quote it when contacting support. The same id is in the X-Wf-Request-Id response header on every response, including a 401 or 403, and the developer portal's Logs tab searches by it. |
remediation | no | A hint about what to change when one is known. |
Rate-limit responses from the edge use the same shape but omit request_id; see Rate Limiting.
Status codes
| Status | Typical type values | Retry? |
|---|---|---|
| 400 | bad_request, invalid_params, invalid_parameter, invalid_request | No. Fix the request. |
| 401 | unauthorized | Only after refreshing or re-minting the token. |
| 403 | insufficient_scope, forbidden, forbidden_role, feature_disabled | No. The token or user lacks permission, or the action is not yet enabled for this account. |
| 404 | not_found | No. Also returned for resources the token is not allowed to see. |
| 409 | conflict, idempotency_conflict | No. The resource is already in the requested state or the key was reused for a different operation. |
| 410 | gone | Not as sent. Your cursor or updated_since is older than the change retention window; list again with neither parameter and replace the records the sync owns. See Cursor retention. |
| 422 | validation_error, use_intents, idempotency_conflict, unprocessable_entity | No. Fix the input or follow the message. |
| 429 | rate_limit_exceeded | Yes, after the wait described in the response, within a capped budget. |
| 500 | internal_server_error | Yes, with exponential backoff, within the same budget. Replay a write only when an idempotency key makes it safe. |
| 502, 503 | none (the body may not be JSON) | Yes, with exponential backoff, within the same budget. The edge or the app was briefly unavailable. |
Two 422 types deserve a note:
use_intentsmeans the action is one a human must approve. Removing a syndicate member, for example, is refused with this type and a message pointing atPOST /intents. See Intents.idempotency_conflictmeans an idempotency key was reused with a different request.POST /intentsreturns it as409; endpoints that take theIdempotency-Keyheader return it as422. See Idempotency.
Authentication failures
A 401 carries type: "unauthorized". The message comes from the OAuth layer, for example an expired or revoked token. Refresh the token (authorization-code flow) or mint a new one (client-credentials flow) and retry once. A second 401 on the fresh token is a real failure (the grant was revoked, the user left, the client was disabled); surface it with its request_id. Do not loop.
A 403 with type: "insufficient_scope" means the token is valid but does not hold the scope the endpoint requires. Request the scope during authorization; see Scopes. A 403 with type: "forbidden" means the scope is present but the user or the token's resource binding does not permit the specific resource.
Cursor retention (410)
The investment sync endpoint (GET /investments, and its per-company alias GET /companies/{company_id}/investments/changes) keeps its change feed for a retention window of about 90 days. A cursor or updated_since older than that returns 410 with type: "gone" and a remediation of List again without a cursor and replace your dataset. Changes below the watermark may have been pruned, so advancing the old position is not an option.
Recover by listing again with neither cursor nor updated_since (keep company_id and any other filter). Dropping only the cursor is not enough: a request with a stale updated_since takes the time-based path and returns 410 again. Page through the fresh snapshot, then in one write replace the records your sync owns and store the final page's next_cursor as the new position. Replace only API-owned state: a record missing from the fresh listing is gone, but rows that came from somewhere else (notes, deal stages, imports) are not the API's to delete. The CRM sync guide shows the replacement step in each language and the go-live checklist tests it.
Handling errors in code
Recovery has two separate budgets. A 401 gets one token refresh, then the request is sent once more. 429 and 5xx share a capped transient budget with backoff. Neither budget resets the other, so a token that keeps failing or an edge that keeps throttling ends in a thrown error that still carries the final status and request_id.
A 5xx may have landed the write before the error was produced. Replay one only when the endpoint takes an Idempotency-Key (see Idempotency) or the operation is naturally idempotent; otherwise surface it. A 429 is always safe to replay: the request was refused before anything ran.
// One token refresh, then a capped transient budget (429 and 5xx).
const TRANSIENT_RETRIES = 3;
async function callApi(send, { replayable = false } = {}) {
let refreshed = false;
let transient = 0;
for (;;) {
try {
const { data } = await send();
return data.data;
} catch (err) {
const status = err.response?.status;
const error = err.response?.data?.error; // undefined on a non-JSON 502/503
console.error("Wefunder API error", {
status,
type: error?.type,
message: error?.message,
request_id: error?.request_id
});
if (status === 401 && !refreshed) {
refreshed = true; // a second 401 falls through to `throw err`
await refreshAccessToken();
continue;
}
const transientStatus = status === 429 || (status >= 500 && replayable);
if (transientStatus && transient < TRANSIENT_RETRIES) {
transient += 1;
const waitSeconds = status === 429
? (error?.details?.retry_after ?? 2 ** transient)
: 2 ** transient;
await sleep(waitSeconds * 1000);
continue;
}
throw err; // err.response.status and error.request_id are intact for the caller
}
}
}
async function inviteMember(syndicateId, email) {
try {
// POST .../members/invite takes no Idempotency-Key, so a 5xx is not replayed.
return await callApi(() => api.post(`/syndicates/${syndicateId}/members/invite`, { email }));
} catch (err) {
const error = err.response?.data?.error;
switch (error?.type) {
case "conflict":
return null; // already a member or already invited
case "insufficient_scope":
throw new Error(`Re-authorize with the missing scope: ${error.message}`);
default:
throw err;
}
}
}
# One token refresh, then a capped transient budget (429 and 5xx).
TRANSIENT_RETRIES = 3
def call_api(replayable: false)
refreshed = false
transient = 0
begin
yield.body["data"]
rescue WefunderClient::Error => e
Rails.logger.error("Wefunder API #{e.status} #{e.type}: #{e.message} (#{e.request_id})")
if e.status == 401 && !refreshed
refreshed = true # a second 401 falls through to `raise`
refresh_access_token!
retry
end
transient_status = e.status == 429 || (e.status >= 500 && replayable)
if transient_status && transient < TRANSIENT_RETRIES
transient += 1
wait_seconds = e.status == 429 ? (e.details&.dig("retry_after") || 2**transient) : 2**transient
sleep(wait_seconds)
retry
end
raise # e.status and e.request_id are intact for the caller
end
end
def invite_member(syndicate_id, email)
# POST .../members/invite takes no Idempotency-Key, so a 5xx is not replayed.
call_api { client.post("/syndicates/#{syndicate_id}/members/invite", email: email) }
rescue WefunderClient::Error => e
case e.type
when "conflict" then nil # already a member or already invited
when "insufficient_scope" then raise "Re-authorize with the missing scope: #{e.message}"
else raise
end
end
With TRANSIENT_RETRIES = 3, the worst case for a request that keeps failing is five sends: the original, one after the token refresh, and three transient retries. A request that only ever sees 401 stops after two sends; one that only ever sees 429 stops after four. api-docs/tests/error-handling-snippet.test.mjs and spec/docs/error_handling_snippet_spec.rb run these two snippets against repeated 401 and 429 responses and fail if either loops.
Best practices
- Switch on
type, logmessageandrequest_id. Messages are for humans and may be reworded. - Give
401one refresh and429/5xxa capped budget, with exponential backoff, and keep the two budgets separate. Three to five transient attempts is plenty. - Treat 404 as "not visible to this token", not proof that a record does not exist.
- Send an idempotency key where the endpoint supports one, so a retried write cannot double up. Without one, do not replay a write after a
5xx. - Surface
details.errorsto the operator on a 422; it lists what to fix. - On a
410, replace, do not resume. List again with neithercursornorupdated_sinceand overwrite only the records the sync owns.