Use incoming webhooks to get real-time updates
Listen for events on your Kavitro account so your integration can automatically trigger reactions.
They work by sending out an HTTP request to a specified endpoint when an event is triggered. This makes webhooks ideal for integrating two applications.
Webhooks are a performant alternative to continuously polling for changes. Webhooks remove the need to send requests over and over. Instead, the service provider sends notifications whenever updated data is available.
How we use webhooks
A webhook enables Kavitro to push real-time notifications to your endpoint as a JSON payload. You can then use these notifications to execute actions in your backend systems.
How to receive webhooks
- Build an endpoint that accepts
POSTrequests with a JSON body. - Verify the signature, then acknowledge with a 2xx before doing any real work.
- Deploy it at a publicly accessible HTTPS URL.
- Register that URL, either in Settings > Integrations or through the API, and choose the events it should receive.
The rest of this page walks through each of those.
Step 1: Create a webhook endpoint
Set up an endpoint on your server that accepts unauthenticated POST requests and expects the data as a JSON payload. It has to be reachable over HTTPS from the public internet.
Step 2: Handle requests
Kavitro sends events to your webhook endpoint as part of a POST request with a JSON payload.
Check event objects
Each event is structured as an event object with an id, type, occurredAt timestamp and related resource nested under data. Your endpoint must check the event type and parse the payload of each event.
[
{
"id": "256a5a1f-3c1f-4a87-9e37-0008be001ade",
"type": "task.updated",
"data": {
"object": {
"id": "9",
"type": "task"
}
},
"occurredAt": "2023-07-09T10:52:24.000000Z"
}
]
The payload is always a JSON array, but it currently carries exactly one event per request. Parse it as an array anyway, so your endpoint keeps working if that ever changes.
data.object carries only an id and a type. It is a pointer to the record, not a copy of it - there are no field values in a webhook, and no before/after comparison.
So the normal shape of a handler is: read type and data.object.id, then query the API for whatever you actually need about that record. Doing it that way also means you always act on the record's current state rather than on a snapshot that may already be stale by the time you process it.
There's an exception for files, where data.object.id carries the file's UUID rather than its numeric ID. The top-level event id is always a UUID, for every event type.
The request we send
Deliveries are POST requests with a JSON body and these characteristics:
| Method | POST |
Content-Type | application/json |
X-Kavitro-Signature | HMAC-SHA256 of the body, see verifying requests |
| Timeout | 3 seconds |
| Body | A JSON array holding one event object |
There is no timestamp header and no custom user agent, so the signature is the only thing that authenticates a delivery.
Kavitro delivers to each endpoint one event at a time: the next delivery goes out once your endpoint has answered the previous one, or its 3 seconds have run out. How quickly your endpoint answers therefore sets how quickly a burst of events, such as an import, reaches you.
Return a 2xx response
Your endpoint must quickly return a successful HTTP status code (2xx) prior to any complex logic that could cause a timeout.
That includes events you do not handle. Subscribing to only the events you need keeps most of them away, but a liveness ping can still arrive. Acknowledge them and move on: an error for an unfamiliar event type counts as a failed delivery, and an endpoint that keeps failing is paused and, after three days, switched off.
Verify requests
To ensure that the requests you're getting at your webhook endpoint are actually coming from Kavitro, Kavitro sends an X-Kavitro-Signature header containing an HMAC-SHA256 of the request body, keyed with your endpoint's secret.
Learn more about validating signature requests.
Retries and failures
Retries
A delivery fails when your endpoint does not answer with a 2xx within 3 seconds: it returns another status, times out, or cannot be reached at all. Kavitro then retries it, waiting longer after each failure so a struggling endpoint is not overwhelmed. Each wait below is the delay after the preceding attempt fails. So if the original delivery fails, the first retry follows 10 seconds later; if that also fails, the next comes a minute after it, and so on.
| Retry | Wait before it |
|---|---|
| 1 | 10 seconds |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 and later | 2 hours |
How long an event keeps being retried depends on whether the problem is the event or the endpoint:
- Your endpoint has accepted other deliveries since the event occurred. Then it is this one event that fails, and it is given up after 6 attempts: the original delivery plus 5 retries. This is the only way an event is dropped while the webhook stays switched on.
- Nothing has got through since. Then the endpoint is down rather than rejecting one event, and its events are not dropped. They keep being retried every 2 hours, and are held while deliveries are paused, until the endpoint recovers or the webhook is switched off.
Pauses
After 25 failed attempts in a row, counted across all events rather than per event, Kavitro pauses deliveries to the endpoint for 15 minutes. During a pause:
- No events are sent. New ones are held for the endpoint, not dropped.
- When the 15 minutes are up, one waiting event is sent to see whether the endpoint is back. If it fails, the pause starts over.
- Once a delivery gets through, the pause ends and the held events are delivered, oldest first, one at a time. Their retry count starts again, because failures during an outage say nothing about the events themselves.
A pause needs nothing from you. The webhook's delivery health shows when it ends (circuitOpenUntil) and how many events are waiting (pendingDeliveries).
Warning and switch-off
When an endpoint has been failing for two days without a single successful delivery, Kavitro warns the account owner by email and with an in-app notification that the webhook is about to be switched off.
After three days of failures, the webhook is switched off: isEnabled becomes false, disabledAt records when, and the account owner is notified by email and in the app. The events still waiting for the endpoint are discarded. Three days of backlog is worth less than a fresh look at the records themselves, which the API gives you at any time.
The check runs every two hours, so either step can come up to two hours after those marks.
To bring a switched-off webhook back, fix the endpoint and set isEnabled back to true, in Settings > Integrations or with updateWebhook. That clears the failure count, any pause and the switch-off, so the endpoint starts from a clean slate. The discarded events are not resent, so reconcile through the API to catch up.
Liveness ping
Before warning the account owner, and again before switching the webhook off, Kavitro sends your endpoint one system.ping event. That way neither happens to an endpoint that has quietly recovered while no events were due. The ping is signed and sent like any other delivery, with the same 3-second timeout:
[
{
"id": "7f0c3a52-6c8e-4f1e-9d2b-3f6a1e0b9c41",
"type": "system.ping",
"data": [],
"occurredAt": "2026-09-22T10:00:00.000000Z"
}
]
A 2xx answer counts as a successful delivery: the failure count resets, any pause ends, the waiting events are delivered, and no warning or switch-off follows. Any other answer, or none within 3 seconds, means the endpoint is still failing.
There is nothing to process in a ping. Verify the signature and answer with a 2xx. The ping appears in the delivery logs under its own type.
Event types
An event's type is the name of the resource and the action, joined with a dot: task.created, contact.updated, file.deleted.
Actions
Every resource in the table below emits the same four actions.
| Action | Sent when |
|---|---|
created | The record is created. |
updated | The record is changed. See the exception below. |
deleted | The record is deleted. Deletes are reversible, so this is not the end. |
restored | A previously deleted record is restored. |
completed | Only for dbMirror: an account database mirror has finished. |
No updated event is sent when the save touched a rich-text description or terms field. This applies whenever one of those fields is among the changes, not only when it is the only change - so editing a description, on its own or alongside other fields, produces no webhook at all.
Resources
| Resource | Resource | Resource |
|---|---|---|
act | invoice | reading |
actType | letter | specification |
contact | letterSource | specificationType |
contract | letterType | status |
contractType | meter | structure |
coordination | meterManufacturer | submission |
coordinationStage | meterModel | submissionSource |
coordinationType | meterNominalValue | submissionType |
easement | priceList | tag |
easementType | product | task |
eSigning | productGroup | taskType |
expense | project | taxRate |
file | property | template |
governanceDocument | quote | typeGroup |
governanceDocumentType |
Two events do not follow the pattern, and neither carries a data.object: dbMirror.completed is sent when a database mirror finishes, and system.ping is the liveness ping sent to an endpoint that has been failing for days.
Each resource is also a value of the WebhookEventResource enum, and each action a value of WebhookEventAction. The enum value is the same name in upper snake case: contractType is CONTRACT_TYPE, dbMirror is DB_MIRROR, created is CREATED. You use them to choose the events an endpoint receives.
Choosing events
An endpoint receives every event the account produces until you narrow it down. Each webhook keeps its own list of subscriptions, so different endpoints can listen to different events.
A subscription names one resource and, optionally, one action:
| Subscription | Receives |
|---|---|
{ resource: CONTRACT, action: CREATED } | contract.created only |
{ resource: CONTRACT } | Every action of the resource: contract.created, contract.updated, contract.deleted and contract.restored |
| No subscriptions at all | Every event, including resources added in the future |
- There is no wildcard across resources. To receive every
deletedevent, list each resource with theDELETEDaction. - The action has to be one the resource raises.
DB_MIRRORonly raisesCOMPLETED, and no other resource does, so{ resource: CONTRACT, action: COMPLETED }is rejected with a validation error. - An empty list means every event, the same as no subscriptions. There is no way to subscribe to nothing; switch the webhook off with
isEnabled: falseinstead. - Liveness pings are always sent.
system.pingis not an event you subscribe to, so an endpoint receives it whatever it is subscribed to.
Events an endpoint is not subscribed to are not sent and not recorded: they do not show in its delivery logs and do not count towards pendingDeliveries. A change to the subscriptions applies to events raised from then on. Events already waiting for the endpoint are still delivered.
In Settings > Integrations, pick the events when you add a webhook, or open an existing one and tick them in its Events card: a row per module, a column per action. Through the API, pass events to createWebhook or updateWebhook, as shown below.
Narrowing the subscriptions keeps unwanted traffic off your endpoint entirely. Some resources change in bulk, so an endpoint that only cares about invoices does not have to acknowledge thousands of structure.updated events after a large sync.
Managing webhooks through the API
Endpoints do not have to be set up by hand. The same registration you can do under Settings > Integrations is available as ordinary mutations, which is what you want if you provision endpoints per environment or per customer.
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
id
url
secret
isEnabled
events {
resource
action
}
}
}
{
"input": {
"name": "Production listener",
"url": "https://example.com/kavitro/webhooks",
"isEnabled": true,
"events": [
{ "resource": "INVOICE" },
{ "resource": "CONTRACT", "action": "CREATED" },
{ "resource": "CONTRACT", "action": "UPDATED" }
]
}
}
url is the only required field. The secret is generated for you and can be read back at any time, so you never have to copy it out of the interface by hand.
events is optional: leave it out, or pass null or an empty list, to receive every event. See choosing events. Reading events back returns the subscriptions as saved, or null when the webhook receives every event.
To change the subscriptions later, pass the whole new list to updateWebhook. It replaces the previous list rather than adding to it, and "events": null subscribes the webhook to every event again. Leaving events out of the input keeps the current subscriptions.
updateWebhook, deleteWebhook and restoreWebhook round out the set, and webhooks lists what an account has registered.
Setting isEnabled: false stops deliveries without deleting the endpoint or its secret. It is not a pause, though: events still waiting for the endpoint are discarded, nothing is recorded while it is off, and switching it back on does not resend what it missed. Deleting a webhook discards its waiting events the same way, and restoring it does not bring them back.
Delivery health
Each webhook carries the state of its deliveries, so one query tells you whether anything is wrong:
query WebhookHealth($id: ID!) {
webhook(id: $id) {
isEnabled
consecutiveFailures
failingSince
lastSuccessAt
circuitOpenUntil
pendingDeliveries
failureWarningSentAt
disabledAt
disabledReason
}
}
| Field | Meaning |
|---|---|
consecutiveFailures | Failed delivery attempts in a row, across all events, since the last success. |
failingSince | When the current run of failures started. Empty while the endpoint is healthy. |
lastSuccessAt | The last time your endpoint accepted a delivery or a liveness ping. |
circuitOpenUntil | While this is in the future, deliveries are paused until then. |
pendingDeliveries | How many events are waiting for the endpoint, including one that may be in flight. |
failureWarningSentAt | When the account owner was warned that the webhook is about to be switched off. |
disabledAt | When Kavitro switched the webhook off. Not set when you switch it off yourself. |
disabledReason | Why Kavitro switched it off. Currently always consecutive_failures. |
A healthy endpoint has consecutiveFailures at 0 and no failingSince. The fields are read-only and maintained by Kavitro. Switching a webhook back on clears the failure count, any pause, the warning and the switch-off.
Delivery logs
Every delivery attempt is recorded, and the log is queryable. Together with delivery health, this is where to look when an endpoint appears to be missing events:
query WebhookLogs($id: ID!) {
webhook(id: $id) {
url
logs(first: 25, orderBy: [{ column: CREATED_AT, order: DESC }]) {
edges {
node {
eventId
type
attempt
statusCode
message
createdAt
}
}
}
}
}
attempt is which try of the event an entry records, and every try of one event shares its eventId, so an entry with a high attempt is a delivery that has been retried. statusCode and message record what your endpoint answered. When no answer came back at all (a timeout, or a DNS, connection or TLS failure) there is no real status to record: the entry shows 404, and message describes the failure. Filter on them with where to pull out just the failures:
logs(first: 25, where: { column: STATUS_CODE, operator: GTE, value: 400 }) {
edges {
node {
type
attempt
statusCode
message
}
}
}
Liveness pings are logged as well, under the system.ping type. Events the webhook is not subscribed to are never sent, so they do not appear here: when an event you expected is missing from the log, check the webhook's events first. Events held during a pause appear only once they are actually sent, so a log that has gone quiet does not mean nothing happened: check circuitOpenUntil and pendingDeliveries in the webhook's delivery health.
Logs are retained for 90 days.
Limitations
These are properties of the system, not things you can configure around. Each one has a corresponding habit in best practices.
- Delivery is "at least once". An endpoint may receive the same event more than once, so handlers need to be idempotent. Duplicates share the same
id. - Delivery is not guaranteed. Events are held through outages and pauses, but they are discarded when the webhook is switched off (by Kavitro after three days of failures, or by you) or deleted, and an event your endpoint keeps rejecting while accepting others is given up after 6 attempts. Webhooks alone are therefore not a complete record of what happened.
- A failing endpoint is switched off. Three days of failures without a single success switch the webhook off until someone switches it back on. See warning and switch-off.
- Order is not guaranteed. Events can arrive out of sequence: a failed event waits out its retry while newer ones go ahead. Use each event's
occurredAtrather than arrival order. - Not every change raises an event. A save that touches a
descriptionortermsfield produces noupdatedevent. See event types. - Subscriptions are per resource. You can narrow an endpoint to particular resources and actions, but not filter on a record's contents, and there is no wildcard across resources. See choosing events.
- Events carry no field values.
data.objectis a pointer to the record; query the API for its contents. - Timing is typical, not contractual. Notifications usually arrive well under 60 seconds after the event, but that is not a guarantee. Events reach an endpoint one at a time, so a burst of changes takes longer to arrive in full, and a paused endpoint receives nothing until the pause ends.
- Logs are kept for 90 days. Delivery logs are permanently deleted after that and cannot be recovered.