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 |
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.
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.
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 |
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 |
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 |
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 |
mergeDryRunRequired | Run the merge as a dry run first |
mergePlanChanged | The merge plan changed since the dry run |
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 |
paymentAccountRequiresMarkPaid | The credential can record a payment only without a funding account or bank records |
paymentAttempted | An ACH payment was already attempted for the transaction |
paymentReturnInvolved | The transaction is part of a payment return |
paymentReturnNotEligible | The payout cannot be returned |
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 |
providerPaymentRecoveryInProgress | Payment recovery is already running |
providerPaymentRecoveryUnavailable | Payment recovery is not available for the transaction |
rampAccountNotReady | The Ramp funding, category, or bank account is not ready |
rampConnectionInactive | The Ramp connection is not active |
rampPaymentNotSupported | Ramp cannot pay this transaction type |
rampRecipientNotReady | The recipient has no ready Ramp ACH payment method |
reconciliationAmountMismatch | The bank record and transaction amounts differ |
reconciliationTransferMismatch | A transfer reconciles only to its source or destination bank record |
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 |
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.
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.
