Error Contract
Handle structured API errors without parsing messages
API failures use a structured envelope:
{
"code": "FORBIDDEN",
"reason": "statementAttachment",
"message": "Cannot modify transaction - attached to published statement",
"issues": [],
"context": { "lockReason": "statement" }
}codeis the stable error class for program behavior.reasonnames the business rule that rejected the request. It is absent for access, authentication, and integration errors. See Reasons.issuescontains field or domain-specific validation details. Each issue has amessageand can add a fieldpathand a finite lower camel casecode. See Field Issues.contextcontains typed recovery information such as IDs, refs, invalid values, lock reasons, supported archive outcomes, next actions, or retry delays. Private provider and infrastructure details are never copied into the response. It is omitted when the error carries no public context.messageis static display text for one error condition. It never embeds UUIDs, refs, emails, indexes, raw values, or other request-specific data and must not be parsed.retryableappears astrueonly when the server explicitly classifies the same idempotent operation as safe to retry. Its absence means do not retry unchanged unless the operation documents another recovery rule.Retry-Aftercan accompany a retryable error when the server has a concrete delay. Respect the header before retrying the same idempotency identity.linksappears only on strict query-validation errors and points to the public interactive reference (docs) and OpenAPI document (schema). It is never an empty object.
Codes And HTTP Status
code values on the wire map to HTTP status as follows:
code | HTTP status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHORIZED | 401 |
OWNER_STATEMENT_LINK_INVALID | 401 |
OWNER_STATEMENT_LINK_EXPIRED | 401 |
BANK_DETAILS_LINK_INVALID | 401 |
BANK_DETAILS_LINK_EXPIRED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
METHOD_NOT_SUPPORTED | 405 |
CONFLICT | 409 |
AUDIT_EVENT_REVISED | 409 |
CONNECTION_RECONNECT_REQUIRED | 409 |
JOURNAL_RECALCULATION_PENDING | 409 |
GONE | 410 |
TEAM_DELETED | 410 |
AUDIT_CURSOR_EXPIRED | 410 |
PAYLOAD_TOO_LARGE | 413 |
MISDIRECTED_REQUEST | 421 |
UNPROCESSABLE_CONTENT | 422 |
EXPORT_REQUIRES_POST | 422 |
LOCKED | 423 |
TEAM_MIGRATION_FROZEN | 423 |
TEAM_DELETION_FROZEN | 423 |
TEAM_WRITES_FROZEN | 423 |
RATE_LIMITED | 429 |
INTERNAL_SERVER_ERROR | 500 |
NOT_IMPLEMENTED | 501 |
BAD_GATEWAY | 502 |
SERVICE_UNAVAILABLE | 503 |
GATEWAY_TIMEOUT | 504 |
INVALID_PLAID_CONNECT_MODE | 400 |
PLAID_CONNECTION_NOT_FOUND | 404 |
PLAID_CONNECT_IN_PROGRESS | 409 |
PLAID_CONNECT_CONFIGURATION_CHANGED | 409 |
PLAID_CONNECT_EXPIRED | 410 |
PLAID_CONNECT_REQUIRES_NEW_LINK | 422 |
PLAID_PROVIDER_UNAVAILABLE | 503 |
RAMP_VENDOR_OWNER_REQUIRED | 422 |
Branch on code, not on raw status, and treat unknown codes as
non-retryable failures.
Batch item failures use the same public code registry. Internal repository
aliases are mapped before the response: USER_ERROR becomes BAD_REQUEST,
INTERNAL_VALIDATION_ERROR becomes UNPROCESSABLE_CONTENT, and
INTERNAL_ERROR becomes INTERNAL_SERVER_ERROR.
An owner statement link exchange returns OWNER_STATEMENT_LINK_EXPIRED when
the link is older than its validity window; offer the owner a new link. Every
other unusable link returns OWNER_STATEMENT_LINK_INVALID, including an
unpublished statement or a changed owner email address.
A bank details link submission returns BANK_DETAILS_LINK_EXPIRED when the
link is past its validity window or the vendor already provided bank details;
ask the team for a new link. Every other unusable link returns
BANK_DETAILS_LINK_INVALID, including a changed vendor email address.
JOURNAL_RECALCULATION_PENDING blocks statement finalization, payout preview,
or payout while an affected reservation journal is not current. Its context
contains journalStatus (stale, recalculating, or failed), the typed
journalReason, its supported journalAction, and safe operationIds that the
caller can poll. Re-read the statement after the work settles; do not retry the
unchanged accounting action while the status remains non-current.
Reasons
A business rule rejection sets reason, a finite lower camel case value.
Choose display text by reason; message is static English text. reason
is independent of code: the same reason keeps its value when the HTTP
status differs between operations.
Errors without reason are access, authentication, or integration failures,
such as a credential type that a route does not accept. End users cannot fix
them; show a generic message and log the x-request-id response header.
Batch item failures carry the same reason next to their code.
POST /transactions/batch item issues also carry the same message as the
matching single-transaction error, such as Cannot modify expense - attached to published statement.
reason | Meaning |
|---|---|
accountClassificationLocked | The account's ledger classification cannot change |
accountConnectionAttached | The account or bank feed is already attached elsewhere |
accountInUse | The account is still used by records, recurring fees, or an assignment |
achPayerBaseRoleRequired | ACH Payer needs Team Admin or Standard Member in the same assignment |
alreadyPaid | The bill or statement already has a payment |
amountExceedsReturnable | The amount exceeds what can still be returned or replaced |
archiveRequired | Archive or return the original transaction first |
bankAccountTooManyRecords | The bank account has too many records to initialize in one request |
bankRecordInactive | The bank record is excluded |
bankRecordNotCsvSource | Only CSV-imported bank records can be permanently deleted |
bankRecordPlaidSource | Plaid bank records cannot be deleted; exclude them instead |
bankRecordReconciled | The bank record is reconciled |
bankRecordRuleMatched | The bank record was matched by a bank rule |
bankRecordUnassigned | The bank record is not linked to a bank account |
bankRuleRunIncomplete | The bank rule run has not finished |
beforeStatementStartDate | The date is before the team's statement start date |
booksClosed | The date is in a closed books period |
checkNumberUsed | A check from this bank account already used this number |
currencyMismatch | The currency differs from the team's default currency |
dailyGlSummaryDisabled | Daily GL summary is not enabled for the team |
duplicateLast4 | Another bank account or feed on the team uses these last 4 digits |
duplicateName | The name or code is already used |
duplicateUniqueRef | A record with this reference already exists |
generalLedgerDisabled | The team does not use the general ledger |
generalLedgerStartDateMissing | The team has no general ledger or statement start date |
historicalImportExists | Historical statements have already been imported into the team |
historicalImportPending | The historical statement import has not finished |
inUse | The record is still referenced and cannot be deleted |
lastAccessManager | The team needs at least one access manager |
listingInGroup | Set this value on the listing group instead |
listingInGroupAlready | The listing already belongs to a group |
listingInactive | The listing is inactive on the posting date |
listingMappingAmbiguous | A PMS listing matches more than one existing listing |
listingMappingRequired | A suggested PMS listing match must be confirmed or overridden |
managedTeamRequired | A partner member needs at least one managed team |
mergeDryRunRequired | Run the merge as a dry run first |
mergePlanChanged | The merge plan changed since the dry run |
migrationLinkMissing | The target team is not linked to the VRI source team; set migratedFromTenantId with PUT /team |
notBankAccount | The account is not a bank account |
openingBalanceUpdateInProgress | The opening balance is being updated; retry |
openingTrialBalanceLocked | The opening trial balance is locked |
operationsAccountingDisabled | Operations Accounting is not enabled for the team |
ownerMembershipConflict | A user is either an owner or a team member, never both |
partnerManagedMember | The member belongs to the team's managing partner, which manages their roles and access in the partner console |
paymentAccountRequiresMarkPaid | The credential can record a payment only without a funding account or bank records |
paymentFileSetupIncomplete | The bank account's Nacha file or check printing setup is missing required fields |
paymentHistory | A transaction line with payment history cannot be removed. Send its id or its uniqueRef to keep it. |
paymentOpen | An ACH payment is open for the transaction. Cancel it first or wait until it settles or fails. |
plaidInstitutionUnsupported | The bank must be connected through its own integration, not Plaid |
plaidReplacementUnavailable | The Plaid replacement feed or target is no longer available |
pmsAccountingStartMissing | The PMS connection has no accounting start date |
previousStatementsOpen | Earlier statements must be published first |
providerBill | The expense is bound to a Ramp bill, so its vendor and Bill ID cannot change |
providerHistory | A transaction with ACH payment history cannot be deleted. Archive it instead. |
rampConnectionInactive | The Ramp connection is not active |
reconciliationAmountMismatch | The bank record and transaction amounts differ |
reconciliationTransferMismatch | A transfer reconciles only to its source or destination bank record |
rentsRevenueTagMissing | A statement layout has no column tagged Rents Revenue |
requiredAccountMissing | A required account assignment is missing |
reservationLocked | The reservation is locked by an accounting lock |
reservationUnmatched | A source PMS reservation has no matching reservation on the target PMS |
selfModification | You cannot change or remove your own access |
statementAttachment | Journal entries are attached to an owner statement |
statementExists | An owner statement already exists for this period |
statementFutureExists | A later owner statement exists |
statementHasBlockingIssues | The owner statement has blocking issues |
statementHasPayouts | The owner statement has linked payouts |
statementInReview | Owner statements are in review |
statementNotReadyForPayment | The owner statement is not ready for payment |
statementPeriod | A published owner statement locks the period |
statementPublished | The owner statement is published |
subscriptionRecoveryUnavailable | The team or billing binding is not eligible for subscription recovery |
transactionInactive | The transaction is archived |
transactionReconciled | The transaction is reconciled; unreconcile it first |
transactionReconciledElsewhere | The transaction is reconciled to another bank record |
A field error can set both reason and a field issue code, for example
duplicateLast4. Treat unknown reason values like a missing reason.
Field Issues
A business rule that rejects one request field returns an issue with a stable
code and the field path. Map the issue to the form field by path and
choose display text by code. The issue message is static English text.
Issue code | Meaning |
|---|---|
duplicateLast4 | Another bank account or feed on the team uses these last 4 digits |
POST /accounts returns this when the last 4 digits are taken:
{
"code": "BAD_REQUEST",
"message": "Account connection last 4 digits already exist on this team",
"issues": [
{
"code": "duplicateLast4",
"message": "Last 4 digits are already used on this team",
"path": ["banking", "last4"]
}
],
"context": {
"accountIds": ["3f6c2a8e-5b1d-4c7a-9e2f-8d4b6a1c0e57"],
"accountConnectionIds": [],
"lastDigits": "4444"
}
}The same issue uses the path of the request field that supplied the digits:
| Route | Issue path |
|---|---|
POST /accounts, PUT /accounts/{id} | ["banking", "last4"] |
PUT /account-connections/{id} | ["lastDigits"] |
POST /account-connections/batch | ["data", index, "lastDigits"] for every duplicate item |
POST /plaid/connect/configure | ["bankAccounts", index] |
Attaching a feed can copy its digits onto a GL account. When that copy
conflicts, the issue has a code and no path because no request field
supplied the digits. context.accountIds and context.accountConnectionIds
name the records that already hold the digits.
Domain Issue Compatibility
Domain issue codes use lower camel case. Existing values
balanceMismatch_start, balanceMismatch_end, and line_unassignedAccount
remain supported compatibility exceptions. Plaid product keys
transactions_updates and item_logins keep Plaid's native format. Clients
must not infer a general snake-case convention from these values.
Export Admission
Generated artifact GET routes return EXPORT_REQUIRES_POST when the selected
work is not expected to finish within the awaited request budget:
{
"code": "EXPORT_REQUIRES_POST",
"message": "Export must be queued",
"issues": [],
"context": {
"recommendedMethod": "POST",
"reason": "selectionTooLarge"
}
}Send the same selector fields to POST on the same path, then poll the returned
operation. The rejected GET does not create an operation. Branch on the exact
error code because other 422 responses have different recovery rules.
Awaited renderer failures return BAD_GATEWAY; renderer timeouts return
GATEWAY_TIMEOUT. Timeouts, aborted requests, 5xx responses, and other error
codes do not indicate that POST is required. Surface or retry them according to
their own error contract. See
Generated exports for both supported
client flows.
Lock Context
Lock failures are 403 FORBIDDEN and identify the lock in context:
- Mutation locks return
context.lockReason, either"statement"(attached to a published owner statement) or"period"(blocked by books closing). Period locks also includecontext.booksClosedAt, the first open date inYYYY-MM-DDformat. - Delete locks on referenced entities return
400 BAD_REQUESTwithcontext.lockReasons(an array of human-readable reason strings) andcontext.suggestedOnLocked(for example"archive") when the operation supports an alternative outcome such as archiving instead of deleting. - Archiving a locked last ownership period whose
onLastwould extend the previous period returns400 BAD_REQUESTwithcontext.suggestedOnLast("none"). Retry with that value.
A request that the database cancels because another transaction holds a lock, a lock
timeout or a deadlock, returns 409 CONFLICT with retryable: true, the message
Another update is in progress. Try again. and context.databaseErrorCode (55P03 or
40P01). Only the failed database transaction is rolled back. Earlier writes of the same
request can already be committed, so re-read the resource before you resubmit a create.
Updates that send the same values again are safe to repeat. Background effects that an
earlier save queued can briefly hold these locks, so the first attempt right after a large
fee or statement change can fail while a retry works.
Other domain errors can include nextAction or supportAction. Present only
the actions returned or documented for that operation. See
Requests, Locks & Issues.
Partner Context
Every /partner route requires the selected team to be the partner team itself, not a
customer team the partner manages. Source and target customer team ids stay in the route's
documented query or body fields.
An interactive caller sends x-team-id (MCP teamId) and, when the selected team is not a
partner team, receives 403 FORBIDDEN:
{
"code": "FORBIDDEN",
"message": "Selected team is not a partner team",
"issues": [],
"context": {
"requiredTeamType": "partner",
"selectedTeamId": "839501b1-df46-4fd6-913b-00dc4d703e76",
"selectedTeamType": "propertyManager",
"partnerTeamId": "7bfec41f-aa3f-4602-95d2-f7996e4f0e59"
}
}Send the next request with the selected team set to context.partnerTeamId and keep the
source and target team ids unchanged. See
VRI migration and
Connecting with MCP.
A machine credential (partner API key) keeps 404 NOT_FOUND with the message
Not a partner API key and no context, so a non-partner key cannot learn team types.
Plaid Connect Recovery Context
PLAID_CONNECT_REQUIRES_NEW_LINK returns one strict recovery directive:
{ "restartMode": "create" }or:
{
"restartMode": "replace",
"connectionId": "6a4fb5d4-9822-46dc-9812-9e6742fda0bc"
}Create recovery never includes connectionId; replace recovery always does.
Provider failures can add plaidErrorCode, plaidErrorMessage, or
plaidRequestId to the same context. Use the recovery directive for the next
POST /plaid/connect and do not infer a different mode from client state.
Request Validation Errors
Invalid path parameters, query parameters, and JSON body fields return 400
with code: "BAD_REQUEST", a static message, and field-level issues.
Schema validation issues include the operation schema location and a field
path when available. The links object points to the API documentation and
OpenAPI schema. The response does not echo the request body.
Malformed JSON also returns 400 with code: "BAD_REQUEST", the message
Malformed JSON in request body, and an empty issues array.
Query parameters are strict. An unsupported parameter returns 400 with
links to the API schema and an issue that identifies the operation schema
location. Remove the parameter; it is not silently ignored.
{
"code": "BAD_REQUEST",
"message": "Invalid query parameters. Check OpenAPI and remove unsupported parameters.",
"issues": [
{
"message": "Unrecognized key: \"foo\"",
"schema": "#/paths/~1transactions/get"
}
],
"links": {
"docs": "https://api.vrplatform.app/spec",
"schema": "https://api.vrplatform.app/openapi.json"
}
}Rate Limits
Webhook subscription, verification-test, and replay operations enforce the
limits documented in Webhooks. An excess
request returns RATE_LIMITED with HTTP 429 and Retry-After. Other API
surfaces do not currently enforce application-level quotas.
Availability
Transient database connectivity exhaustion is returned as
SERVICE_UNAVAILABLE. The API uses bounded retries for compiled database
selects after known connection failures, including an established connection
being lost, but does not replay mutations or raw or unknown queries after an
ambiguous connection loss. Callers should retry only idempotently and with
bounded backoff. A domain-specific service-unavailable response can include
structured delay or pending-resource context.
Regional misroutes return MISDIRECTED_REQUEST. Its context contains
dataRegion, apiBaseUrl, and teamId when the request selected a team.
Send the request to the returned API base URL without changing the selected
team.
Connection provider failures return BAD_GATEWAY; the 25-second awaited
provider limit returns GATEWAY_TIMEOUT. Provider details remain private.
See Error handling and retry policy and Requests, Locks & Issues.
