Developers

Belvak API and webhooks

A REST API and signed outbound webhooks for building on top of Belvak, or connecting it to automation tools like Zapier, Make, n8n, and Pipedream. This page documents version 1 (v1). API access is a Professional plan feature.

BASEhttps://app.belvak.com/api/v1

Overview

The Belvak API is a REST API that returns and accepts JSON. Every request goes to a path under the base URL above, authenticated with an API key you create in your workspace. It reads and writes the same records you work with in the app, so a request through the API runs the same validation, tax, currency, and activity logging that the app does.

curl https://app.belvak.com/api/v1/me \
  -H "Authorization: Bearer bv_live_your_key_here"

Not in v1: time entries, users, and roles are not available through the API yet. The API covers the eleven record types listed under Entities.

Authentication

Create an API key in Settings, Integrations, API & Webhooks. Only an admin can manage keys, and API access is available on the Professional plan and above. A key is shown in full once, at the moment you create it, so copy it then. Keys belong to the workspace, not to the person who made them.

Send the key as a bearer token on every request:

Authorization: Bearer bv_live_...

You can rotate or revoke a key from the same screen, and the change takes effect immediately: a revoked key stops working on its very next request, even mid-session. A request with a missing or revoked key returns 401 invalid_key.

Conventions

  • Public ids everywhere. Every id you send or receive is your per-workspace public id, the same number shown as #N in the app. Internal database ids are never exposed and never accepted. Relation fields are public ids too, for example client_id, project_id, invoice_id, and referral_id.
  • Query parameters for reads. A single read and list filters use query parameters, never path segments. For example GET https://app.belvak.com/api/v1/invoices?id=42 returns invoice #42. An unrecognised query parameter returns 400 unsupported_param; an invalid limit or after_id value returns 400 invalid_input.
  • Date-range filters. fromDate and toDate are inclusive YYYY-MM-DD dates, and each entity's filter list says which field the range applies to. Where that field is a creation timestamp (clients and proposals), the calendar day is evaluated in UTC, so a record created near your local midnight can file under the neighbouring UTC day. Plain date fields (project end dates, invoice service periods, payment dates) are compared exactly as stored.
  • The body carries writes. Create, update, and delete send everything in the JSON body, including the record id for update and delete. A PUT or DELETE is addressed as https://app.belvak.com/api/v1/invoices with { "id": 42, ... } in the body, not as a query string. A query parameter on a write returns 400 unsupported_param; an unaccepted body field returns 400 unsupported_field.
  • Update replaces the whole record. PUT is a full replace, not a patch: it expects the same fields as create, with the same required fields, and on most record types an optional field you leave out is cleared or reset to its default. Read the record first, then send every writable field you want to keep, with the values you want. Read-only response fields are not accepted back on a write. A few fields are kept, not cleared, when you leave them out of a PUT:
    • clients: phone, email, address, tax_number, and contacts (send address, tax_number, or contacts as null to clear them)
    • employees: ephone, hDate, emergency_contact_relation, expected_weekly_hours, hourly_cost_override with its effective date, and the location lat/lon
    • maintenances: contract_end_date (send null to clear it)
    • proposals: status (send a valid status to change it)
    • referrals: commission_currency
    Notes are the exception to full replace: a note update takes only title, note, and color, and all three are required.
  • Pagination. List endpoints accept limit (default 100, maximum 500) and after_id, a cursor that is the public id of the last row you saw. Pass the last id back as after_id to fetch the next page.
    GET https://app.belvak.com/api/v1/clients?limit=100
    GET https://app.belvak.com/api/v1/clients?limit=100&after_id=100
  • Rate limit. Requests are limited to about 30 per second per IP address. Over the limit, the API returns a plain 429 response. Slow down and retry.
  • Module gates are additive. Even with API access, entities that belong to a module your plan does not include stay gated. A workspace without payroll or maintenance features gets a 403 feature error on the employees or maintenance endpoints, and that error passes straight through.

Responses

Every response is a JSON object with an ok flag. The shape depends on the call. Ids in the body are always your public ids.

List

GET https://app.belvak.com/api/v1/clients

{
  "ok": true,
  "data": [ { "id": 42, "clientName": "Acme Studio" } ],
  "count": 1,
  "has_more": false,
  "next_cursor": null
}

One record

GET https://app.belvak.com/api/v1/clients?id=42

{ "ok": true, "data": { "id": 42, "clientName": "Acme Studio" } }

Create

POST https://app.belvak.com/api/v1/clients

{ "ok": true, "id": 42 }

Update and delete

{ "ok": true }

Error

{ "ok": false, "error_code": "invalid_input", "message": "A human-readable reason." }

On a list, has_more tells you another page exists and next_cursor is the after_id to pass for it. On a successful create, id is the public id of the new record. A successful entity create returns HTTP 200; registering a webhook subscription is the one create that returns 201. Errors always carry an error_code, and usually a message; 404 responses carry the code alone.

Errors

Errors come back as JSON with an error_code you can branch on. Known errors carry the codes below. A validation error on a field (for example a missing required field) passes through with the same message the app would show.

error_codeHTTPMeaning
invalid_key401The bearer token is missing, malformed, or has been revoked.
permission_denied403The key is not allowed to perform this action, including module gates that pass straight through.
plan_locked403The workspace plan does not include API access. API access is a Professional plan feature.
org_inactive403The workspace is paused or closed, so the API is unavailable.
not_found404The addressed record does not exist in your workspace.
unknown_reference404A related id in your request does not exist in your workspace (for example a client_id that is not yours).
unsupported_param400A query parameter is not recognised for this endpoint.
unsupported_field400A body field is not accepted on this endpoint.
invalid_json400The request body is not a JSON object.
invalid_input400A field value failed validation, for example a malformed date or a missing required field.
db_validation_error400A value was rejected at the database layer. This is a defensive fallback, most often a number out of range; most fixed-choice fields are checked before the database and return invalid_input or a specific code such as invalid_invoice_status.
complete_requires_settlement400An invoice cannot be created as, or set to, Completed until it is fully paid or credited.
invalid_invoice_status400The invoice status is not one of Pending, Completed, or Overdue (exact case).
invalid_id400A required id is missing or not a positive integer (webhook management).
invalid_action400An unknown webhook action was requested.
invalid_url400A webhook URL failed validation, including the private-address checks. The response includes a reason field saying which check failed.
invalid_events400A webhook events value is not an array of event type strings.
unknown_event400An event type in a webhook events list is not in the events catalog. The message names the rejected value; read the catalog from GET /v1/webhooks.
nothing_to_update400A webhook PATCH carried nothing actionable. Send url, events, or an action.
method_not_allowed405The HTTP method is not supported on this endpoint.
conflict409The request conflicts with the current state of the record.
delete_blocked409The record has dependents and cannot be deleted. The response lists them under blockers, an array of { label, label_plural, count } objects. Each entry is a per-type aggregate, for example { label: invoice, label_plural: invoices, count: 2 }; the individual blocking records are not identified.
idempotency_conflict409The same Idempotency-Key was reused with a different request body.
idempotency_in_progress409A request with the same Idempotency-Key is still being processed.
idempotency_interrupted409The original request with this Idempotency-Key was interrupted before finishing. Verify whether the record was created, then retry with a new Idempotency-Key.
key_cap_reached409The workspace already has the maximum of 10 active API keys. Revoke one first.
subscription_cap_reached409The workspace already has the maximum of 25 webhook subscriptions.
rate_limited429Too many requests. Slow down and retry.
lock_timeout503A short-lived lock could not be acquired. Retry.
lock_unavailable503A short-lived lock could not be acquired while creating a webhook subscription. Retry.
server_error500An unexpected error occurred. The details are logged on our side, not returned to you.

Field validation reuses the same rules as the app, so a bad value comes back as invalid_input, or with a more specific code and message where the app has one (for example recurring_amount_required or invalid_recurring_interval on a recurring project). Branch on error_code and fall back to message.

A 500 server_error is deliberately opaque: the details are logged on our side and never returned, so nothing internal leaks to the caller.

Idempotency

To make a POST safe to retry, send an Idempotency-Key header with a value you choose (a UUID works well). It is optional, and only applies to creates.

curl https://app.belvak.com/api/v1/invoices \
  -X POST \
  -H "Authorization: Bearer bv_live_..." \
  -H "Idempotency-Key: 6f1c9b0e-2a7d-4f3e-9c11-8e2a7d4f3e9c" \
  -H "Content-Type: application/json" \
  -d '{"project_id": 42, "Amount": 1500, "due_date": "2026-08-01", "status": "Pending", "currency": "USD"}'
  • Only a successful response is stored. Reuse the same key with the same body and the stored response is replayed, without creating a second record.
  • If the first request fails with a 4xx error (a validation or permission problem), the key is released, so you can fix the request and retry with the same key.
  • Reuse the same key with a different body and the request is rejected with 409 idempotency_conflict.
  • Send it again while the first is still running and you get 409 idempotency_in_progress. Wait and retry.
  • If the first request fails with a 5xx server error, or is interrupted before finishing (a dropped connection or a server restart), a later retry with that key returns 409 idempotency_interrupted. The record may or may not have been created, so check first, then retry with a new Idempotency-Key.

Keys are remembered for 24 hours.

Entities

Each of the eleven record types supports the same calls: list, read one, create, update, and delete. Notes are the one exception, they are always listed under a parent record and have no read-one call. The tables below list the fields you can write for each type (v1); fields marked required must be present on create, and again on update: an update is a full replace, so send the complete record, not just the fields that changed. Update and delete send the record id in the JSON body. Relation fields are public ids, and you send them by the public id of the related record: an unknown id returns 404 unknown_reference. Every list endpoint also accepts the pagination parameters.

Clients

The companies and people you do business with.

GET/clientslist, paginated
GET/clients?id=<id>one record
POST/clientscreate
PUT/clientsupdate, id in body
DELETE/clientsdelete, id in body

List filters

  • referral_id only clients introduced by this referral partner
  • fromDate, toDate clients whose creation timestamp (created_at, UTC calendar day) falls in the range (YYYY-MM-DD). This is when the record was created, not knowDate
FieldTypeNotes
clientNamerequiredstringClient name
knowDaterequireddateThe date you started working with them
referral_idrequiredpublic idThe referral partner who introduced this client. Every workspace has at least one referral to point at; list them with GET /referrals.
phonestring
emailstring
addressstring
tax_numberstringTax registration number shown on documents
contactsarrayAdditional contact people

The response also returns status, projects, invoiced, and paid, which are read-only.

Projects

Client engagements, one-time or recurring.

GET/projectslist, paginated
GET/projects?id=<id>one record
POST/projectscreate
PUT/projectsupdate, id in body
DELETE/projectsdelete, id in body

List filters

  • fromDate, toDate projects whose end date falls in the range (YYYY-MM-DD). Projects without an end date are left out when this filter is used.
FieldTypeNotes
namerequiredstring
start_daterequireddate
statusrequiredstringactive, completed, or on_hold
typerequiredstringone_time or recurring
client_idrequiredpublic idThe client this project belongs to
descriptionstring
contract_valuenumberAgreed price, per period for recurring projects
currencystringISO 4217
estimated_hoursnumberOptional hours estimate
end_datedate
recurring_intervalstringweekly, biweekly, semimonthly, monthly, quarterly, or yearly (recurring only)
next_due_datedateNext invoice date, for recurring projects. Accepted on create only: after that the server advances it as invoices are generated, and sending it on update returns unsupported_field.
proposal_idpublic idThe proposal this project was converted from, if any
tax_namestringOptional tax label
tax_ratenumberTax percentage
tax2_namestringOptional second tax label
tax2_ratenumberSecond tax percentage
employeespublic id[]Assigned employees, as public ids
tag_idspublic id[]Tags, as public ids. There is no tags endpoint: tag ids appear in project responses, so read them from an existing project that already carries the tag.

Deleting a project permanently deletes everything under it: its invoices, payments on those invoices, linked maintenance contracts and their payments, and credit notes. Projects never return delete_blocked; the delete succeeds and the children are gone. Delete with care.

Invoices

Bills raised against a project. Manual invoices are one-time.

GET/invoiceslist, paginated
GET/invoices?id=<id>one record
POST/invoicescreate
PUT/invoicesupdate, id in body
DELETE/invoicesdelete, id in body

List filters

  • fromDate, toDate invoices whose service period (StartDate to EndDate) overlaps the range (YYYY-MM-DD). Invoices without a service period are left out when this filter is used.
FieldTypeNotes
due_daterequireddate
AmountrequirednumberThe invoice total. The field name is Amount, with a capital A. With amount_includes_tax true, this is the tax-inclusive total; otherwise it is treated as the pre-tax subtotal and the stored Amount is grossed up to subtotal plus tax, so the Amount you read back can be higher than the value you sent.
statusrequiredstringPending, Completed, or Overdue (exact case). A new invoice cannot start as Completed: create it as Pending and record a payment, and it completes automatically once fully covered.
project_idrequiredpublic idThe project this invoice belongs to
descriptionstring
currencystringISO 4217. Must match the linked project's currency; a mismatch returns invalid_input. Updates to a maintenance-linked invoice must match the maintenance contract's currency instead.
amount_includes_taxbooleanWhether the Amount you send already includes the taxes. Defaults to false, where taxes are added on top.
tax_namestringOptional tax label
tax_ratenumberTax percentage
tax2_namestringOptional second tax label
tax2_ratenumberSecond tax percentage
StartDatedateService period start (YYYY-MM-DD). Set it, with EndDate, to make the invoice match the fromDate/toDate filter.
EndDatedateService period end (YYYY-MM-DD). Must not be before StartDate.

subtotal, tax_amount, and tax2_amount are computed server-side from Amount and the tax rates and returned read-only, along with paid, credited, refunded, and cancel_reason. There is no separate way to set Cancelled through the API. PaymentType is read-only: invoices created through the API are always one_time, and recurring invoices only come from the app's billing flows.

Deleting an invoice permanently deletes the payments recorded against it (project and maintenance payments), its credit notes and refunds, and its documents, and removes pending referral commissions tied to those payments. Delete with care.

Payments

Money received against an invoice.

GET/paymentslist, paginated
GET/payments?id=<id>one record
POST/paymentscreate
PUT/paymentsupdate, id in body
DELETE/paymentsdelete, id in body

List filters

  • client_id only payments for this client
  • fromDate, toDate payments whose payment_date falls in the range (YYYY-MM-DD)
FieldTypeNotes
invoice_idrequiredpublic idThe invoice this payment is applied to
payment_daterequireddate
amount_paidrequirednumberAmount received. The field name is amount_paid.
currencystringISO 4217
payment_methodstringOne of Bank Transfer, Check, Cash, Credit Card, or Other, written exactly like that (the check is case-sensitive)
reference_numberstringCheque number, transaction id, and the like
fx_rate_modestringHow to source the exchange rate when currencies differ
fx_rate_overridenumberA specific exchange rate to use

Proposals

Offers sent to clients. Statuses are Pending, Approved, and Rejected.

GET/proposalslist, paginated
GET/proposals?id=<id>one record
POST/proposalscreate
PUT/proposalsupdate, id in body
DELETE/proposalsdelete, id in body

List filters

  • fromDate, toDate proposals whose creation timestamp (created_at, UTC calendar day) falls in the range (YYYY-MM-DD)
FieldTypeNotes
namerequiredstringProposal title
client_idrequiredpublic idThe client this proposal is for
due_daterequireddateValid-until date
costsrequirednumberThe proposal value
descriptionstring
currencystringISO 4217
statusstringPending, Approved, or Rejected. Accepted on update only.

Suppliers

The vendors you buy from.

GET/supplierslist, paginated
GET/suppliers?id=<id>one record
POST/supplierscreate
PUT/suppliersupdate, id in body
DELETE/suppliersdelete, id in body
FieldTypeNotes
namerequiredstring
emailstring
websitestring
phonestring
addressstring
contact_personstring

Purchases

Expenses, optionally billable to a project or maintenance contract.

GET/purchaseslist, paginated
GET/purchases?id=<id>one record
POST/purchasescreate
PUT/purchasesupdate, id in body
DELETE/purchasesdelete, id in body

List filters

  • supplier_public_id only purchases from this supplier, by its public id
FieldTypeNotes
supplier_idrequiredpublic idThe supplier for this purchase
purchase_daterequireddate
pricerequirednumberPurchase amount. The field name is price.
descriptionstring
currencystringISO 4217
project_idpublic idLinked project, if billable to one
maintenance_idpublic idLinked maintenance contract, if any
categorystringExpense category
statusstringpending, approved, paid, or cancelled
is_billablebooleanWhether this expense can be billed on
markup_percentagenumberMarkup applied when billed on
tax_amountnumber
is_recurringboolean
recurrence_frequencystringFor a recurring purchase
recurrence_end_datedateFor a recurring purchase
fx_rate_modestringHow to source the exchange rate
fx_rate_overridenumberA specific exchange rate to use

Employees

Your team members and their compensation.

GET/employeeslist, paginated
GET/employees?id=<id>one record
POST/employeescreate
PUT/employeesupdate, id in body
DELETE/employeesdelete, id in body
FieldTypeNotes
firstNamerequiredstring
lastNamerequiredstring
emailrequiredstring
phonerequiredstring
jobTitlerequiredstring
ephonerequiredstringEmergency contact phone
hDaterequireddateHire date
salarynumberAmount per pay period
salary_currencystringISO 4217
compensation_typestringMonthly, Weekly, Bi-weekly, Semi-monthly, or Per-Work
dobdateDate of birth
jobLevelstring
emergency_contact_relationstring
expected_weekly_hoursnumber
hourly_cost_overridenumber
hourly_cost_override_effective_datedate
locationobjectAddress, as an object with city, country, state, street, road, neighbourhood, lat, and lon
pay_daynumberPay day of month, 1-28 or 31 (Monthly)
pay_day_of_weeknumberPay day of week, 0-6 (Weekly and Bi-weekly)
pay_day_semi_1numberFirst semi-monthly pay day, 1-28 or 31 (Semi-monthly)
pay_day_semi_2numberSecond semi-monthly pay day, 1-28 or 31 (Semi-monthly)

Pay-day writes depend on compensation_type: Monthly uses pay_day, Weekly and Bi-weekly use pay_day_of_week, Semi-monthly uses pay_day_semi_1 and pay_day_semi_2, and Per-Work stores no pay days. Reads normalize them to two fields: pay_day carries whichever first value applies, and pay_day_2 carries the second semi-monthly day. The write-only alias names are not returned, and pay_day_2 is not writable: to keep a semi-monthly schedule on update, resend it as pay_day_semi_1 and pay_day_semi_2.

user_uid, image, docs, and skills are not accepted on writes. Skills are returned as names, read-only.

Maintenance contracts

Recurring service contracts, optionally tied to a project.

GET/maintenanceslist, paginated
GET/maintenances?id=<id>one record
POST/maintenancescreate
PUT/maintenancesupdate, id in body
DELETE/maintenancesdelete, id in body
FieldTypeNotes
namerequiredstring
start_daterequireddate
statusrequiredstringactive or closed
amountrequirednumberPrice per period
termrequiredstringBilling frequency: monthly or yearly, lowercase
servicerequiredstringWhat the contract covers
descriptionstring
contract_end_datedateWhen the contract stops renewing
currencystringISO 4217
client_idpublic idThe client this contract covers
project_idpublic idLinked project, if any
supplier_idpublic idLinked supplier, if any
tax_namestringOptional tax label
tax_ratenumberTax percentage
tax2_namestringOptional second tax label
tax2_ratenumberSecond tax percentage

The response returns end_date, the server-managed billed-through cursor: it starts at start_date and advances as paid coverage is recorded or a renewal period is skipped. It is not the contractual end; that is contract_end_date. A maintenance record has no invoice_id.

At least one of client_id and project_id is required, on create and on update. With only a project_id, the contract's client comes from the project.

Referrals

The partners who introduce clients to you.

GET/referralslist, paginated
GET/referrals?id=<id>one record
POST/referralscreate
PUT/referralsupdate, id in body
DELETE/referralsdelete, id in body
FieldTypeNotes
referralNamerequiredstringPartner name
phonestring
emailstring
percentagenumberCommission percentage
commission_typestringpercentage or flat_fee
flat_fee_amountnumberFlat commission amount
commission_durationstring
commission_capnumberMaximum total commission
commission_currencystringISO 4217
include_maintenancebooleanWhether maintenance revenue earns commission

Notes

Internal notes attached to any of the other ten record types.

GET/notes?entity_type=<type>&entity_id=<id>list, paginated
POST/notescreate
PUT/notesupdate, id in body
DELETE/notesdelete, id in body

List filters

  • entity_type the parent record type, plural and lowercase, matching the endpoint paths (clients, projects, invoices, and so on). Required.
  • entity_id the public id of the parent record. Required.
FieldTypeNotes
titlerequiredstring
noterequiredstringThe note body. The field name is note.
entity_typerequiredstringThe parent record type, plural and lowercase, matching the endpoint paths: clients, projects, invoices, payments, proposals, suppliers, purchases, employees, maintenances, or referrals. A note cannot be attached to another note.
entity_idrequiredpublic idThe record this note is attached to
colorstringColour label. Optional on create, where it defaults to yellow; required on update.
date_addeddate-timeOptional; defaults to the current time (UTC)

Notes are always scoped to a parent record, so there is no single-note read by id: list them with entity_type and entity_id; leaving either out, or sending an unknown entity_type, returns 400 invalid_input. The response includes creator_first_name, creator_last_name, and creator_email; the name fields are null when the creating user has not set a name. Editing a note is limited to its author, matching how notes behave in the app.

Webhooks

Webhooks tell your systems when something changes in Belvak, so you do not have to poll. Register an endpoint, choose the events you care about, and Belvak posts to it as they happen. Manage subscriptions in Settings, or over the API:

POST/webhooksregister an endpoint
GET/webhookslist subscriptions, no secrets
PATCH/webhooks?id=<id>update url or events, disable, enable, rotate secret
DELETE/webhooks?id=<id>remove an endpoint

The create body takes two fields: url, required, and events, an optional array of event names from the catalog below. Leave events out, or send it as null or an empty array, to subscribe to every event. An unknown event name is rejected, an unknown body field returns 400 unsupported_field, and a non-object body returns 400 invalid_json.

POST https://app.belvak.com/api/v1/webhooks
{ "url": "https://example.com/hooks/belvak" }

POST https://app.belvak.com/api/v1/webhooks
{ "url": "https://example.com/hooks/belvak",
  "events": ["invoice.created", "payment.created"] }

When you register an endpoint you get a signing secret, prefixed whsec_ and shown once. A PATCH body takes url, events, or action, where action is disable, enable, or rotate_secret. On PATCH and DELETE the subscription id can go in the query string as ?id= or in the JSON body. This is the one place an id is accepted on the query string for a write, because webhook management is a dedicated endpoint rather than one of the record types above.

The payload

Payloads are thin. They tell you what happened and where to read the full record, which you then fetch from the API. A *.deleted event is a tombstone: it carries the id of the record that was removed, and no self link. Note events also omit self, because notes have no single-record read: list them by their parent with entity_type and entity_id.

{
  "event": "invoice.created",
  "occurred_at": "2026-07-18T09:41:00Z",
  "delivery_id": "b6d1f0c2a9e34b7c8d5e6f7a8b9c0d1e",
  "workspace": { "name": "Acme Studio" },
  "entity": "invoice",
  "id": 42,
  "self": "https://app.belvak.com/api/v1/invoices?id=42"
}

Headers on every delivery

  • Belvak-Event the event type, for example invoice.created
  • Belvak-Delivery a unique id for this delivery, used to dedupe
  • Belvak-Webhook-Id the public id of the subscription
  • Belvak-Signature the signature, described below

Verifying the signature

Each delivery is signed with the subscription secret so you can confirm it really came from Belvak. The header looks like this:

Belvak-Signature: t=1750000000,v1=<hex>

To verify, take the timestamp t and the exact raw request body, join them with a dot as {t}.{rawBody}, and compute HMAC-SHA256 of that string using your secret. Compare it to the v1 value using a constant-time comparison. Verify against the raw body bytes, before any JSON parsing or re-serialising, since re-encoding changes the signature. Reject a delivery whose t is more than five minutes from your own clock, which blocks replays.

Worked example

With these fixed inputs you should get exactly this signature, which you can use to confirm your verification code:

secret     whsec_test_secret
timestamp  1750000000
raw body   {"event":"invoice.created"}

signed string:
1750000000.{"event":"invoice.created"}

HMAC-SHA256 (hex):
0556c1c4f94574349f40d02a3d8038ddf20d741c66fe42de885f695ff11fef86

full header:
Belvak-Signature: t=1750000000,v1=0556c1c4f94574349f40d02a3d8038ddf20d741c66fe42de885f695ff11fef86

Delivery

Delivery is best effort until an event is queued. Once queued, Belvak delivers at least once, so a duplicate is possible: dedupe on the Belvak-Delivery id and treat repeats as no-ops. Belvak retries automatically with a growing backoff for up to ten attempts, and an endpoint that keeps failing is disabled automatically. Because a single change can be logged more than once, you may see more than one event for it, which is another reason to make your handler idempotent. One deliberate gap: an invoice whose status flips because a payment landed does not emit invoice.updated. Hydrate the invoice after a payment.created instead.

Event catalog

There are 33 events, one created, updated, and deleted for each of the eleven record types:

EntityEvents
clientclient.created, client.updated, client.deleted
projectproject.created, project.updated, project.deleted
invoiceinvoice.created, invoice.updated, invoice.deleted
paymentpayment.created, payment.updated, payment.deleted
proposalproposal.created, proposal.updated, proposal.deleted
suppliersupplier.created, supplier.updated, supplier.deleted
purchasepurchase.created, purchase.updated, purchase.deleted
employeeemployee.created, employee.updated, employee.deleted
maintenancemaintenance.created, maintenance.updated, maintenance.deleted
referralreferral.created, referral.updated, referral.deleted
notenote.created, note.updated, note.deleted

Connection test

Call GET /me to check a key and see which workspace and plan it belongs to. It is the quickest way to confirm a key works before wiring up the rest.

GET https://app.belvak.com/api/v1/me

{
  "ok": true,
  "workspace": { "name": "Acme Studio" },
  "plan": { "name": "Professional" },
  "key": {
    "label": "Zapier",
    "prefix": "bv_live_a1b2",
    "last_used_at": "2026-07-18T09:41:00Z"
  }
}

plan.name is your plan name when it is set, and can be null for a legacy workspace. Likewise last_used_at is null until the key's first use.

Next

Connect an automation tool

See the Zapier guide for a worked example, or the API and webhooks help guide for step-by-step setup, including Make, n8n, and Pipedream.