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…"
  }
}
StatusCodeMeaning
400validation_error, bad_requestSomething in the request needs fixing. details says what.
401invalid_api_keyNo key, a revoked key, or a typo.
403insufficient_scopeThe key doesn’t have that permission.
403upgrade_requiredThe business’s plan doesn’t include the API.
404not_foundNot found, or it belongs to another business.
429rate_limitedToo 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

NameInAbout
statusqueryOne or more statuses, comma separated.
estimatorIdquery
emailqueryOnly leads with this email address (any capitals).
createdAfterquery
updatedAfterquery
limitquery Default 25.
cursorqueryThe 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

NameInAbout
leadId requiredpath

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

NameInAbout
leadId requiredpath

Body

FieldTypeAbout
statusstring One of new, contacted, quoted, won, lost.
lostReasonstring or nullKept when the status is (or becomes) lost.
finalJobValueinteger or nullIn pence. Kept when the status is (or becomes) won.
tagsstring[]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

NameInAbout
limitquery 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

FieldTypeAbout
urlstringMust use https.
eventsWebhookEvent[]
descriptionstring

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

NameInAbout
webhookId requiredpath

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.

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.

FieldTypeAbout
id requiredstring
createdAt requiredstring
updatedAt requiredstring
status requiredstring One of new, contacted, quoted, won, lost.
name requiredstring
email requiredstring
phonestring or null
postcodestring or null
servicestring or nullThe first job type they chose, e.g. "Tree felling".
estimateLowinteger or nullIn pence.
estimateHighinteger or nullIn pence.
estimateTextstring or null
currencystring
vatIncludedboolean
requiresSiteVisitbooleanTrue when the estimator showed "site visit needed" instead of a price.
answersobject[]
messagestring or nullAnything else they told you.
photoCountintegerPhotos are in the dashboard.
tagsstring[]
assignedTostring or nullName of the team member.
finalJobValueinteger or nullIn pence, once won.
lostReasonstring or null
wonAtstring or null
marketingConsentboolean
sourcestring One of widget, hosted_page, import, api.
sourceUrlstring or nullThe page the estimator was on.
utmobject or null
estimatorIdstring or null
estimatorNamestring or null
dashboardUrlstring