Developers
The OnPoint API and webhooks
Read and add customers, sites and appointments, read quotes, invoices, payments and certificates, and get told when something happens, usually within a minute. It works with Zapier, Make or your own code.
Overview
The API is JSON over HTTPS. Every request is made with an API key that belongs to one organisation and carries only the permissions it was given. The base URL is:
https://www.onpointbusiness.co.uk/api/v1Webhooks are the other half: OnPoint sends a signed HTTPS POST to an address you choose when a quote is accepted, an invoice is paid, a job is completed and so on. Both are set up in Settings → API & webhooks, by anyone who can manage integrations.
Authentication
Create a key in Settings → API & webhooks. It starts op_live_ and is shown once. OnPoint only keeps a fingerprint of it, so a lost key can't be recovered: revoke it and create a new one. Send it in the Authorization header:
curl https://www.onpointbusiness.co.uk/api/v1/me \
-H "Authorization: Bearer op_live_…"
{
"object": "api_key",
"name": "Zapier",
"scopes": ["contacts:write", "appointments:write", "sales:read"],
"organization": { "id": "org_6z8jrxzdzhx8552", "name": "Patel Heating Ltd" }
}Treat keys like passwords: keep them on your server, never in a web page or an app someone else can open. A revoked key stops working straight away. A key only reaches what the person who created it can still see in OnPoint, and stops working if they're removed from the account or suspended. If your OnPoint subscription lapses, the API stops along with the dashboard (402 subscription_inactive) and starts again when you renew.
Permissions
Each key has a set of scopes. A …:write scope includes reading the same records. Calling an endpoint without its scope returns 403 permission_denied.
| Data | Read | Change | What it covers |
|---|---|---|---|
| Customers | contacts:read | contacts:write | Names, phone numbers, emails and billing addresses |
| Sites | sites:read | sites:write | Property addresses and tenant details |
| Appointments | appointments:read | appointments:write | Jobs in the diary |
| Quotes, invoices and job sheets | sales:read | Read only | Documents with their line items and totals |
| Payments | payments:read | Read only | Money received against invoices |
| Certificates | certificates:read | Read only | Type, reference and dates, not the certificate itself |
Data conventions
- Ids are strings, even where they look like numbers. Keep them as strings.
- Timestamps (
created_at,starts_at…) are ISO 8601 in UTC. Appointmentdate,start_timeandend_timeare the UK wall-clock times you see in OnPoint (Europe/London);starts_atandends_atare the same moments in UTC, with British Summer Time already applied. - Money is a decimal string with two places, like
"1234.50", never a float. Every object that has money has acurrency(GBP, orEURfor businesses set up in euros). - A value that isn't set is
null, not an empty string. - Deleted records aren't returned. A cancelled appointment is still returned by
GET /appointments/:idwithstatus: "cancelled"(or"declined"for a booking request that was turned down), but is left out of the list.
Pagination
Lists are newest first and return up to limit items (25 by default, 100 at most). When has_more is true, pass next_cursor back as starting_after to get the next page. Cursors are opaque, so don't build them yourself.
{
"object": "list",
"data": [ { "id": "4821", "object": "contact", … }, … ],
"has_more": true,
"next_cursor": "eyJpIjoiNDc5OSJ9"
}limit | 1–100, default 25. |
starting_after | The next_cursor from the previous page. |
created_after | Only records created after this ISO 8601 date or timestamp, e.g. 2026-09-01 or 2026-09-01T09:00:00Z. |
updated_after | Only records changed after this time. Customers, sites, appointments and certificates only. |
let cursor = null;
do {
const url = new URL("https://www.onpointbusiness.co.uk/api/v1/contacts");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("starting_after", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.ONPOINT_KEY}` } });
const page = await res.json();
for (const contact of page.data) { /* … */ }
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);Errors
Every error has the same shape. Use type in code; message is for people.
HTTP/1.1 403 Forbidden
{
"error": {
"type": "permission_denied",
"message": "This API key doesn't have the contacts:write permission."
}
}400 invalid_request | A parameter or field is missing, unknown or invalid. The message says which. |
401 authentication_failed | No key, a malformed key, an unknown key or a revoked one. |
402 subscription_inactive | The organisation's subscription has lapsed. |
403 permission_denied | The key doesn't have the scope this endpoint needs. |
404 not_found | No such record in this organisation. |
429 rate_limited | Too many requests. Wait for the number of seconds in Retry-After. |
500 server_error | Something went wrong on our side. Safe to retry reads. |
503 not_available | The API isn't switched on for this account yet. |
Unknown fields in a request body are refused rather than ignored, so a typo like firstname gets an error instead of quietly creating a customer with no name.
Rate limits
Each key can make 3,600 requests an hour. Over that, requests get 429 rate_limited with a Retry-After header in seconds. Need more? Use updated_after to fetch only what changed, or let webhooks tell you instead of polling.
Endpoints
Your key
The organisation and scopes of the key making the request. Needs no scope.
Team
Active members who can be assigned to appointments: user_id, name, role. Use a user_id in an appointment's assigned_to.
Customers
Filter the list with email (exact address) to find a customer before creating one.
{
"id": "4821",
"object": "contact",
"first_name": "Sarah",
"last_name": "Patel",
"business_name": null,
"customer_type": "consumer",
"email": "sarah.patel@example.com",
"emails": ["sarah.patel@example.com"],
"phone": "+447700900123",
"phones": ["+447700900123"],
"address": {
"line1": "14 Park Road", "line2": null, "line3": null,
"town": "Leeds", "county": null, "postcode": "LS6 2AB"
},
"notes": null,
"source": "Website",
"tax_number": null,
"created_at": "2026-09-12T09:31:04.000Z",
"updated_at": "2026-09-12T09:31:04.000Z"
}Creating and changing. Send any of first_name, last_name, business_name, customer_type (consumer or business), email or emails, phone or phones, address (line1, line2, line3, town, county, postcode), notes, source, tax_number. A customer needs a first name, last name or business name. Emails are lowercased, UK phone numbers are stored as +44… and postcodes are formatted (ls62ab → LS6 2AB), the same as when you add them in OnPoint. On PATCH, fields you leave out are unchanged and null clears one; setting email replaces every stored address.
curl -X POST https://www.onpointbusiness.co.uk/api/v1/contacts \
-H "Authorization: Bearer op_live_…" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Sarah",
"last_name": "Patel",
"email": "sarah.patel@example.com",
"phone": "07700 900123",
"address": { "line1": "14 Park Road", "town": "Leeds", "postcode": "ls62ab" },
"source": "Website"
}'Sites
A site is a property a customer owns or manages. Filter the list by contact_id. To create one send contact_id, an address with at least line1 or postcode, and optionally an occupant (first_name, last_name, email, phone).
{
"id": "915",
"object": "site",
"contact_id": "4821",
"address": {
"line1": "Flat 3, 22 Hyde Terrace", "line2": null, "line3": null,
"town": "Leeds", "county": null, "postcode": "LS2 9LN"
},
"occupant": { "first_name": "Tom", "last_name": "Reed", "email": null, "phone": "+447700900456" },
"created_at": "2026-09-12T09:40:11.000Z",
"updated_at": "2026-09-12T09:40:11.000Z"
}Appointments
Filter the list with date_from and date_to (inclusive, YYYY-MM-DD) and contact_id. status is requested, confirmed, completed, cancelled or declined.
{
"id": "48213",
"object": "appointment",
"title": "Annual boiler service",
"description": "Side gate code 4471",
"status": "confirmed",
"date": "2026-10-01",
"end_date": "2026-10-01",
"start_time": "09:00",
"end_time": "10:30",
"all_day": false,
"timezone": "Europe/London",
"starts_at": "2026-10-01T08:00:00.000Z",
"ends_at": "2026-10-01T09:30:00.000Z",
"arrival_window": { "start": "08:00", "end": "10:00" },
"contact_id": "4821",
"site_id": "915",
"customer_name": "Sarah Patel",
"address": { "line1": "Flat 3, 22 Hyde Terrace", "line2": null, "postcode": "LS2 9LN" },
"assigned_to": [{ "user_id": "2b7c1f4e-9f5a-4c1e-8a57-3f0c2d9b1e11", "name": "Dan Hughes" }],
"completed_at": null,
"cancelled_at": null,
"url": "https://www.onpointbusiness.co.uk/dashboard/calendar?appointment=48213",
"created_at": "2026-09-12T10:02:45.000Z",
"updated_at": "2026-09-12T10:02:45.000Z"
}Booking. title, contact_id, date, start_time and end_time (24-hour UK time) are required; description, site_id (a site of that customer), end_date (for a job that runs past midnight), arrival_window and assigned_to (up to three user ids from GET /team) are optional. The end can't be before the start on the same day. The customer's name and address are copied onto the job from the customer and site, exactly as when the office books it. Assigned engineers get the usual notification, and the job's Activity shows it was booked by your key.
curl -X POST https://www.onpointbusiness.co.uk/api/v1/appointments \
-H "Authorization: Bearer op_live_…" \
-H "Content-Type: application/json" \
-d '{
"title": "Annual boiler service",
"contact_id": "4821",
"site_id": "915",
"date": "2026-10-01",
"start_time": "09:00",
"end_time": "10:30",
"arrival_window": { "start": "08:00", "end": "10:00" },
"assigned_to": ["2b7c1f4e-9f5a-4c1e-8a57-3f0c2d9b1e11"]
}'PATCH takes the same fields to reschedule, reassign or edit. Completing and cancelling are done in OnPoint, where the paperwork happens. You'll hear about both through webhooks.
Quotes, invoices and job sheets
Read-only. Each document comes with its line_items. Filter lists by contact_id and created_after. The document number OnPoint prints is the id.
Quote statuses: draft, sent, accepted, declined, not_chosen. Invoice statuses: draft, sent, part_paid, overdue, paid, written_off. balance_due is the total less any CIS deduction, plus late charges, less what's been paid or written off.
Quotes with options. A quote can offer choices (Good / Better / Best) and optional extras. options lists them with the one currently selected, and total is always the total of the current choice. Each line says whether it's included right now. Lines of an option that isn't chosen, and extras that aren't ticked, are included: false and aren't in any total. Don't add up lines yourself; use the document's totals.
{
"id": "1758901234567",
"object": "quote",
"number": "1758901234567",
"reference": "Boiler swap",
"title": null,
"contact_id": "4821",
"site_id": "915",
"issue_date": "2026-09-20",
"currency": "GBP",
"subtotal": "2250.00",
"vat": "450.00",
"total": "2700.00",
"reverse_charge": false,
"reverse_charge_vat": null,
"cis_deduction": null,
"status": "accepted",
"sent": true,
"options": [
{ "key": "opt_a1", "name": "Good", "description": "Repair", "recommended": false, "selected": false },
{ "key": "opt_b2", "name": "Best", "description": "New combi", "recommended": true, "selected": true }
],
"selected_option": "opt_b2",
"line_items": [
{ "description": "Remove old boiler", "quantity": 1, "unit_price": "250.00", "vat_rate": 20,
"net": "250.00", "total": "300.00", "included": true, "option": null, "optional": false },
{ "description": "Replace fan and PCB", "quantity": 1, "unit_price": "400.00", "vat_rate": 20,
"net": "400.00", "total": "480.00", "included": false, "option": "opt_a1", "optional": false },
{ "description": "Worcester 30i combi, fitted", "quantity": 1, "unit_price": "2000.00", "vat_rate": 20,
"net": "2000.00", "total": "2400.00", "included": true, "option": "opt_b2", "optional": false },
{ "description": "Smart thermostat", "quantity": 1, "unit_price": "180.00", "vat_rate": 20,
"net": "180.00", "total": "216.00", "included": false, "option": null, "optional": true }
],
"converted_to": null,
"url": "https://www.onpointbusiness.co.uk/dashboard/sales/quotes/view/?quoteId=1758901234567",
"created_at": "2026-09-20T14:11:09.000Z",
"updated_at": "2026-09-22T08:30:51.000Z"
}{
"id": "1758990000001",
"object": "invoice",
"number": "1758990000001",
"status": "part_paid",
"sent": true,
"issue_date": "2026-09-25",
"due_date": "2026-10-25",
"currency": "GBP",
"subtotal": "2250.00",
"vat": "450.00",
"total": "2700.00",
"charges": "0.00",
"amount_paid": "1000.00",
"amount_written_off": "0.00",
"balance_due": "1700.00",
"refunded": false,
"converted_from": "1758901234567",
"line_items": [ … ],
…
}Payments
Filter by invoice_id, contact_id or created_after. type is payment (money in against an invoice), on_account (money in with no invoice yet), credit_applied (held credit used on an invoice, not new money), refund (money back out; the amount is negative) or write_off. method is bank_transfer, card, online, cash, cheque, tap_to_pay, other or null.
{
"id": "20931",
"object": "payment",
"type": "payment",
"amount": "1000.00",
"currency": "GBP",
"method": "bank_transfer",
"method_label": "Bank transfer",
"date": "2026-09-26",
"reference": "PATEL DEPOSIT",
"note": null,
"invoice_id": "1758990000001",
"contact_id": "4821",
"created_at": "2026-09-26T11:04:37.000Z"
}Certificates
The details you'd file a certificate by (type, reference, dates, customer and site), not the certificate itself. status is issued once it has been sent to the customer, otherwise draft. Filter by status, contact_id, created_after and updated_after. next_due_date is null for one-off certificates.
{
"id": "1758907777000",
"object": "certificate",
"type": "PAD2",
"type_name": "Landlord Gas Safety Record",
"reference": "1758907777000",
"status": "issued",
"issue_date": "2026-09-26",
"next_due_date": "2027-09-26",
"contact_id": "4821",
"site_id": "915",
"url": "https://www.onpointbusiness.co.uk/dashboard/certificates/gas/pad2?id=1758907777000",
"created_at": "2026-09-26T13:20:02.000Z",
"updated_at": "2026-09-26T13:41:18.000Z"
}Webhooks
Add a webhook in Settings → API & webhooks: an https:// address and the events to send. Use Send test event to check it: you'll get a webhook.test event whose data.object is a made-up example of the webhook's first event, with every field a real one has and "0" for its id. Events are sent within about a minute of happening. While your subscription has lapsed they wait and are sent when you renew. A webhook sends only what the person who added it can still see in OnPoint, and stops if they leave the account.
| Event | When |
|---|---|
contact.created | A customer is added, whether in the app, from a booking, by an import or through the API. |
contact.updated | A customer's details are changed. |
appointment.created | An appointment is added to the diary. |
appointment.updated | An appointment's details change: time, engineer, notes or status. Live location updates while an engineer travels don't count. |
appointment.completed | An appointment is marked complete. |
appointment.cancelled | An appointment is cancelled or deleted, or a booking request is declined. The status says which. |
quote.sent | A quote is sent to the customer for the first time. |
quote.accepted | A quote is accepted, either by the customer from their link or by marking it accepted in the app. |
quote.declined | A quote is declined. |
invoice.created | An invoice is first saved, including one converted from a quote or job sheet. It may still be a draft, so use invoice.sent for the finished invoice. |
invoice.sent | An invoice is sent to the customer for the first time. |
invoice.paid | An invoice is paid in full. |
payment.received | A payment is recorded, whether by card, bank transfer, cash or any other way. |
certificate.issued | A certificate is completed and issued. |
data.object is the same object the API returns for that record: a quote for quote.*, an invoice for invoice.*, and so on. It's built when the event is sent, so it shows the record as it is at that moment. If two changes land in the same minute, both events carry the latest version.
POST /your/webhook/path
Content-Type: application/json
webhook-id: evt_3f9c2b7d8e1a4c5b9d0e2f4a6b8c1d3e
webhook-timestamp: 1790065849
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_3f9c2b7d8e1a4c5b9d0e2f4a6b8c1d3e",
"type": "quote.accepted",
"created_at": "2026-09-22T08:30:49.000Z",
"data": {
"object": { "id": "1758901234567", "object": "quote", "status": "accepted", … }
}
}Reply with any 2xx within 10 seconds. Anything else counts as a failure, including a timeout, an error or a redirect (redirects aren't followed). A failed delivery is tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h. That's 8 tries in all, over about two days. A 410 Gone stops retries for that event straight away. If every delivery to an address fails for three days, the webhook is switched off and your team is told. Switch it back on in settings once it's fixed. You can retry any failed delivery by hand from the same screen.
Deliveries can occasionally arrive twice or out of order. Use webhook-id (the same as the body's id, unchanged on retries) to ignore repeats, and created_at to order them.
Verifying webhooks
Webhooks are signed with the Standard Webhooks scheme, so libraries like standardwebhooks or svix can verify them with no changes. The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body} using your signing secret (Settings → API & webhooks → Reveal). Without a library, in Node:
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string, before JSON.parse).
// headers: the request headers. secret: your whsec_… signing secret.
function verifyOnPointWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = String(headers["webhook-signature"] || "").split(" ");
if (!id || !timestamp) return false;
// Refuse anything older (or newer) than 5 minutes: stops replays.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected =
"v1," + crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
return signatures.some(
(sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}Verify the raw body before you parse it. JSON that has been parsed and re-serialised won't match. Reject timestamps more than 5 minutes from your clock. When you rotate the secret, new events are signed with the new one straight away.
Using Zapier or Make
OnPoint isn't in the Zapier or Make app directories. You connect with their built-in webhook and HTTP tools instead, which do the same job.
Zapier
- When something happens in OnPoint. Start a Zap with Webhooks by Zapier → Catch Hook. Copy the address Zapier gives you, add it as a webhook in OnPoint with the events you want, then press Send test event so Zapier can see the fields.
- To do something in OnPoint. Add an action step Webhooks by Zapier → Custom Request with your API key in the headers.
Method: POST
URL: https://www.onpointbusiness.co.uk/api/v1/contacts
Headers: Authorization | Bearer op_live_…
Content-Type | application/json
Data: {"first_name": "{{first name}}", "last_name": "{{last name}}", "email": "{{email}}"}Webhooks by Zapier is a premium Zapier app, so it needs a paid Zapier plan.
Make
- Trigger. Start a scenario with Webhooks → Custom webhook, copy its address into OnPoint as a webhook, then press Send test event so Make learns the structure.
- Action. Use HTTP → Make a request with the method and URL from this page and a header
Authorization: Bearer op_live_….
Give each tool its own key with only the permissions it needs, so you can revoke one without breaking the others.
Not on OnPoint yet?
Jobs, quotes, invoices and certificates for UK trades, with an API when you need one. Free for 14 days, no card needed.