Grovia Developer API
Connect your website, forms and other systems to Grovia. Send leads and clients in, read invoices and payments out, and get a signed webhook the moment something changes.
Introduction
The Grovia API is a JSON-over-HTTPS REST API. Every request goes to https://groviaos.com/api/v1 and acts on the one Grovia account that owns the API key. Request bodies are JSON (send Content-Type: application/json); responses are JSON with snake_case field names and dates in ISO 8601 UTC (for example 2026-11-11T06:30:12.345Z).
What v1 covers:
- Leads — list, read, create and update.
- Clients — list, read and create.
- Invoices — list and read. Invoices are read-only in v1: they carry GST and feed your GST returns and Tally export, so they are created in the Grovia app.
- Payments received against invoices — list.
- Webhooks for new leads, lead changes, won leads, new clients, new invoices, paid invoices and payments.
Import it: the full API is described in an OpenAPI 3.1 file (works with Postman, Insomnia, code generators and AI tools), and there is a ready-made Postman collection — set its apiKey variable to your key and every request is ready to send.
Every object carries an object field ("lead", "client", "invoice" or "payment") and an id. Ids are opaque strings; store them as text.
Authentication
An owner or admin of a Grovia account (or a team member given the Integrations permission) creates API keys in Grovia → Settings → API & Webhooks. Give the key a name (the app that will use it) and choose its permissions (scopes). The key is shown once, when it is created — Grovia stores only a hash of it, so copy it then. An account can have up to 20 active keys. A key can be revoked at any time from the same screen; a revoked key stops working at once.
Keys look like gro_live_ followed by 32 letters and digits. Send the key on every request, preferably as a bearer token:
Authorization: Bearer gro_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXThe header X-Api-Key: gro_live_… is also accepted. Keep keys on your server; never put them in a web page or a mobile app.
Scopes
Read and write are separate, so a reporting tool can be given read-only access. A request that needs a scope the key does not have gets 403 insufficient_scope.
| Scope | Allows |
|---|---|
leads:read | Read leads |
leads:write | Create and update leads |
clients:read | Read clients |
clients:write | Create clients |
invoices:read | Read invoices and payments |
anyCheck that a key works: which account it belongs to and which scopes it has. Any valid key with at least one scope may call it.
curl https://groviaos.com/api/v1/me \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"object": "api_key",
"account": {
"id": "cm0x1y2z30000ab12cd34ef56",
"name": "Mehta Consulting"
},
"scopes": [
"leads:read",
"leads:write",
"clients:read"
]
}Errors
Success is a 2xx status. Every error has a non-2xx status and the same body shape: a machine-readable code and a sentence you can show or log.
{
"error": {
"code": "not_found",
"message": "No lead with that id in this account."
}
}| Status | code | When |
|---|---|---|
| 400 | invalid_request | The body is not valid JSON or a field fails validation. The message names each field and the problem. |
| 400 | invalid_status | The status filter is not one of the allowed values. |
| 400 | invalid_date | A date filter (updated_since or since) is not an ISO date. |
| 400 | invalid_cursor | starting_after is not a cursor from a previous page. |
| 400 | invalid_owner | owner_user_id is not an active member of the account. |
| 401 | unauthorized | No key, a malformed key, or a key that is not valid or has been revoked. |
| 403 | account_inactive | The Grovia account that owns the key is not active. |
| 403 | insufficient_scope | The key does not have the scope this endpoint needs. |
| 404 | not_found | No record with that id in this account. |
| 422 | invalid_contact | A lead could not be created — for example it has neither a usable phone number nor a usable email address. |
| 429 | rate_limited | Too many requests. See Rate limits. |
| 500 | server_error | Something went wrong on Grovia's side. Try again. |
Rate limits
Each key may make 120 requests a minute. Past that, requests get 429 rate_limited with a Retry-After header giving the number of seconds to wait. Wait that long, then retry.
HTTP/1.1 429 Too Many Requests
Retry-After: 37
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Too many requests — the limit is 120 a minute per key."
}
}Every authenticated request counts, including ones refused for a missing scope. To keep data in sync, prefer webhooks or the updated_since filter over re-reading everything.
Pagination
List endpoints return records newest first, a page at a time. Control the page with two query parameters:
| Parameter | Description |
|---|---|
limit | How many records per page: 1–100. Default 25. A value outside 1–100 (or not a whole number) is treated as 25. |
starting_after | A cursor: the id of the last record you received. Pass the next_cursor from the previous response. |
Every list response has the same shape:
{
"data": [
"…records…"
],
"has_more": true,
"next_cursor": "cm3k9x2ab0001qz8f7h2d4e6t"
}When has_more is true, request the next page with starting_after=<next_cursor>. When it is false, next_cursor is null and you have everything.
cursor=""
while :; do
page=$(curl -s "https://groviaos.com/api/v1/leads?limit=100${cursor:+&starting_after=$cursor}" \
-H "Authorization: Bearer $GROVIA_API_KEY")
echo "$page" | jq -c '.data[]'
[ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break
cursor=$(echo "$page" | jq -r '.next_cursor')
doneLeads
A lead is an enquiry from a prospective customer.
The lead object
| Field | Type | Description |
|---|---|---|
id | string | Unique id. |
object | string | Always "lead". |
name | string | The person's name. |
email | string | null | Email address, lower-cased. |
phone | string | null | Phone number with country code, e.g. +919812345678. |
subject | string | null | Short subject line. |
message | string | What they asked for. |
source | string | null | Where the lead came from, as shown in Grovia. |
channel | string | null | How it was captured, e.g. api, form, whatsapp, email. null for leads added by hand. |
status | string | One of new, contacted, qualified, proposal, proposal_sent, negotiation, won, lost, spam. |
expected_value | number | null | Expected deal value in the account's currency. |
client_id | string | null | The client this lead is linked to, if any. |
owner_user_id | string | null | The team member who owns the lead. |
won_at | string | null | When the lead was marked won. |
created_at | string | When the lead was created. |
updated_at | string | When the lead last changed. |
leads:readList leads, newest first.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | string | No | Only leads in this status (one of the values above). |
updated_since | ISO date | No | Only leads changed at or after this time, e.g. 2026-11-01T00:00:00Z. |
limit | integer | No | 1–100. Default 25. Any other value is treated as 25. |
starting_after | string | No | The next_cursor from the previous page. |
curl "https://groviaos.com/api/v1/leads?status=new&limit=2" \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"data": [
{
"id": "cm3k9x2ab0001qz8f7h2d4e6t",
"object": "lead",
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "+919812345678",
"subject": "Website contact form enquiry",
"message": "Need a quote for 20 users.",
"source": "Website contact form",
"channel": "api",
"status": "new",
"expected_value": null,
"client_id": null,
"owner_user_id": "cm1a2b3c40000ab12cd34ef56",
"won_at": null,
"created_at": "2026-11-11T06:30:12.345Z",
"updated_at": "2026-11-11T06:30:12.345Z"
}
],
"has_more": false,
"next_cursor": null
}leads:readRead one lead. Returns 404 not_found if there is no lead with that id in the account.
curl https://groviaos.com/api/v1/leads/cm3k9x2ab0001qz8f7h2d4e6t \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"id": "cm3k9x2ab0001qz8f7h2d4e6t",
"object": "lead",
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "+919812345678",
"subject": "Website contact form enquiry",
"message": "Need a quote for 20 users.",
"source": "Website contact form",
"channel": "api",
"status": "new",
"expected_value": null,
"client_id": null,
"owner_user_id": "cm1a2b3c40000ab12cd34ef56",
"won_at": null,
"created_at": "2026-11-11T06:30:12.345Z",
"updated_at": "2026-11-11T06:30:12.345Z"
}leads:writeCreate a lead. It goes through the same path as every other enquiry that reaches Grovia: it is given an owner, the owner is alerted, and any automatic handling the account has switched on (sorting, replies, follow-ups) applies.
Body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–120 characters. |
email | string | No | Up to 200 characters. An address that does not look valid is ignored. |
phone | string | No | Up to 30 characters. Must have 8–15 digits to be used. Include the country code with +; a bare 10-digit Indian mobile number gets +91 added. |
message | string | No | Up to 5,000 characters. Default "Connected app enquiry". |
source | string | No | Up to 120 characters, e.g. "Website contact form". Shown in Grovia as the lead's source; the subject becomes "<source> enquiry". Default "Connected app". |
external_id | string | No | Up to 120 characters. Your own id for this enquiry. See Idempotency. |
email or phone must be usable, or the request fails with 422 invalid_contact.Responses
- 201 — a new lead was created. The body is the lead with
"duplicate": false. - 200 — the same person (same email or phone) already has an open lead created in the last 30 days. No new lead is made: the new message is added to that lead's notes and its owner is alerted. The body is the existing lead with
"duplicate": true. The same response is returned when anexternal_idis sent again.
curl https://groviaos.com/api/v1/leads \
-H "Authorization: Bearer $GROVIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "9812345678",
"message": "Need a quote for 20 users.",
"source": "Website contact form",
"external_id": "form-submission-88412"
}'{
"id": "cm3k9x2ab0001qz8f7h2d4e6t",
"object": "lead",
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "+919812345678",
"subject": "Website contact form enquiry",
"message": "Need a quote for 20 users.",
"source": "Website contact form",
"channel": "api",
"status": "new",
"expected_value": null,
"client_id": null,
"owner_user_id": "cm1a2b3c40000ab12cd34ef56",
"won_at": null,
"created_at": "2026-11-11T06:30:12.345Z",
"updated_at": "2026-11-11T06:30:12.345Z",
"duplicate": false
}leads:writeUpdate a lead. Send only the fields you want to change; any other field is refused with 400 invalid_request. Returns the updated lead, or 404 not_found.
Body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
status | string | No | One of new, contacted, qualified, proposal, proposal_sent, negotiation, won, lost, spam. Moving to won sets won_at, exactly as the app does. Any status other than new stops Grovia's automatic follow-ups for the lead. |
notes | string | null | No | Up to 10,000 characters. Replaces the lead's internal notes. Notes are not returned by the API. |
expected_value | number | null | No | 0 to 1,000,000,000,000. |
owner_user_id | string | null | No | Must be an active member of the account, otherwise 400 invalid_owner. null removes the owner. |
A change of status or owner sends the lead.updated webhook, and a move to won also sends lead.won.
curl -X PATCH https://groviaos.com/api/v1/leads/cm3k9x2ab0001qz8f7h2d4e6t \
-H "Authorization: Bearer $GROVIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "qualified", "expected_value": 250000 }'{
"id": "cm3k9x2ab0001qz8f7h2d4e6t",
"object": "lead",
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "+919812345678",
"subject": "Website contact form enquiry",
"message": "Need a quote for 20 users.",
"source": "Website contact form",
"channel": "api",
"status": "qualified",
"expected_value": 250000,
"client_id": null,
"owner_user_id": "cm1a2b3c40000ab12cd34ef56",
"won_at": null,
"created_at": "2026-11-11T06:30:12.345Z",
"updated_at": "2026-11-11T09:02:44.120Z"
}Clients
A client is a customer you bill.
The client object
| Field | Type | Description |
|---|---|---|
id | string | Unique id. |
object | string | Always "client". |
name | string | Contact name. |
email | string | null | Email address. |
phone | string | null | Phone number. |
company | string | null | Company name. |
gstin | string | null | GST number. |
address | string | null | Street address. |
city | string | null | City. |
state | string | null | State. |
country | string | null | Country. |
created_at | string | When the client was created. |
updated_at | string | When the client last changed. |
clients:readList clients, newest first.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
email | string | No | Only the client with this email address (not case-sensitive). Useful to look a client up before creating one. |
updated_since | ISO date | No | Only clients changed at or after this time. |
limit | integer | No | 1–100. Default 25. Any other value is treated as 25. |
starting_after | string | No | The next_cursor from the previous page. |
curl "https://groviaos.com/api/v1/clients?email=accounts@mehtatraders.in" \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"data": [
{
"id": "cm3kb71qe0004qz8fh3n2k9vw",
"object": "client",
"name": "Rahul Mehta",
"email": "accounts@mehtatraders.in",
"phone": "+919876543210",
"company": "Mehta Traders Pvt Ltd",
"gstin": "29ABCDE1234F1Z5",
"address": "12, 3rd Cross, Indiranagar",
"city": "Bengaluru",
"state": "Karnataka",
"country": "India",
"created_at": "2026-11-11T07:15:03.981Z",
"updated_at": "2026-11-11T07:15:03.981Z"
}
],
"has_more": false,
"next_cursor": null
}clients:readRead one client. Returns 404 not_found if there is no client with that id in the account.
curl https://groviaos.com/api/v1/clients/cm3kb71qe0004qz8fh3n2k9vw \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"id": "cm3kb71qe0004qz8fh3n2k9vw",
"object": "client",
"name": "Rahul Mehta",
"email": "accounts@mehtatraders.in",
"phone": "+919876543210",
"company": "Mehta Traders Pvt Ltd",
"gstin": "29ABCDE1234F1Z5",
"address": "12, 3rd Cross, Indiranagar",
"city": "Bengaluru",
"state": "Karnataka",
"country": "India",
"created_at": "2026-11-11T07:15:03.981Z",
"updated_at": "2026-11-11T07:15:03.981Z"
}clients:writeCreate a client. An account has one client per email address.
Body (JSON)
| Name | Type | Required | Description |
|---|---|---|---|
name | string | Yes | 1–200 characters. |
email | string | Yes | A valid email address, up to 200 characters. Stored lower-cased. |
phone | string | No | Up to 30 characters. |
company | string | No | Up to 200 characters. |
gstin | string | No | Exactly 15 capital letters and digits. |
address | string | No | Up to 500 characters. |
city | string | No | Up to 100 characters. |
state | string | No | Up to 100 characters. |
country | string | No | Up to 100 characters. Default "India". |
Responses
- 201 — the client was created. The body is the new client.
- 200 — a client with this email already exists in the account. Nothing is created or changed; the body is the existing client with
"duplicate": true.
curl https://groviaos.com/api/v1/clients \
-H "Authorization: Bearer $GROVIA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Rahul Mehta",
"email": "accounts@mehtatraders.in",
"phone": "+919876543210",
"company": "Mehta Traders Pvt Ltd",
"gstin": "29ABCDE1234F1Z5",
"address": "12, 3rd Cross, Indiranagar",
"city": "Bengaluru",
"state": "Karnataka"
}'{
"id": "cm3kb71qe0004qz8fh3n2k9vw",
"object": "client",
"name": "Rahul Mehta",
"email": "accounts@mehtatraders.in",
"phone": "+919876543210",
"company": "Mehta Traders Pvt Ltd",
"gstin": "29ABCDE1234F1Z5",
"address": "12, 3rd Cross, Indiranagar",
"city": "Bengaluru",
"state": "Karnataka",
"country": "India",
"created_at": "2026-11-11T07:15:03.981Z",
"updated_at": "2026-11-11T07:15:03.981Z"
}{
"id": "cm3kb71qe0004qz8fh3n2k9vw",
"object": "client",
"name": "Rahul Mehta",
"email": "accounts@mehtatraders.in",
"phone": "+919876543210",
"company": "Mehta Traders Pvt Ltd",
"gstin": "29ABCDE1234F1Z5",
"address": "12, 3rd Cross, Indiranagar",
"city": "Bengaluru",
"state": "Karnataka",
"country": "India",
"created_at": "2026-11-11T07:15:03.981Z",
"updated_at": "2026-11-11T07:15:03.981Z",
"duplicate": true
}Invoices
The invoice object
| Field | Type | Description |
|---|---|---|
id | string | Unique id. |
object | string | Always "invoice". |
number | string | The invoice number as printed. |
status | string | One of draft, sent, paid, partial, cancelled, overdue. |
client_id | string | The client billed. |
currency | string | Currency code. Default INR. |
invoice_date | string | The invoice date. |
due_date | string | null | When payment is due. |
paid_at | string | null | When it was paid in full. |
taxable_value | number | Value before tax, after discounts. |
tax | number | Total GST. |
round_off | number | Rounding adjustment. 0 when none. |
total | number | What the invoice comes to: taxable_value + tax + round_off. The same figure as the invoice PDF. |
amount_paid | number | Sum of the amount of every payment recorded against it. |
tds_deducted | number | TDS your customer deducted across those payments. It settles the invoice just as cash does. |
balance_due | number | total − amount_paid − tds_deducted, never below 0. |
lines | array | The line items: description, quantity, unit_price, gst_rate (percent, e.g. 18), discount_percent and discount_amount (each null when not used). |
created_at | string | When the invoice was created. |
updated_at | string | When the invoice last changed. |
Amounts are rounded to 2 decimal places.
invoices:readList invoices, newest first.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
status | string | No | Only invoices in this status: draft, sent, paid, partial, cancelled or overdue. |
client_id | string | No | Only invoices for this client. |
updated_since | ISO date | No | Only invoices changed at or after this time. |
limit | integer | No | 1–100. Default 25. Any other value is treated as 25. |
starting_after | string | No | The next_cursor from the previous page. |
curl "https://groviaos.com/api/v1/invoices?client_id=cm3kb71qe0004qz8fh3n2k9vw&limit=1" \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"data": [
{
"id": "cm3kc2p8z0007qz8f1m5x0a2b",
"object": "invoice",
"number": "INV-2026-0142",
"status": "partial",
"client_id": "cm3kb71qe0004qz8fh3n2k9vw",
"currency": "INR",
"invoice_date": "2026-11-05T10:20:00.000Z",
"due_date": "2026-11-20T00:00:00.000Z",
"paid_at": null,
"taxable_value": 17400,
"tax": 3132,
"round_off": 0,
"total": 20532,
"amount_paid": 10000,
"tds_deducted": 0,
"balance_due": 10532,
"lines": [
{
"description": "Website maintenance — November",
"quantity": 1,
"unit_price": 15000,
"gst_rate": 18,
"discount_percent": null,
"discount_amount": null
},
{
"description": "Domain renewal",
"quantity": 2,
"unit_price": 1200,
"gst_rate": 18,
"discount_percent": null,
"discount_amount": null
}
],
"created_at": "2026-11-05T10:20:00.000Z",
"updated_at": "2026-11-09T12:41:18.402Z"
}
],
"has_more": true,
"next_cursor": "cm3kc2p8z0007qz8f1m5x0a2b"
}invoices:readRead one invoice with its lines, total, amount paid and balance. Returns 404 not_found if there is no invoice with that id in the account.
curl https://groviaos.com/api/v1/invoices/cm3kc2p8z0007qz8f1m5x0a2b \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"id": "cm3kc2p8z0007qz8f1m5x0a2b",
"object": "invoice",
"number": "INV-2026-0142",
"status": "partial",
"client_id": "cm3kb71qe0004qz8fh3n2k9vw",
"currency": "INR",
"invoice_date": "2026-11-05T10:20:00.000Z",
"due_date": "2026-11-20T00:00:00.000Z",
"paid_at": null,
"taxable_value": 17400,
"tax": 3132,
"round_off": 0,
"total": 20532,
"amount_paid": 10000,
"tds_deducted": 0,
"balance_due": 10532,
"lines": [
{
"description": "Website maintenance — November",
"quantity": 1,
"unit_price": 15000,
"gst_rate": 18,
"discount_percent": null,
"discount_amount": null
},
{
"description": "Domain renewal",
"quantity": 2,
"unit_price": 1200,
"gst_rate": 18,
"discount_percent": null,
"discount_amount": null
}
],
"created_at": "2026-11-05T10:20:00.000Z",
"updated_at": "2026-11-09T12:41:18.402Z"
}Payments
Payments received against invoices. Read-only.
The payment object
| Field | Type | Description |
|---|---|---|
id | string | Unique id. |
object | string | Always "payment". |
invoice_id | string | The invoice it was paid against. |
amount | number | Amount received. |
tds_amount | number | Tax deducted at source by the customer. 0 when none. |
method | string | How it was paid, as recorded in Grovia, e.g. bank_transfer. |
reference | string | null | Payment reference, e.g. a UTR or cheque number. |
paid_at | string | The date the payment was made. |
created_at | string | When it was recorded in Grovia. |
invoices:readList payments, newest paid_at first.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
invoice_id | string | No | Only payments against this invoice. |
since | ISO date | No | Only payments with paid_at at or after this time. |
limit | integer | No | 1–100. Default 25. Any other value is treated as 25. |
starting_after | string | No | The next_cursor from the previous page. |
curl "https://groviaos.com/api/v1/payments?invoice_id=cm3kc2p8z0007qz8f1m5x0a2b" \
-H "Authorization: Bearer $GROVIA_API_KEY"{
"data": [
{
"id": "cm3kd9w1r000aqz8f6t4y2c8n",
"object": "payment",
"invoice_id": "cm3kc2p8z0007qz8f1m5x0a2b",
"amount": 10000,
"tds_amount": 0,
"method": "bank_transfer",
"reference": "UTR 412345678901",
"paid_at": "2026-11-09T00:00:00.000Z",
"created_at": "2026-11-09T12:41:18.402Z"
}
],
"has_more": false,
"next_cursor": null
}Webhooks
A webhook tells your server when something happens in Grovia, so you do not have to keep asking. An owner or admin adds one in Grovia → Settings → API & Webhooks: the address to call and the events to send. Grovia then shows the webhook's signing secret (it starts with whsec_); you need it to check that a request really came from Grovia. An account can have up to 25 webhooks switched on. Apps can also subscribe themselves with an API key — see Subscribing through the API.
Address rules
https://only, on the standard port (443; 8443 is also accepted).- It must resolve to a public internet address. Private networks,
localhostand similar addresses are refused, both when you save it and on every delivery. - No username or password in the address.
Events
| Event | Sent when | data |
|---|---|---|
lead.created | A new lead or enquiry arrives | lead object |
lead.updated | A lead's stage (status) or owner changes | lead object plus previous |
lead.won | A lead is marked won | lead object |
client.created | A client is added | client object |
invoice.created | An invoice is created | invoice object |
invoice.paid | A payment (with any TDS) brings an invoice's balance_due to 0 | invoice object |
payment.received | A payment is recorded against an invoice | payment object |
Events are sent for records created any way — on screen, by import, by the mobile apps, by an online payment, by the API — usually within about a minute. A webhook only receives events for records created after it was added: adding one never sends you old history. To load existing records, use the list endpoints.
The request
Grovia sends a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Grovia-Webhooks/1.0 |
Grovia-Event | The event type, e.g. lead.updated. |
Grovia-Delivery | An id for this delivery to this webhook. It stays the same when the delivery is retried. |
Grovia-Signature | t=<unix seconds>,v1=<hex signature>. See below. |
The body is { id, type, created_at, data }. data is the same object the API returns. For example:
{
"id": "evt_5f2c8a91d03e4b7a6c1f0e92",
"type": "lead.updated",
"created_at": "2026-11-11T09:02:44.480Z",
"data": {
"id": "cm3k9x2ab0001qz8f7h2d4e6t",
"object": "lead",
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "+919812345678",
"subject": "Website contact form enquiry",
"message": "Need a quote for 20 users.",
"source": "Website contact form",
"channel": "api",
"status": "qualified",
"expected_value": 250000,
"client_id": null,
"owner_user_id": "cm1a2b3c40000ab12cd34ef56",
"won_at": null,
"created_at": "2026-11-11T06:30:12.345Z",
"updated_at": "2026-11-11T09:02:44.120Z",
"previous": {
"status": "new",
"owner_user_id": "cm1a2b3c40000ab12cd34ef56"
}
}
}lead.updated adds previous: the status and owner_user_id before the change.
{
"id": "evt_payment_cm3kd9w1r000aqz8f6t4y2c8n",
"type": "payment.received",
"created_at": "2026-11-09T12:41:19.021Z",
"data": {
"id": "cm3kd9w1r000aqz8f6t4y2c8n",
"object": "payment",
"invoice_id": "cm3kc2p8z0007qz8f1m5x0a2b",
"amount": 10000,
"tds_amount": 0,
"method": "bank_transfer",
"reference": "UTR 412345678901",
"paid_at": "2026-11-09T00:00:00.000Z",
"created_at": "2026-11-09T12:41:18.402Z"
}
}Treat event ids as opaque strings. The Send test event button sends a made-up example of the webhook's first chosen event — the real type and data shape, plus "test": true — so no-code tools can map its fields. Skip events with test: true if you don't want them stored. A test is signed the same way and is not retried.
Responding, timeouts and retries
- Answer with any 2xx status to confirm receipt. Anything else counts as a failure.
- Grovia waits up to 10 seconds for an answer. Reply quickly and do slow work afterwards.
- Redirects are not followed — a 3xx is a failure. Use the final address.
- A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours — eight attempts in all. After the last one it is given up.
- A webhook that keeps failing for 3 days with no success in between is switched off, and the account's owners are told. Fix the receiving side, then switch it back on in Settings.
Subscribing through the API
Automation platforms (Zapier, Make, Pabbly and your own apps) can add and remove their own webhooks with an API key, the way "REST hooks" work: subscribe when an automation is switched on, unsubscribe when it is switched off. The key needs the read scope of each event's data — lead.* needs leads:read, client.created needs clients:read, invoice.* and payment.received need invoices:read.
read scope of each eventBody: url (public https), events (one or more), optional description. The response includes the secret — the only time it is shown.
curl -X POST "https://groviaos.com/api/v1/webhooks" -H "Authorization: Bearer $GROVIA_API_KEY" -H "Content-Type: application/json" -d '{"url":"https://example.com/grovia-webhook","events":["lead.created"]}'{
"id": "cm3kwh0000001qz8fabc12345",
"object": "webhook",
"url": "https://example.com/grovia-webhook",
"events": [
"lead.created"
],
"enabled": true,
"description": "Added through the API",
"disabled_reason": null,
"created_at": "2026-11-11T06:30:12.345Z",
"secret": "whsec_…"
}anyUnsubscribe. The webhook is switched off rather than erased, so its delivery history stays visible in Settings → API & Webhooks. Calling it twice is harmless. GET /api/v1/webhooks and GET /api/v1/webhooks/:id list and read webhooks (never their secrets).
Verifying the signature
Check every request before trusting it. The Grovia-Signature header holds a timestamp t (unix seconds, set when the attempt was sent) and v1, the hex-encoded HMAC-SHA256 of the string <t>.<raw body> using your webhook secret as the key.
- Read the raw request body exactly as received — not re-encoded JSON.
- Split the header on
,and taketandv1. - Reject the request if
tis more than 5 minutes away from your clock. This stops an old request being replayed. - Compute HMAC-SHA256(secret,
t + "." + raw body) as hex. - Compare it with
v1using a constant-time comparison.
// Node.js (Express). Verify against the RAW request body, before JSON parsing.
const crypto = require("crypto");
const express = require("express");
const SECRET = process.env.GROVIA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 5 * 60;
function verifyGroviaSignature(rawBody, header, secret) {
if (!header) return false;
const parts = {};
for (const piece of header.split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = parts.t;
const v1 = parts.v1;
if (!/^\d+$/.test(t || "") || !/^[0-9a-f]{64}$/.test(v1 || "")) return false;
// Reject anything older (or further in the future) than 5 minutes.
const age = Math.floor(Date.now() / 1000) - Number(t);
if (Math.abs(age) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".")
.update(rawBody) // Buffer of the exact bytes received
.digest();
const received = Buffer.from(v1, "hex");
return received.length === expected.length && crypto.timingSafeEqual(expected, received);
}
const app = express();
app.post("/grovia/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyGroviaSignature(req.body, req.get("Grovia-Signature"), SECRET)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate by event.id — the same event can arrive more than once.
// ... handle event.type / event.data ...
res.sendStatus(200);
});# Python 3 (Flask). Verify against the RAW request body, before JSON parsing.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["GROVIA_WEBHOOK_SECRET"] # whsec_…
TOLERANCE_SECONDS = 5 * 60
def verify_grovia_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = {}
for piece in (header or "").split(","):
key, sep, value = piece.strip().partition("=")
if sep:
parts[key] = value
t = parts.get("t", "")
v1 = parts.get("v1", "")
if not t.isdigit() or len(v1) != 64:
return False
# Reject anything older (or further in the future) than 5 minutes.
if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = Flask(__name__)
@app.post("/grovia/webhooks")
def grovia_webhook():
raw = request.get_data() # exact bytes received
if not verify_grovia_signature(raw, request.headers.get("Grovia-Signature", ""), SECRET):
abort(400)
event = json.loads(raw)
# Deduplicate by event["id"] — the same event can arrive more than once.
# ... handle event["type"] / event["data"] ...
return "", 200<?php
// PHP 7.4+. Verify against the RAW request body, before json_decode.
const TOLERANCE_SECONDS = 300; // 5 minutes
function verify_grovia_signature(string $rawBody, string $header, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $piece) {
$kv = explode('=', trim($piece), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if (!ctype_digit($t) || strlen($v1) !== 64) {
return false;
}
// Reject anything older (or further in the future) than 5 minutes.
if (abs(time() - (int) $t) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1); // constant-time
}
$secret = getenv('GROVIA_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_GROVIA_SIGNATURE'] ?? '';
if (!verify_grovia_signature($raw, $header, $secret)) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($raw, true);
// Deduplicate by $event['id'] — the same event can arrive more than once.
// ... handle $event['type'] / $event['data'] ...
http_response_code(200);Idempotency
Creating leads: external_id
If your system might send the same enquiry twice (a retry after a timeout, a re-run job), send your own id for it as external_id. The first request creates the lead. A later request with the same external_id from the same API key creates nothing and returns 200 with the original lead and "duplicate": true.
Separately, Grovia never makes a second open lead for the same person: a new enquiry from an email or phone number that already has an open lead from the last 30 days is added to that lead (also 200, "duplicate": true).
Creating clients
Clients are unique by email within an account. Creating one with an email that already exists returns the existing client with 200 and "duplicate": true, so a retried create is safe.
Webhooks: deduplicate by event id
Webhooks are delivered at least once. A retry resends the same event with the same id — for example when your server processed it but answered too slowly. Store the id of each event you handle and ignore one you have already seen. Do not rely on events arriving in order; use updated_at or read the record from the API when order matters.
Changelog
v1 — November 2026
- First release.
- API keys with scopes, 120 requests a minute per key, cursor pagination.
- Leads: list, read, create, update. Clients: list, read, create. Invoices: list, read. Payments: list.
- Webhook subscriptions through the API (
/api/v1/webhooks) for automation platforms. - Signed webhooks for
lead.created,lead.updated,lead.won,client.created,invoice.created,invoice.paidandpayment.received.
Questions or something missing? Contact us.