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.

https://groviaos.com/api/v1

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_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

The 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.

ScopeAllows
leads:readRead leads
leads:writeCreate and update leads
clients:readRead clients
clients:writeCreate clients
invoices:readRead invoices and payments
GET/api/v1/meScope: any

Check 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.

Request
curl https://groviaos.com/api/v1/me \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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."
  }
}
StatuscodeWhen
400invalid_requestThe body is not valid JSON or a field fails validation. The message names each field and the problem.
400invalid_statusThe status filter is not one of the allowed values.
400invalid_dateA date filter (updated_since or since) is not an ISO date.
400invalid_cursorstarting_after is not a cursor from a previous page.
400invalid_ownerowner_user_id is not an active member of the account.
401unauthorizedNo key, a malformed key, or a key that is not valid or has been revoked.
403account_inactiveThe Grovia account that owns the key is not active.
403insufficient_scopeThe key does not have the scope this endpoint needs.
404not_foundNo record with that id in this account.
422invalid_contactA lead could not be created — for example it has neither a usable phone number nor a usable email address.
429rate_limitedToo many requests. See Rate limits.
500server_errorSomething 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:

ParameterDescription
limitHow many records per page: 1–100. Default 25. A value outside 1–100 (or not a whole number) is treated as 25.
starting_afterA 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.

Fetch every lead (bash)
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')
done

Leads

A lead is an enquiry from a prospective customer.

The lead object

FieldTypeDescription
idstringUnique id.
objectstringAlways "lead".
namestringThe person's name.
emailstring | nullEmail address, lower-cased.
phonestring | nullPhone number with country code, e.g. +919812345678.
subjectstring | nullShort subject line.
messagestringWhat they asked for.
sourcestring | nullWhere the lead came from, as shown in Grovia.
channelstring | nullHow it was captured, e.g. api, form, whatsapp, email. null for leads added by hand.
statusstringOne of new, contacted, qualified, proposal, proposal_sent, negotiation, won, lost, spam.
expected_valuenumber | nullExpected deal value in the account's currency.
client_idstring | nullThe client this lead is linked to, if any.
owner_user_idstring | nullThe team member who owns the lead.
won_atstring | nullWhen the lead was marked won.
created_atstringWhen the lead was created.
updated_atstringWhen the lead last changed.
GET/api/v1/leadsScope: leads:read

List leads, newest first.

Query parameters

NameTypeRequiredDescription
statusstringNoOnly leads in this status (one of the values above).
updated_sinceISO dateNoOnly leads changed at or after this time, e.g. 2026-11-01T00:00:00Z.
limitintegerNo1–100. Default 25. Any other value is treated as 25.
starting_afterstringNoThe next_cursor from the previous page.
Request
curl "https://groviaos.com/api/v1/leads?status=new&limit=2" \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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
}
GET/api/v1/leads/:idScope: leads:read

Read one lead. Returns 404 not_found if there is no lead with that id in the account.

Request
curl https://groviaos.com/api/v1/leads/cm3k9x2ab0001qz8f7h2d4e6t \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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"
}
POST/api/v1/leadsScope: leads:write

Create 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)

NameTypeRequiredDescription
namestringYes1–120 characters.
emailstringNoUp to 200 characters. An address that does not look valid is ignored.
phonestringNoUp 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.
messagestringNoUp to 5,000 characters. Default "Connected app enquiry".
sourcestringNoUp 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_idstringNoUp to 120 characters. Your own id for this enquiry. See Idempotency.
At least one of 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 an external_id is sent again.
Request
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"
  }'
Response 201
{
  "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
}
PATCH/api/v1/leads/:idScope: leads:write

Update 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)

NameTypeRequiredDescription
statusstringNoOne 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.
notesstring | nullNoUp to 10,000 characters. Replaces the lead's internal notes. Notes are not returned by the API.
expected_valuenumber | nullNo0 to 1,000,000,000,000.
owner_user_idstring | nullNoMust 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.

Request
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 }'
Response 200
{
  "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

FieldTypeDescription
idstringUnique id.
objectstringAlways "client".
namestringContact name.
emailstring | nullEmail address.
phonestring | nullPhone number.
companystring | nullCompany name.
gstinstring | nullGST number.
addressstring | nullStreet address.
citystring | nullCity.
statestring | nullState.
countrystring | nullCountry.
created_atstringWhen the client was created.
updated_atstringWhen the client last changed.
GET/api/v1/clientsScope: clients:read

List clients, newest first.

Query parameters

NameTypeRequiredDescription
emailstringNoOnly the client with this email address (not case-sensitive). Useful to look a client up before creating one.
updated_sinceISO dateNoOnly clients changed at or after this time.
limitintegerNo1–100. Default 25. Any other value is treated as 25.
starting_afterstringNoThe next_cursor from the previous page.
Request
curl "https://groviaos.com/api/v1/clients?email=accounts@mehtatraders.in" \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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
}
GET/api/v1/clients/:idScope: clients:read

Read one client. Returns 404 not_found if there is no client with that id in the account.

Request
curl https://groviaos.com/api/v1/clients/cm3kb71qe0004qz8fh3n2k9vw \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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"
}
POST/api/v1/clientsScope: clients:write

Create a client. An account has one client per email address.

Body (JSON)

NameTypeRequiredDescription
namestringYes1–200 characters.
emailstringYesA valid email address, up to 200 characters. Stored lower-cased.
phonestringNoUp to 30 characters.
companystringNoUp to 200 characters.
gstinstringNoExactly 15 capital letters and digits.
addressstringNoUp to 500 characters.
citystringNoUp to 100 characters.
statestringNoUp to 100 characters.
countrystringNoUp 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.
Request
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"
  }'
Response 201
{
  "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"
}
Response 200 (already exists)
{
  "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

Invoices are read-only in v1. They carry GST and feed GST returns and the Tally export, so they are created and edited in the Grovia app.

The invoice object

FieldTypeDescription
idstringUnique id.
objectstringAlways "invoice".
numberstringThe invoice number as printed.
statusstringOne of draft, sent, paid, partial, cancelled, overdue.
client_idstringThe client billed.
currencystringCurrency code. Default INR.
invoice_datestringThe invoice date.
due_datestring | nullWhen payment is due.
paid_atstring | nullWhen it was paid in full.
taxable_valuenumberValue before tax, after discounts.
taxnumberTotal GST.
round_offnumberRounding adjustment. 0 when none.
totalnumberWhat the invoice comes to: taxable_value + tax + round_off. The same figure as the invoice PDF.
amount_paidnumberSum of the amount of every payment recorded against it.
tds_deductednumberTDS your customer deducted across those payments. It settles the invoice just as cash does.
balance_duenumbertotal − amount_paid − tds_deducted, never below 0.
linesarrayThe line items: description, quantity, unit_price, gst_rate (percent, e.g. 18), discount_percent and discount_amount (each null when not used).
created_atstringWhen the invoice was created.
updated_atstringWhen the invoice last changed.

Amounts are rounded to 2 decimal places.

GET/api/v1/invoicesScope: invoices:read

List invoices, newest first.

Query parameters

NameTypeRequiredDescription
statusstringNoOnly invoices in this status: draft, sent, paid, partial, cancelled or overdue.
client_idstringNoOnly invoices for this client.
updated_sinceISO dateNoOnly invoices changed at or after this time.
limitintegerNo1–100. Default 25. Any other value is treated as 25.
starting_afterstringNoThe next_cursor from the previous page.
Request
curl "https://groviaos.com/api/v1/invoices?client_id=cm3kb71qe0004qz8fh3n2k9vw&limit=1" \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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"
}
GET/api/v1/invoices/:idScope: invoices:read

Read 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.

Request
curl https://groviaos.com/api/v1/invoices/cm3kc2p8z0007qz8f1m5x0a2b \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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

FieldTypeDescription
idstringUnique id.
objectstringAlways "payment".
invoice_idstringThe invoice it was paid against.
amountnumberAmount received.
tds_amountnumberTax deducted at source by the customer. 0 when none.
methodstringHow it was paid, as recorded in Grovia, e.g. bank_transfer.
referencestring | nullPayment reference, e.g. a UTR or cheque number.
paid_atstringThe date the payment was made.
created_atstringWhen it was recorded in Grovia.
GET/api/v1/paymentsScope: invoices:read

List payments, newest paid_at first.

Query parameters

NameTypeRequiredDescription
invoice_idstringNoOnly payments against this invoice.
sinceISO dateNoOnly payments with paid_at at or after this time.
limitintegerNo1–100. Default 25. Any other value is treated as 25.
starting_afterstringNoThe next_cursor from the previous page.
Request
curl "https://groviaos.com/api/v1/payments?invoice_id=cm3kc2p8z0007qz8f1m5x0a2b" \
  -H "Authorization: Bearer $GROVIA_API_KEY"
Response 200
{
  "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, localhost and similar addresses are refused, both when you save it and on every delivery.
  • No username or password in the address.

Events

EventSent whendata
lead.createdA new lead or enquiry arriveslead object
lead.updatedA lead's stage (status) or owner changeslead object plus previous
lead.wonA lead is marked wonlead object
client.createdA client is addedclient object
invoice.createdAn invoice is createdinvoice object
invoice.paidA payment (with any TDS) brings an invoice's balance_due to 0invoice object
payment.receivedA payment is recorded against an invoicepayment 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:

HeaderValue
Content-Typeapplication/json
User-AgentGrovia-Webhooks/1.0
Grovia-EventThe event type, e.g. lead.updated.
Grovia-DeliveryAn id for this delivery to this webhook. It stays the same when the delivery is retried.
Grovia-Signaturet=<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:

lead.updated
{
  "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.

payment.received
{
  "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.

POST/api/v1/webhooksScope: read scope of each event

Body: url (public https), events (one or more), optional description. The response includes the secret — the only time it is shown.

Request
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"]}'
Response 201
{
  "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_…"
}
DELETE/api/v1/webhooks/:idScope: any

Unsubscribe. 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.

  1. Read the raw request body exactly as received — not re-encoded JSON.
  2. Split the header on , and take t and v1.
  3. Reject the request if t is more than 5 minutes away from your clock. This stops an old request being replayed.
  4. Compute HMAC-SHA256(secret, t + "." + raw body) as hex.
  5. Compare it with v1 using a constant-time comparison.
Node.js
// 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
# 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
// 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.paid and payment.received.

Questions or something missing? Contact us.