RoughFigure API
Read your leads, update their status and list your estimators from your own systems. Webhooks tell you the moment something happens.
Base URL: https://api.roughfigure.com. Everything is JSON. Amounts are whole pence, so £300 is 30000. Times are ISO 8601 in UTC.
Authentication
Business owners and admins create API keys in the dashboard under Integrations. Each key belongs to one business and has the permissions chosen when it was made. Send it on every request:
Authorization: Bearer tbp_live_…
Keep keys on your server. Anyone with a key can read your customers’ details. If one leaks, revoke it in the dashboard and make a new one.
The API, webhooks and Zapier are part of the Pro plan.
Errors
Errors use the HTTP status and always have this shape:
{
"error": {
"code": "validation_error",
"message": "Some details need fixing",
"details": [
{
"path": "status",
"message": "Choose one of the options."
}
],
"requestId": "6f1c…"
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error, bad_request | Something in the request needs fixing. details says what. |
| 401 | invalid_api_key | No key, a revoked key, or a typo. |
| 403 | insufficient_scope | The key doesn’t have that permission. |
| 403 | upgrade_required | The business’s plan doesn’t include the API. |
| 404 | not_found | Not found, or it belongs to another business. |
| 429 | rate_limited | Too many requests. Wait for the Retry-After seconds. |
Pages and limits
Lists return { "data": [...], "nextCursor": "…" }. To get the next page, pass that value as ?cursor=. On the last page nextCursor is null.
Each key can make 120 requests a minute. Every response has RateLimit-Limit and RateLimit-Remaining headers.
Account
Check a key works.
GET /v1/me
The business and permissions for this key
Example
curl -X GET https://api.roughfigure.com/v1/me \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 200
{
"data": {
"business": {
"id": "string",
"name": "string"
},
"apiKey": {
"name": "string",
"scopes": [
"leads:read"
]
}
}
}
Leads
People who sent their details through your estimator.
GET /v1/leads
List leads, newest first
Needs the leads:read permission.
Parameters
| Name | In | About |
|---|---|---|
status | query | One or more statuses, comma separated. |
estimatorId | query | |
email | query | Only leads with this email address (any capitals). |
createdAfter | query | |
updatedAfter | query | |
limit | query | Default 25. |
cursor | query | The nextCursor from the previous page. |
Example
curl -X GET https://api.roughfigure.com/v1/leads \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 200
{
"data": [
{
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
}
],
"nextCursor": "string"
}
GET /v1/leads/{leadId}
Get one lead
Needs the leads:read permission.
Parameters
| Name | In | About |
|---|---|---|
leadId required | path |
Example
curl -X GET https://api.roughfigure.com/v1/leads/<leadId> \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 200
{
"data": {
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
}
}
PATCH /v1/leads/{leadId}
Update a lead’s status
Needs the leads:write permission. The change shows on the lead’s timeline as made with your key, and sends a lead.updated webhook. Marking a lead won can start a Google review request.
Parameters
| Name | In | About |
|---|---|---|
leadId required | path |
Body
| Field | Type | About |
|---|---|---|
status | string | One of new, contacted, quoted, won, lost. |
lostReason | string or null | Kept when the status is (or becomes) lost. |
finalJobValue | integer or null | In pence. Kept when the status is (or becomes) won. |
tags | string[] | Replaces the lead’s tags. |
Example
curl -X PATCH https://api.roughfigure.com/v1/leads/<leadId> \
-H "Content-Type: application/json" \
-d '{"status":"won","finalJobValue":42000}' \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 200
{
"data": {
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
}
}
Estimators
GET /v1/estimators
List estimators
Needs the estimators:read permission. Archived estimators are left out.
Example
curl -X GET https://api.roughfigure.com/v1/estimators \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 200
{
"data": [
{
"id": "string",
"name": "string",
"status": "draft",
"language": "en-GB",
"publicKey": "string",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z"
}
]
}
Site visits
Visits customers or your team booked for a lead.
GET /v1/bookings
List site visits, newest booking first
Each item has the same shape as the booking.created webhook. Needs the leads:read permission.
Parameters
| Name | In | About |
|---|---|---|
limit | query | Default 25. |
Example
curl -X GET https://api.roughfigure.com/v1/bookings \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 200
{
"data": [
{
"booking": {
"id": "string",
"startsAt": "2026-10-27T09:30:00.000Z",
"endsAt": "2026-10-27T09:30:00.000Z",
"status": "confirmed",
"bookedBy": "homeowner",
"timeZone": "Europe/London"
},
"lead": {
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
}
}
]
}
Webhook subscriptions
Add and remove webhooks from code (this is how Zapier connects). Webhooks added here can also be seen in the dashboard.
POST /v1/webhooks
Add a webhook
Needs the leads:read permission. The response includes the signing secret; it isn’t shown again here (admins can reveal it in the dashboard).
Body
| Field | Type | About |
|---|---|---|
url | string | Must use https. |
events | WebhookEvent[] | |
description | string |
Example
curl -X POST https://api.roughfigure.com/v1/webhooks \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","events":["lead.created"],"description":"string"}' \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 201
{
"data": {
"id": "string",
"url": "string",
"events": [
"lead.created"
],
"secret": "whsec_…"
}
}
DELETE /v1/webhooks/{webhookId}
Remove a webhook
A key can only remove webhooks it added.
Parameters
| Name | In | About |
|---|---|---|
webhookId required | path |
Example
curl -X DELETE https://api.roughfigure.com/v1/webhooks/<webhookId> \
-H "Authorization: Bearer $TRADEBALLPARK_KEY"
Response 204
Removed
Webhooks
Add a webhook in the dashboard (or with the API) and we’ll POST to your URL when something happens. Reply with any 2xx status within 10 seconds.
- Retries. Anything else is tried again up to 7 more times over about 4 hours (after 1, 2, 4, 8, 16, 32 and 64 minutes). Every attempt is in the dashboard’s delivery log, where you can also resend.
- Repeats. The
RoughFigure-Deliveryheader (and the body’sid) is the same on every retry. Ignore one you’ve already handled. - Stop. Reply
410 Goneand we’ll remove the webhook. - Order. Events usually arrive in order, but not always. Use
lead.updatedAtto keep the newest.
Checking the signature
Each webhook has a signing secret (whsec_…). We send RoughFigure-Signature: t=<unix time>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body>. Check it against the raw body, before parsing the JSON, and reject times more than 5 minutes old:
import { createHmac, timingSafeEqual } from 'node:crypto';
function isFromRoughFigure(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
“Send test” in the dashboard sends a ping event so you can try this out.
lead.created
New lead
{
"id": "string",
"type": "lead.created",
"createdAt": "2026-10-27T09:30:00.000Z",
"data": {
"lead": {
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
}
}
}lead.updated
Lead changed (status, value, tags or who it’s assigned to)
{
"id": "string",
"type": "lead.updated",
"createdAt": "2026-10-27T09:30:00.000Z",
"data": {
"lead": {
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
},
"changes": [
"status"
]
}
}booking.created
Site visit booked
{
"id": "string",
"type": "booking.created",
"createdAt": "2026-10-27T09:30:00.000Z",
"data": {
"booking": {
"id": "string",
"startsAt": "2026-10-27T09:30:00.000Z",
"endsAt": "2026-10-27T09:30:00.000Z",
"status": "confirmed",
"bookedBy": "homeowner",
"timeZone": "Europe/London"
},
"lead": {
"id": "cmh1x2y3z0000abcd",
"createdAt": "2026-10-27T09:30:00.000Z",
"updatedAt": "2026-10-27T09:30:00.000Z",
"status": "new",
"name": "James Carter",
"email": "[email protected]",
"phone": "+447700900123",
"postcode": "SW1A 1AA",
"service": "Tree felling",
"estimateLow": 30000,
"estimateHigh": 45000,
"estimateText": "£300 – £450",
"currency": "GBP",
"vatIncluded": false,
"requiresSiteVisit": false,
"answers": [
{
"question": "How tall is the tree?",
"answer": "Small (under 5m)"
}
],
"message": "string",
"photoCount": 0,
"tags": [
"string"
],
"assignedTo": "string",
"finalJobValue": 0,
"lostReason": "string",
"wonAt": "2026-10-27T09:30:00.000Z",
"marketingConsent": false,
"source": "widget",
"sourceUrl": "string",
"utm": {},
"estimatorId": "string",
"estimatorName": "string",
"dashboardUrl": "https://example.com"
}
}
}Zapier
The RoughFigure Zapier app uses this API. Connect it with an API key that can read leads and change lead status. It can start a Zap when a new lead arrives, and update a lead’s status from another app.
The lead object
The same shape in API responses and webhooks.
| Field | Type | About |
|---|---|---|
id required | string | |
createdAt required | string | |
updatedAt required | string | |
status required | string | One of new, contacted, quoted, won, lost. |
name required | string | |
email required | string | |
phone | string or null | |
postcode | string or null | |
service | string or null | The first job type they chose, e.g. "Tree felling". |
estimateLow | integer or null | In pence. |
estimateHigh | integer or null | In pence. |
estimateText | string or null | |
currency | string | |
vatIncluded | boolean | |
requiresSiteVisit | boolean | True when the estimator showed "site visit needed" instead of a price. |
answers | object[] | |
message | string or null | Anything else they told you. |
photoCount | integer | Photos are in the dashboard. |
tags | string[] | |
assignedTo | string or null | Name of the team member. |
finalJobValue | integer or null | In pence, once won. |
lostReason | string or null | |
wonAt | string or null | |
marketingConsent | boolean | |
source | string | One of widget, hosted_page, import, api. |
sourceUrl | string or null | The page the estimator was on. |
utm | object or null | |
estimatorId | string or null | |
estimatorName | string or null | |
dashboardUrl | string |