VRPlatformVRPlatform

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" }
}
  • code is the stable error class for program behavior.
  • reason names the business rule that rejected the request. It is absent for access, authentication, and integration errors. See Reasons.
  • issues contains field or domain-specific validation details. Each issue has a message and can add a field path and a finite lower camel case code. See Field Issues.
  • context contains 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.
  • message is 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.
  • retryable appears as true only 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-After can accompany a retryable error when the server has a concrete delay. Respect the header before retrying the same idempotency identity.
  • links appears 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:

codeHTTP status
BAD_REQUEST400
UNAUTHORIZED401
OWNER_STATEMENT_LINK_INVALID401
OWNER_STATEMENT_LINK_EXPIRED401
FORBIDDEN403
NOT_FOUND404
METHOD_NOT_SUPPORTED405
CONFLICT409
AUDIT_EVENT_REVISED409
CONNECTION_RECONNECT_REQUIRED409
JOURNAL_RECALCULATION_PENDING409
GONE410
TEAM_DELETED410
AUDIT_CURSOR_EXPIRED410
PAYLOAD_TOO_LARGE413
MISDIRECTED_REQUEST421
UNPROCESSABLE_CONTENT422
EXPORT_REQUIRES_POST422
LOCKED423
TEAM_MIGRATION_FROZEN423
TEAM_DELETION_FROZEN423
TEAM_WRITES_FROZEN423
RATE_LIMITED429
INTERNAL_SERVER_ERROR500
NOT_IMPLEMENTED501
BAD_GATEWAY502
SERVICE_UNAVAILABLE503
GATEWAY_TIMEOUT504
INVALID_PLAID_CONNECT_MODE400
PLAID_CONNECTION_NOT_FOUND404
PLAID_CONNECT_IN_PROGRESS409
PLAID_CONNECT_CONFIGURATION_CHANGED409
PLAID_CONNECT_EXPIRED410
PLAID_CONNECT_REQUIRES_NEW_LINK422
PLAID_PROVIDER_UNAVAILABLE503
RAMP_VENDOR_OWNER_REQUIRED422

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.

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.

reasonMeaning
accountClassificationLockedThe account's ledger classification cannot change
accountConnectionAttachedThe account or bank feed is already attached elsewhere
accountInUseThe account is still used by records, recurring fees, or an assignment
alreadyPaidThe bill or statement already has a payment
amountExceedsReturnableThe amount exceeds what can still be returned or replaced
archiveRequiredArchive or return the original transaction first
bankAccountTooManyRecordsThe bank account has too many records to initialize in one request
bankRecordInactiveThe bank record is excluded
bankRecordNotCsvSourceOnly CSV-imported bank records can be permanently deleted
bankRecordPlaidSourcePlaid bank records cannot be deleted; exclude them instead
bankRecordReconciledThe bank record is reconciled
bankRecordRuleMatchedThe bank record was matched by a bank rule
bankRecordUnassignedThe bank record is not linked to a bank account
bankRuleRunIncompleteThe bank rule run has not finished
beforeStatementStartDateThe date is before the team's statement start date
booksClosedThe date is in a closed books period
dailyGlSummaryDisabledDaily GL summary is not enabled for the team
duplicateLast4Another bank account or feed on the team uses these last 4 digits
duplicateNameThe name or code is already used
duplicateUniqueRefA record with this reference already exists
generalLedgerDisabledThe team does not use the general ledger
generalLedgerStartDateMissingThe team has no general ledger or statement start date
historicalImportPendingThe historical statement import has not finished
inUseThe record is still referenced and cannot be deleted
lastAccessManagerThe team needs at least one access manager
listingInGroupSet this value on the listing group instead
listingInGroupAlreadyThe listing already belongs to a group
listingInactiveThe listing is inactive on the posting date
listingMappingAmbiguousA PMS listing matches more than one existing listing
listingMappingRequiredA suggested PMS listing match must be confirmed or overridden
mergeDryRunRequiredRun the merge as a dry run first
mergePlanChangedThe merge plan changed since the dry run
notBankAccountThe account is not a bank account
openingBalanceUpdateInProgressThe opening balance is being updated; retry
openingTrialBalanceLockedThe opening trial balance is locked
operationsAccountingDisabledOperations Accounting is not enabled for the team
paymentAccountRequiresMarkPaidThe credential can record a payment only without a funding account or bank records
paymentAttemptedAn ACH payment was already attempted for the transaction
paymentReturnInvolvedThe transaction is part of a payment return
paymentReturnNotEligibleThe payout cannot be returned
plaidReplacementUnavailableThe Plaid replacement feed or target is no longer available
pmsAccountingStartMissingThe PMS connection has no accounting start date
previousStatementsOpenEarlier statements must be published first
providerPaymentRecoveryInProgressPayment recovery is already running
providerPaymentRecoveryUnavailablePayment recovery is not available for the transaction
rampAccountNotReadyThe Ramp funding, category, or bank account is not ready
rampConnectionInactiveThe Ramp connection is not active
rampPaymentNotSupportedRamp cannot pay this transaction type
rampRecipientNotReadyThe recipient has no ready Ramp ACH payment method
reconciliationAmountMismatchThe bank record and transaction amounts differ
reconciliationTransferMismatchA transfer reconciles only to its source or destination bank record
requiredAccountMissingA required account assignment is missing
reservationLockedThe reservation is locked by an accounting lock
reservationUnmatchedA source PMS reservation has no matching reservation on the target PMS
selfModificationYou cannot change or remove your own access
statementAttachmentJournal entries are attached to an owner statement
statementExistsAn owner statement already exists for this period
statementFutureExistsA later owner statement exists
statementHasBlockingIssuesThe owner statement has blocking issues
statementHasPayoutsThe owner statement has linked payouts
statementInReviewOwner statements are in review
statementNotReadyForPaymentThe owner statement is not ready for payment
statementPeriodA published owner statement locks the period
statementPublishedThe owner statement is published
transactionInactiveThe transaction is archived
transactionReconciledThe transaction is reconciled; unreconcile it first
transactionReconciledElsewhereThe 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 codeMeaning
duplicateLast4Another 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:

RouteIssue 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 include context.booksClosedAt, the first open date in YYYY-MM-DD format.
  • Delete locks on referenced entities return 400 BAD_REQUEST with context.lockReasons (an array of human-readable reason strings) and context.suggestedOnLocked (for example "archive") when the operation supports an alternative outcome such as archiving instead of deleting.
  • Archiving a locked last ownership period whose onLast would extend the previous period returns 400 BAD_REQUEST with context.suggestedOnLast ("none"). Retry with that value.

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.

On this page