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.
https://app.belvak.com/api/v1Overview
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
#Nin the app. Internal database ids are never exposed and never accepted. Relation fields are public ids too, for exampleclient_id,project_id,invoice_id, andreferral_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=42returns invoice#42. An unrecognised query parameter returns400 unsupported_param; an invalidlimitorafter_idvalue returns400 invalid_input. - Date-range filters.
fromDateandtoDateare inclusiveYYYY-MM-DDdates, 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
idfor update and delete. APUTorDELETEis addressed ashttps://app.belvak.com/api/v1/invoiceswith{ "id": 42, ... }in the body, not as a query string. A query parameter on a write returns400 unsupported_param; an unaccepted body field returns400 unsupported_field. - Update replaces the whole record.
PUTis 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 aPUT:- clients:
phone,email,address,tax_number, andcontacts(sendaddress,tax_number, orcontactsasnullto clear them) - employees:
ephone,hDate,emergency_contact_relation,expected_weekly_hours,hourly_cost_overridewith its effective date, and the locationlat/lon - maintenances:
contract_end_date(sendnullto clear it) - proposals:
status(send a valid status to change it) - referrals:
commission_currency
title,note, andcolor, and all three are required. - clients:
- Pagination. List endpoints accept
limit(default 100, maximum 500) andafter_id, a cursor that is the public id of the last row you saw. Pass the last id back asafter_idto 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
429response. 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
403feature 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_code | HTTP | Meaning |
|---|---|---|
| invalid_key | 401 | The bearer token is missing, malformed, or has been revoked. |
| permission_denied | 403 | The key is not allowed to perform this action, including module gates that pass straight through. |
| plan_locked | 403 | The workspace plan does not include API access. API access is a Professional plan feature. |
| org_inactive | 403 | The workspace is paused or closed, so the API is unavailable. |
| not_found | 404 | The addressed record does not exist in your workspace. |
| unknown_reference | 404 | A related id in your request does not exist in your workspace (for example a client_id that is not yours). |
| unsupported_param | 400 | A query parameter is not recognised for this endpoint. |
| unsupported_field | 400 | A body field is not accepted on this endpoint. |
| invalid_json | 400 | The request body is not a JSON object. |
| invalid_input | 400 | A field value failed validation, for example a malformed date or a missing required field. |
| db_validation_error | 400 | A 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_settlement | 400 | An invoice cannot be created as, or set to, Completed until it is fully paid or credited. |
| invalid_invoice_status | 400 | The invoice status is not one of Pending, Completed, or Overdue (exact case). |
| invalid_id | 400 | A required id is missing or not a positive integer (webhook management). |
| invalid_action | 400 | An unknown webhook action was requested. |
| invalid_url | 400 | A webhook URL failed validation, including the private-address checks. The response includes a reason field saying which check failed. |
| invalid_events | 400 | A webhook events value is not an array of event type strings. |
| unknown_event | 400 | An 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_update | 400 | A webhook PATCH carried nothing actionable. Send url, events, or an action. |
| method_not_allowed | 405 | The HTTP method is not supported on this endpoint. |
| conflict | 409 | The request conflicts with the current state of the record. |
| delete_blocked | 409 | The 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_conflict | 409 | The same Idempotency-Key was reused with a different request body. |
| idempotency_in_progress | 409 | A request with the same Idempotency-Key is still being processed. |
| idempotency_interrupted | 409 | The 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_reached | 409 | The workspace already has the maximum of 10 active API keys. Revoke one first. |
| subscription_cap_reached | 409 | The workspace already has the maximum of 25 webhook subscriptions. |
| rate_limited | 429 | Too many requests. Slow down and retry. |
| lock_timeout | 503 | A short-lived lock could not be acquired. Retry. |
| lock_unavailable | 503 | A short-lived lock could not be acquired while creating a webhook subscription. Retry. |
| server_error | 500 | An 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.
/clientslist, paginated/clients?id=<id>one record/clientscreate/clientsupdate, id in body/clientsdelete, id in bodyList filters
referral_idonly clients introduced by this referral partnerfromDate, toDateclients whose creation timestamp (created_at, UTC calendar day) falls in the range (YYYY-MM-DD). This is when the record was created, not knowDate
| Field | Type | Notes |
|---|---|---|
| clientNamerequired | string | Client name |
| knowDaterequired | date | The date you started working with them |
| referral_idrequired | public id | The referral partner who introduced this client. Every workspace has at least one referral to point at; list them with GET /referrals. |
| phone | string | |
| string | ||
| address | string | |
| tax_number | string | Tax registration number shown on documents |
| contacts | array | Additional contact people |
The response also returns status, projects, invoiced, and paid, which are read-only.
Projects
Client engagements, one-time or recurring.
/projectslist, paginated/projects?id=<id>one record/projectscreate/projectsupdate, id in body/projectsdelete, id in bodyList filters
fromDate, toDateprojects whose end date falls in the range (YYYY-MM-DD). Projects without an end date are left out when this filter is used.
| Field | Type | Notes |
|---|---|---|
| namerequired | string | |
| start_daterequired | date | |
| statusrequired | string | active, completed, or on_hold |
| typerequired | string | one_time or recurring |
| client_idrequired | public id | The client this project belongs to |
| description | string | |
| contract_value | number | Agreed price, per period for recurring projects |
| currency | string | ISO 4217 |
| estimated_hours | number | Optional hours estimate |
| end_date | date | |
| recurring_interval | string | weekly, biweekly, semimonthly, monthly, quarterly, or yearly (recurring only) |
| next_due_date | date | Next 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_id | public id | The proposal this project was converted from, if any |
| tax_name | string | Optional tax label |
| tax_rate | number | Tax percentage |
| tax2_name | string | Optional second tax label |
| tax2_rate | number | Second tax percentage |
| employees | public id[] | Assigned employees, as public ids |
| tag_ids | public 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.
/invoiceslist, paginated/invoices?id=<id>one record/invoicescreate/invoicesupdate, id in body/invoicesdelete, id in bodyList filters
fromDate, toDateinvoices 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.
| Field | Type | Notes |
|---|---|---|
| due_daterequired | date | |
| Amountrequired | number | The 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. |
| statusrequired | string | Pending, 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_idrequired | public id | The project this invoice belongs to |
| description | string | |
| currency | string | ISO 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_tax | boolean | Whether the Amount you send already includes the taxes. Defaults to false, where taxes are added on top. |
| tax_name | string | Optional tax label |
| tax_rate | number | Tax percentage |
| tax2_name | string | Optional second tax label |
| tax2_rate | number | Second tax percentage |
| StartDate | date | Service period start (YYYY-MM-DD). Set it, with EndDate, to make the invoice match the fromDate/toDate filter. |
| EndDate | date | Service 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.
/paymentslist, paginated/payments?id=<id>one record/paymentscreate/paymentsupdate, id in body/paymentsdelete, id in bodyList filters
client_idonly payments for this clientfromDate, toDatepayments whose payment_date falls in the range (YYYY-MM-DD)
| Field | Type | Notes |
|---|---|---|
| invoice_idrequired | public id | The invoice this payment is applied to |
| payment_daterequired | date | |
| amount_paidrequired | number | Amount received. The field name is amount_paid. |
| currency | string | ISO 4217 |
| payment_method | string | One of Bank Transfer, Check, Cash, Credit Card, or Other, written exactly like that (the check is case-sensitive) |
| reference_number | string | Cheque number, transaction id, and the like |
| fx_rate_mode | string | How to source the exchange rate when currencies differ |
| fx_rate_override | number | A specific exchange rate to use |
Proposals
Offers sent to clients. Statuses are Pending, Approved, and Rejected.
/proposalslist, paginated/proposals?id=<id>one record/proposalscreate/proposalsupdate, id in body/proposalsdelete, id in bodyList filters
fromDate, toDateproposals whose creation timestamp (created_at, UTC calendar day) falls in the range (YYYY-MM-DD)
| Field | Type | Notes |
|---|---|---|
| namerequired | string | Proposal title |
| client_idrequired | public id | The client this proposal is for |
| due_daterequired | date | Valid-until date |
| costsrequired | number | The proposal value |
| description | string | |
| currency | string | ISO 4217 |
| status | string | Pending, Approved, or Rejected. Accepted on update only. |
Suppliers
The vendors you buy from.
/supplierslist, paginated/suppliers?id=<id>one record/supplierscreate/suppliersupdate, id in body/suppliersdelete, id in body| Field | Type | Notes |
|---|---|---|
| namerequired | string | |
| string | ||
| website | string | |
| phone | string | |
| address | string | |
| contact_person | string |
Purchases
Expenses, optionally billable to a project or maintenance contract.
/purchaseslist, paginated/purchases?id=<id>one record/purchasescreate/purchasesupdate, id in body/purchasesdelete, id in bodyList filters
supplier_public_idonly purchases from this supplier, by its public id
| Field | Type | Notes |
|---|---|---|
| supplier_idrequired | public id | The supplier for this purchase |
| purchase_daterequired | date | |
| pricerequired | number | Purchase amount. The field name is price. |
| description | string | |
| currency | string | ISO 4217 |
| project_id | public id | Linked project, if billable to one |
| maintenance_id | public id | Linked maintenance contract, if any |
| category | string | Expense category |
| status | string | pending, approved, paid, or cancelled |
| is_billable | boolean | Whether this expense can be billed on |
| markup_percentage | number | Markup applied when billed on |
| tax_amount | number | |
| is_recurring | boolean | |
| recurrence_frequency | string | For a recurring purchase |
| recurrence_end_date | date | For a recurring purchase |
| fx_rate_mode | string | How to source the exchange rate |
| fx_rate_override | number | A specific exchange rate to use |
Employees
Your team members and their compensation.
/employeeslist, paginated/employees?id=<id>one record/employeescreate/employeesupdate, id in body/employeesdelete, id in body| Field | Type | Notes |
|---|---|---|
| firstNamerequired | string | |
| lastNamerequired | string | |
| emailrequired | string | |
| phonerequired | string | |
| jobTitlerequired | string | |
| ephonerequired | string | Emergency contact phone |
| hDaterequired | date | Hire date |
| salary | number | Amount per pay period |
| salary_currency | string | ISO 4217 |
| compensation_type | string | Monthly, Weekly, Bi-weekly, Semi-monthly, or Per-Work |
| dob | date | Date of birth |
| jobLevel | string | |
| emergency_contact_relation | string | |
| expected_weekly_hours | number | |
| hourly_cost_override | number | |
| hourly_cost_override_effective_date | date | |
| location | object | Address, as an object with city, country, state, street, road, neighbourhood, lat, and lon |
| pay_day | number | Pay day of month, 1-28 or 31 (Monthly) |
| pay_day_of_week | number | Pay day of week, 0-6 (Weekly and Bi-weekly) |
| pay_day_semi_1 | number | First semi-monthly pay day, 1-28 or 31 (Semi-monthly) |
| pay_day_semi_2 | number | Second 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.
/maintenanceslist, paginated/maintenances?id=<id>one record/maintenancescreate/maintenancesupdate, id in body/maintenancesdelete, id in body| Field | Type | Notes |
|---|---|---|
| namerequired | string | |
| start_daterequired | date | |
| statusrequired | string | active or closed |
| amountrequired | number | Price per period |
| termrequired | string | Billing frequency: monthly or yearly, lowercase |
| servicerequired | string | What the contract covers |
| description | string | |
| contract_end_date | date | When the contract stops renewing |
| currency | string | ISO 4217 |
| client_id | public id | The client this contract covers |
| project_id | public id | Linked project, if any |
| supplier_id | public id | Linked supplier, if any |
| tax_name | string | Optional tax label |
| tax_rate | number | Tax percentage |
| tax2_name | string | Optional second tax label |
| tax2_rate | number | Second 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.
/referralslist, paginated/referrals?id=<id>one record/referralscreate/referralsupdate, id in body/referralsdelete, id in body| Field | Type | Notes |
|---|---|---|
| referralNamerequired | string | Partner name |
| phone | string | |
| string | ||
| percentage | number | Commission percentage |
| commission_type | string | percentage or flat_fee |
| flat_fee_amount | number | Flat commission amount |
| commission_duration | string | |
| commission_cap | number | Maximum total commission |
| commission_currency | string | ISO 4217 |
| include_maintenance | boolean | Whether maintenance revenue earns commission |
Notes
Internal notes attached to any of the other ten record types.
/notes?entity_type=<type>&entity_id=<id>list, paginated/notescreate/notesupdate, id in body/notesdelete, id in bodyList filters
entity_typethe parent record type, plural and lowercase, matching the endpoint paths (clients, projects, invoices, and so on). Required.entity_idthe public id of the parent record. Required.
| Field | Type | Notes |
|---|---|---|
| titlerequired | string | |
| noterequired | string | The note body. The field name is note. |
| entity_typerequired | string | The 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_idrequired | public id | The record this note is attached to |
| color | string | Colour label. Optional on create, where it defaults to yellow; required on update. |
| date_added | date-time | Optional; 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:
/webhooksregister an endpoint/webhookslist subscriptions, no secrets/webhooks?id=<id>update url or events, disable, enable, rotate secret/webhooks?id=<id>remove an endpointThe 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-Eventthe event type, for exampleinvoice.createdBelvak-Deliverya unique id for this delivery, used to dedupeBelvak-Webhook-Idthe public id of the subscriptionBelvak-Signaturethe 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=0556c1c4f94574349f40d02a3d8038ddf20d741c66fe42de885f695ff11fef86Delivery
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:
| Entity | Events |
|---|---|
| client | client.created, client.updated, client.deleted |
| project | project.created, project.updated, project.deleted |
| invoice | invoice.created, invoice.updated, invoice.deleted |
| payment | payment.created, payment.updated, payment.deleted |
| proposal | proposal.created, proposal.updated, proposal.deleted |
| supplier | supplier.created, supplier.updated, supplier.deleted |
| purchase | purchase.created, purchase.updated, purchase.deleted |
| employee | employee.created, employee.updated, employee.deleted |
| maintenance | maintenance.created, maintenance.updated, maintenance.deleted |
| referral | referral.created, referral.updated, referral.deleted |
| note | note.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.