Skip to content

Open-balance check

The open-balance check tells you whether a guest owes an operator money, and how much, in each of the operator’s workspaces (buildings). Use it before you offer something that depends on the guest being up to date, such as rebooker terms for returning residents.

Lavanda only reports the balance. Deciding who is eligible for what stays with you.

Note: The check is being rolled out one client at a time. Ask your Lavanda representative to enable it for your API client before you build against it.

  • Contact: a guest or resident in an operator’s CRM. Each contact has a Lavanda uniqueReference, and may also carry your own CRM id in externalCrmId.
  • CRM namespace: the set of contacts that belongs to one operator. Access to the check is granted per CRM namespace. An operator can have more than one.
  • Balance: what the contact owes in one workspace and one currency: issued invoices minus receipts and the credit notes raised against those invoices. A contact has one balance per workspace and currency they have been invoiced in.
  • Status: OWING or NOT_OWING, worked out by Lavanda for each balance and for the contact overall.

The check is a read-only field, arBalances, on the existing CrmContact type. There is no separate query: you find the contact with a query that already exists, and select arBalances on it.

Use crm.contact when you hold the contact’s Lavanda uniqueReference.

query ContactArBalances($id: ID!) {
crm {
contact(id: $id) {
id
arBalances {
status
balances {
workspace {
id
name
}
balance {
amountMinor
currency
}
status
updatedAt
}
}
}
}
}
{
"id": "<contact-unique-reference>"
}

If you store your own CRM id on the contact, search for it with crm.contacts and select the same field on each match.

query ContactArBalancesByCrmId($filters: ContactsFiltersInput) {
crm {
contacts(filters: $filters) {
nodes {
id
arBalances {
status
balances {
workspace {
name
}
balance {
amountMinor
currency
}
status
updatedAt
}
}
}
}
}
}
{
"filters": {
"externalCrmId": { "eq": "<your-crm-id>" }
}
}

An empty nodes list means no contact you can see has that CRM id. Treat it as “not found”.

Use GraphQL aliases to check several contacts in one HTTP request. Each alias is answered on its own, so an error on one alias does not stop the others. Within one alias, each contact is answered on its own too: if a crm.contacts filter matches several contacts and the check fails for one of them, only that contact’s arBalances is null, with an error whose path points to it. The other contacts in nodes keep their balances.

A request can carry at most 15 aliases, counted anywhere in the query, nested ones included. The router rejects more, so split larger batches across requests.

This is also how you check a guest who appears in more than one of an operator’s CRM namespaces. Lavanda does not combine balances across namespaces for you. If you need “owes money in any namespace”, ask for each contact and treat the guest as owing when any of the results is OWING.

query GuestArBalancesAcrossNamespaces(
$firstNamespace: ContactsFiltersInput
$secondNamespace: ContactsFiltersInput
) {
crm {
firstNamespace: contacts(filters: $firstNamespace) {
nodes {
id
arBalances {
status
balances {
workspace {
name
}
balance {
amountMinor
currency
}
status
updatedAt
}
}
}
}
secondNamespace: contacts(filters: $secondNamespace) {
nodes {
id
arBalances {
status
balances {
workspace {
name
}
balance {
amountMinor
currency
}
status
updatedAt
}
}
}
}
}
}
{
"firstNamespace": {
"externalCrmId": { "eq": "<your-crm-id>" },
"crmNamespace": { "id": { "eq": "<first-crm-namespace-id>" } }
},
"secondNamespace": {
"externalCrmId": { "eq": "<your-crm-id>" },
"crmNamespace": { "id": { "eq": "<second-crm-namespace-id>" } }
}
}

The same works with crm.contact: alias it once per uniqueReference you want to check.

Field Type Meaning
arBalances.status AccountingArBalanceStatus! OWING when any balance is above zero. NOT_OWING otherwise, including when there are no balances.
arBalances.balances [AccountingArBalance!]! One entry per workspace and currency. Empty when the contact has no issued documents.
balances.workspace Workspace The workspace the balance belongs to. Can be null; see Workspace details.
balances.balance AccountingMoney! amountMinor (in minor units, so 12500 is 125.00) and currency. Signed: negative means a credit. Never floored at zero.
balances.status AccountingArBalanceStatus! OWING when this balance is above zero.
balances.updatedAt DateTime! When this balance last changed.

An example response, with made-up values:

{
"data": {
"crm": {
"contact": {
"id": "<contact-id>",
"arBalances": {
"status": "OWING",
"balances": [
{
"workspace": { "id": "<workspace-a-id>", "name": "Example House North" },
"balance": { "amountMinor": 12500, "currency": "GBP" },
"status": "OWING",
"updatedAt": "2026-10-20T09:15:00Z"
},
{
"workspace": { "id": "<workspace-b-id>", "name": "Example House South" },
"balance": { "amountMinor": -3000, "currency": "GBP" },
"status": "NOT_OWING",
"updatedAt": "2026-10-02T14:40:00Z"
}
]
}
}
}
}
}

How to read it:

  • A credit never offsets debt elsewhere. The guest above is OWING because they owe 125.00 in one workspace, even though they are 30.00 in credit in another. Balances are not netted across workspaces or currencies.
  • Balances are signed and never floored. A negative amountMinor is money the operator holds for the guest. Lavanda does not round it up to zero.
  • No balances means NOT_OWING. A contact who has never been invoiced has an empty balances list and the status NOT_OWING.
  • updatedAt is when the balance last changed. It does not tell you whether a very recent invoice has been counted yet. See Known gaps.

Errors come back in errors[], with the code in extensions.code. When the check fails for a contact, you get no balances for it: arBalances is null, with an error whose path ends in arBalances. The rest of the contact (id, name and so on) still resolves, and so do the other contacts in the same list. A null arBalances means unknown, never not owing.

If Lavanda cannot answer at all (for example a timeout), every contact in the response can have arBalances: null with a single error that does not point at any one contact. Treat a null arBalances as unknown whether or not an error points at it.

If a contact comes back with arBalances: null and a FIND_CONTACT_ERROR error for it, your client cannot read that contact. Treat it as not found.

Code Meaning What to do
AR_BALANCE_UNAVAILABLE Lavanda cannot answer for this contact right now. The check may be temporarily unavailable, or the contact is not ready to be checked yet. Treat the guest as unknown. Never treat this as NOT_OWING. Retry later or fall back to a manual check.
UNAUTHORIZED_ERROR Your client is not allowed to see this contact’s balance. Ask Lavanda for the grant on that CRM namespace. Treat the guest as unknown.
AUTHORIZATION_UNAVAILABLE_ERROR Lavanda could not check your access. Retry. Treat the guest as unknown until you get an answer.
FIND_CONTACT_ERROR No contact you can read matches. From crm.contact, or from crm.contacts when a match exists but your client cannot read its profile. Treat as not found. If you expected a match from crm.contacts, check your client has the view_profile grant.

Not found looks the same whether the contact is unknown or belongs to someone else. A contact in another operator’s CRM namespace returns exactly the same response as a contact that does not exist: FIND_CONTACT_ERROR from crm.contact, or an empty nodes list from crm.contacts. This stops callers from discovering other operators’ guests.

An error on a balance’s workspace is different: see the next section. It never makes the balance unknown.

The safe rule: only NOT_OWING means not owing. An error, a null, or a missing contact never does.

Each balance carries the Workspace it belongs to, so you can select the workspace’s current name and other workspace fields.

Reading a workspace’s details needs the awc_workspaces_read grant on that workspace. Without it, that balance comes back with workspace: null and a USER_NOT_ALLOWED_ERROR error whose path points to it (for example ["crm", "contact", "arBalances", "balances", 1, "workspace"]). The balance, status and updatedAt of that entry are still returned, and the contact’s overall status still covers it.

So an error on workspace does not mean the balance is unknown. Read the amount and status as normal, and ask Lavanda for awc_workspaces_read on the workspaces whose names you need.

  • Issued documents only. Drafts do not count. The balance is issued invoices minus receipts and the credit notes raised against those invoices.
  • Payments count once their receipt is issued. A payment moves the balance when Lavanda issues its receipt, not when the money is taken.
  • Lag on busy billing days. A new invoice or receipt can take a while to reach the balance, and during a large billing run it can be delayed by hours. Until it arrives, the balance and its updatedAt look exactly as they did before, so updatedAt cannot tell you that something is still on its way. If a decision depends on very recent invoices, check again later.
  • Group-invoiced residents. When a resident is billed through a company, the debt sits on the company, not on the resident. The resident’s check does not show it.
  • Not yet in the balance: write-offs, standalone credit notes and refund reversals. Each will be added to the balance when it ships, and we will update this page.

Lavanda grants access to your API client per CRM namespace. For each operator namespace you check, your client needs:

Grant Needed for
view_ar_balance Reading arBalances on the namespace’s contacts
view_profile Reading the contact. Needed for both crm.contact and crm.contacts
search_contacts Looking contacts up with crm.contacts (by your own CRM id)
awc_workspaces_read Reading workspace details on each balance. Granted per workspace, not per namespace.

You only need search_contacts if you look contacts up by your own CRM id. Ask your Lavanda representative to set these up.

Change to crm.contact errors for denied lookups

Section titled “Change to crm.contact errors for denied lookups”

crm.contact used to tell you when a contact existed but your client could not see it. It now returns the not-found error in that case. The same applies wherever a contact is resolved for another query, for example when crm.contacts fetches a contact’s profile fields.

Case Before Now
Contact does not exist FIND_CONTACT_ERROR “Failed to find contact” No change
Contact exists but your client cannot see it USER_NOT_ALLOWED_ERROR “Unable to show contact details” FIND_CONTACT_ERROR “Failed to find contact”

If your integration treats USER_NOT_ALLOWED_ERROR from crm.contact as “this contact exists”, update it: a missing grant now looks the same as an unknown contact. Queries for companies, leads and contact relationships still return USER_NOT_ALLOWED_ERROR when access is denied.