Getting started
Every request goes to the base URL above, followed by the endpoint's path. The version is part of the path, so a request to /api/public/v1/me always gets version 1.
Get an API key
- An admin of the company signs in to the Helix Field office app and opens Settings > Integrations.
- Under Make a key for, pick Zapier or Your own software and press Make key.
- Copy the key straight away. It is shown once and never again; Helix Field keeps only a fingerprint of it, so a lost key cannot be looked up. Revoke it and make another.
A key belongs to the company, not to a person, and everything it creates is recorded against the key. Keys start with hf_live_. A company can have up to ten keys that are not revoked. The Integrations screen lists each key by its first characters, the day it was made and the day it was last used, and an admin can revoke any of them there. A revoked key stops working at once, and the webhook subscriptions it made are turned off with it.
Make a first call
GET /me answers with the company the key belongs to. It is the call to make to check a key.
curl https://app.helixfielddispatch.com/api/public/v1/me \
-H "Authorization: Bearer $HELIX_API_KEY"
Response 200
{
"company": "Helix Field Demo",
"company_id": "55cc855f-ba9d-482c-bd91-9840b36e753c",
"key_name": "Your own software",
"api_version": "v1",
"events": ["booking.received", "customer.created", "invoice.paid", "job.completed", "job.created"]
}
Authentication
Send the key in one of these two headers on every request:
Authorization: Bearer hf_live_...X-API-Key: hf_live_..., for clients that cannot set the first one
A key in the URL is not read. A request that carries its key only in the query string is answered 401, which keeps keys out of server logs and browser history.
A missing key, a key that does not start with hf_live_, an unknown key and a revoked key all get the same answer, so the response never says which of these it was:
HTTP/1.1 401 Unauthorized
{"detail": "A valid API key is required"}
When the company's Helix Field account is locked (its subscription was canceled or stayed unpaid past the grace period, or the account was closed), every request with its key is answered 402, as the office app is:
HTTP/1.1 402 Payment Required
{"detail": "This company's Helix account is not active"}
Requests and responses
- Bodies are JSON. Send
Content-Type: application/jsonwith a body. Fields this page does not list are ignored. - Ids are UUID strings, such as
"6505eb45-3350-4c08-b6ee-45f2d2cc4319". - Times are ISO 8601, written without an offset and to the second, such as
"2026-10-16T07:52:08". When something happened (created_at,completed_at,occurred_at) is in UTC;created_aton a webhook subscription also carries fractions of a second. A job'sscheduled_dateis the company's own clock time as it was booked, and an invoice'sissued_dateanddue_dateare days on the company's calendar, written at midnight. - Money is a string with two decimals, such as
"185.00", so no amount passes through a floating point number. - Fields with no value are present and
null; lists with nothing in them are[].
Lists
Every list endpoint returns a JSON array, newest first by the time the record was created. limit sets how many come back: 25 when it is left out, at most 100. A number below 1 is read as 1 and a number above 100 as 100; a value that is not a whole number is a 422.
There are no pages and no cursor: a list returns the newest records up to limit. A list never includes records the company has removed.
Errors
An error is a status code and a JSON body with a detail; a 429 is the one exception (see Rate limits). Most details are one sentence written to be shown to a person:
{"detail": "Customer not found"}
When the request itself is malformed (a required field is missing, a field is too long or of the wrong type, or the body is not valid JSON), detail is a list instead, one entry per problem. loc says where the problem is; the value that was sent is never echoed back.
{"detail": [
{"type": "missing", "loc": ["body", "first_name"], "msg": "Field required"}
]}
| Status | When |
|---|---|
401 | The key is missing, malformed, unknown or revoked. |
402 | The company's Helix Field account is locked. |
404 | The customer named in the path or in customer_id is not one of this company's customers, or the path does not exist. |
405 | The path exists but not with this method. |
409 | A customer with that email already exists, or a job was asked for a customer marked "Do not service" at a company that blocks work for them. |
422 | The request is malformed, or a value is not allowed (for example a job_type that is not on the list). |
429 | Too many requests to an endpoint that has a rate limit. See Rate limits. |
Rate limits
The endpoints that write have a limit per minute, counted per calling IP address:
| Endpoint | Limit |
|---|---|
POST /customers | 60 a minute |
PUT /customers/{customer_id}/tags | 60 a minute |
POST /jobs | 60 a minute |
POST /hooks | 30 a minute |
The other endpoints have no limit of their own. A request over the limit is answered 429 with this body, and nothing is saved:
{"error": "Rate limit exceeded: 60 per 1 minute"}
No Retry-After header is sent. Wait for the minute to pass and send the request again.
Endpoints
Paths are relative to https://app.helixfielddispatch.com.
| Endpoint | What it does |
|---|---|
GET /me | The company this key belongs to |
GET /events | The webhook events, each with its description |
GET /customers | List customers, or find one by email |
POST /customers | Add a customer |
PUT /customers/{customer_id}/tags | Replace a customer's tags |
GET /jobs | List jobs |
POST /jobs | Create a job for a customer |
GET /invoices | List invoices |
GET /bookings | List booking requests from the online booking form |
GET /hooks | List webhook subscriptions |
POST /hooks | Subscribe a URL to an event |
DELETE /hooks/{hook_id} | Unsubscribe |
/me
The company and key making the request. Use it to test a key.
| Field | Type | Description |
|---|---|---|
company | string | The company's name in Helix Field |
company_id | string | The company's id |
key_name | string | What the key was made for: "Zapier" or "Your own software" |
api_version | string | Always "v1" on this path |
events | array of strings | Every event a subscription can name, sorted |
The example is under Getting started.
/events
Every webhook event with the sentence the office app shows for it.
Response 200[
{"event": "booking.received", "description": "A booking request arrived from the online form."},
{"event": "customer.created", "description": "A customer was added."},
{"event": "invoice.paid", "description": "An invoice was paid in full."},
{"event": "job.completed", "description": "A job was completed by the crew."},
{"event": "job.created", "description": "A job was created."}
]
/customers
The company's customers, newest first, as customer objects.
| Query | Description |
|---|---|
limit | How many to return: 25 by default, 1 to 100 |
email | Find a customer by email. Spaces around the address are trimmed and it is matched against the address on file in any case. The answer is a list with the customer in it, or an empty list when there is none. |
curl "https://app.helixfielddispatch.com/api/public/v1/customers?email=dana.reyes@example.com" \
-H "Authorization: Bearer $HELIX_API_KEY"
Response 200
[
{
"id": "6505eb45-3350-4c08-b6ee-45f2d2cc4319",
"name": "Dana Reyes",
"first_name": "Dana",
"last_name": "Reyes",
"company_name": null,
"email": "dana.reyes@example.com",
"phone": "405-555-0134",
"customer_type": "commercial",
"tags": ["Spring campaign"],
"created_at": "2026-10-16T07:52:08"
}
]
/customers
Adds a customer and answers 201 with the new customer object. A customer.created webhook is sent for it.
| Field | Type | Description |
|---|---|---|
first_name | string, required | 1 to 100 characters |
last_name | string | Up to 100 characters. Empty when left out. |
company_name | string | Up to 200 characters |
email | string | Up to 255 characters. Saved trimmed and lowercased. It must not belong to another customer of the company in any case, removed customers included; if it does, the answer is 409 with "A customer with that email already exists. Find them by email instead." A customer can be added with no email. |
phone | string | Up to 50 characters |
notes | string | Up to 5,000 characters. Saved on the customer for the office; not returned by the API. |
customer_type | string | residential (when left out), commercial or contractor |
tags | array of strings | Up to 50 tag names, each 1 to 40 characters. A name the company does not have yet is created. |
curl https://app.helixfielddispatch.com/api/public/v1/customers \
-H "Authorization: Bearer $HELIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"first_name": "Dana", "last_name": "Reyes", "email": "Dana.Reyes@example.com",
"phone": "405-555-0134", "customer_type": "commercial", "tags": ["Spring campaign"]}'
Response 201
{
"id": "6505eb45-3350-4c08-b6ee-45f2d2cc4319",
"name": "Dana Reyes",
"first_name": "Dana",
"last_name": "Reyes",
"company_name": null,
"email": "dana.reyes@example.com",
"phone": "405-555-0134",
"customer_type": "commercial",
"tags": ["Spring campaign"],
"created_at": "2026-10-16T07:52:08"
}
/customers/{customer_id}/tags
Replaces the customer's tags with the list sent and answers 200 with the customer object. A tag left out of the list comes off the customer, an empty list removes every tag, and a name the company does not have yet is created. No webhook is sent.
| Field | Type | Description |
|---|---|---|
tags | array of strings | Up to 50 tag names, each 1 to 40 characters |
A customer_id that is not one of the company's customers, or belongs to a removed customer, is answered 404 with "Customer not found".
curl -X PUT https://app.helixfielddispatch.com/api/public/v1/customers/6505eb45-3350-4c08-b6ee-45f2d2cc4319/tags \
-H "Authorization: Bearer $HELIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"tags": ["Net 30", "VIP"]}'
/jobs
The company's jobs, newest first, as job objects.
| Query | Description |
|---|---|
limit | How many to return: 25 by default, 1 to 100 |
status_filter | Only jobs with this status: scheduled, in_progress, completed or cancelled. Any other value returns an empty list. |
curl "https://app.helixfielddispatch.com/api/public/v1/jobs?status_filter=completed&limit=10" \
-H "Authorization: Bearer $HELIX_API_KEY"
/jobs
Creates a job for an existing customer and answers 201 with the new job object. The job's status is scheduled; without a scheduled_date it has no date, and the office books it from Helix Field. A job.created webhook is sent for it. At a company with a single active user, the job is assigned to that person, the same as a job made in the office app, unless the company has turned that off in its Job Settings.
| Field | Type | Description |
|---|---|---|
customer_id | string, required | The customer the job is for |
property_id | string | The customer's property where the work is. When it is left out, the job goes to the customer's only property, or to their primary property when they have several. |
title | string, required | 1 to 255 characters |
description | string | Up to 5,000 characters |
job_type | string | maintenance (when left out), repair, installation or inspection |
scheduled_date | string | An ISO 8601 date, or date and time. A time without an offset is the company's own clock time and is saved as written. A time with an offset, or with Z for UTC, is moved to the company's time zone (Central time unless the company has chosen another): for a company on Central time, "2026-10-20T14:00:00Z" and "2026-10-20T10:00:00-04:00" are both saved as "2026-10-20T09:00:00". A time of exactly midnight UTC, which many tools send for a date with no time, keeps its date and starts at midnight on the company's clock. |
| Answer | When |
|---|---|
404 | "Customer not found": the customer is not one of the company's, or was removed. |
409 | The customer is marked "Do not service" and the company has that mark block new work. There is no override through the API; the office can book the job in Helix Field with a reason. |
422 | "customer_id is not a valid id", "property_id is not a valid id", "Property not found for this customer", "This customer has no property on file yet; add one in Helix first", or "job_type must be maintenance, repair, installation or inspection". |
curl https://app.helixfielddispatch.com/api/public/v1/jobs \
-H "Authorization: Bearer $HELIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customer_id": "6505eb45-3350-4c08-b6ee-45f2d2cc4319", "title": "Spring opening",
"job_type": "maintenance", "scheduled_date": "2026-10-20T09:00:00", "description": "Open the pond"}'
Response 201
{
"id": "6e704563-572e-4fc0-8826-fc5140892025",
"job_number": "JOB-AA107B31",
"title": "Spring opening",
"status": "scheduled",
"job_type": "maintenance",
"service_id": null,
"service_name": null,
"customer_id": "6505eb45-3350-4c08-b6ee-45f2d2cc4319",
"customer_name": "Dana Reyes",
"property_id": "4bf224ef-3402-4fec-97d8-628fc57245fc",
"scheduled_date": "2026-10-20T09:00:00",
"scheduled_start_time": null,
"completed_at": null
}
/invoices
The company's invoices, newest first, as invoice objects.
| Query | Description |
|---|---|
limit | How many to return: 25 by default, 1 to 100 |
status_filter | Only invoices with this status: draft, sent, paid or overdue. Any other value returns an empty list. |
[
{
"id": "6c01eb96-503e-4e40-b0ec-c588505b5f97",
"invoice_number": "INV-1001",
"status": "draft",
"customer_id": "6505eb45-3350-4c08-b6ee-45f2d2cc4319",
"customer_name": "Dana Reyes",
"job_id": null,
"total": "185.00",
"paid_amount": "0.00",
"paid_date": null,
"issued_date": "2026-10-16T00:00:00",
"due_date": "2026-11-15T00:00:00"
}
]
/bookings
Booking requests sent through the company's online booking form, newest first, as booking request objects. Takes limit: 25 by default, 1 to 100. Every request is listed whether the office has accepted it, declined it, marked it as spam or not answered it yet; the object does not say which.
/hooks
The company's webhook subscriptions that are switched on, oldest first, made through any of its keys, as subscription objects. secret is always null here.
/hooks
Subscribes a URL to one event and answers 201 with the subscription, including its secret. The secret signs every delivery; it is in this answer and nowhere else, so keep it. Each call makes a new subscription, so the same URL subscribed twice receives every delivery twice.
| Field | Type | Description |
|---|---|---|
event | string, required | One of the events. Any other value is 422 with "Unknown event. One of: booking.received, customer.created, invoice.paid, job.completed, job.created". |
target_url | string, required | 12 to 2,000 characters. It must be an https URL (otherwise "target_url must be an https URL") at a public address: localhost, loopback, .local and .internal names and private network addresses are refused with "target_url must be a public address". |
curl https://app.helixfielddispatch.com/api/public/v1/hooks \
-H "Authorization: Bearer $HELIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event": "job.created", "target_url": "https://hooks.example.com/helix/jobs"}'
Response 201
{
"id": "0cc1e630-f74f-43de-b15c-47b4db15e5a2",
"event": "job.created",
"target_url": "https://hooks.example.com/helix/jobs",
"secret": "your-signing-secret-shown-only-here",
"created_at": "2026-10-16T07:52:40.330158"
}
/hooks/{hook_id}
Removes the subscription for good and answers 204 with no body. Nothing more is sent to it, including retries that were still owed. The answer is 204 as well when the subscription was already removed or the id is unknown, so an unsubscribe can be repeated safely.
curl -X DELETE https://app.helixfielddispatch.com/api/public/v1/hooks/0cc1e630-f74f-43de-b15c-47b4db15e5a2 \
-H "Authorization: Bearer $HELIX_API_KEY"
Objects
The list endpoints, the create endpoints and the data of a webhook all use these shapes.
Customer
| Field | Type | Description |
|---|---|---|
id | string | The customer's id |
name | string or null | First and last name; the company name when both are empty |
first_name | string | |
last_name | string | Can be empty |
company_name | string or null | |
email | string or null | null when the customer has no email |
phone | string or null | |
customer_type | string | residential, commercial or contractor |
tags | array of strings | The customer's tag names |
created_at | string | When the customer was added |
Job
| Field | Type | Description |
|---|---|---|
id | string | The job's id |
job_number | string | The number the office sees, such as JOB-AA107B31 |
title | string | |
status | string | scheduled, in_progress, completed or cancelled |
job_type | string | maintenance, repair, installation or inspection |
service_id | string or null | The service from the company's service list, when the job has one |
service_name | string or null | That service's name |
customer_id | string or null | |
customer_name | string or null | The customer's name |
property_id | string or null | Where the work is |
scheduled_date | string or null | The date the job is booked for |
scheduled_start_time | string or null | The booked start time as HH:MM |
completed_at | string or null | When the crew finished the job |
Invoice
| Field | Type | Description |
|---|---|---|
id | string | The invoice's id |
invoice_number | string | Such as INV-1001 |
status | string | draft, sent, paid or overdue |
customer_id | string or null | |
customer_name | string or null | The customer's name |
job_id | string or null | The job the invoice was raised from, if any |
total | string | The amount of the invoice |
paid_amount | string | How much has been paid so far |
paid_date | string or null | When the invoice was paid in full |
issued_date | string | |
due_date | string |
Booking request
| Field | Type | Description |
|---|---|---|
id | string | The request's id |
service_id | string or null | The service that was asked for |
service_name | string | That service's name |
contact_name | string | The name the person gave |
contact_email | string or null | |
contact_phone | string or null | |
address | string or null | |
city | string or null | |
zip_code | string or null | |
timing_preference | string or null | When the person would like the work done, in their words |
notes | string or null | |
created_at | string | When the request arrived |
Webhook subscription
| Field | Type | Description |
|---|---|---|
id | string | The id to unsubscribe with |
event | string | The event it is for |
target_url | string | Where deliveries are posted |
secret | string or null | The signing secret, in the answer to POST /hooks only |
created_at | string | When it was made |
Webhooks
Subscribe a URL with POST /hooks and Helix Field posts to it each time the event happens, in the ways the table below lists. The office can see every subscription under Settings > Integrations, with when it last sent, and can remove one there.
Events
| Event | Sent when | data |
|---|---|---|
customer.created | A customer is added by hand in Helix Field, through POST /customers, or by accepting a booking request from someone who is not already a customer. A customer made by an import does not send it, so one import does not start a Zap for every row. tags lists the tags sent with POST /customers, and is empty for a customer added in Helix Field. | Customer |
job.created | A job is created by hand in Helix Field, through POST /jobs, by accepting a booking request, by converting an accepted estimate, or by a season booking, which sends one for each visit it books. The visits a recurring plan makes ahead of time and a job made by an import do not send it, so a plan or an import does not start a Zap for every visit or row. | Job |
job.completed | A job is marked completed, from the crew's phone or the office app. | Job |
invoice.paid | The payments on an invoice reach its total, whether by card, cash or check. An invoice that is reopened by a refund and then paid in full again is sent again. | Invoice |
booking.received | Someone sends a request through the company's online booking form. | Booking request |
Delivery
Each delivery is an HTTPS POST to the subscription's target_url with a JSON body:
{"data":{"company_name":null,"created_at":"2026-10-16T07:52:08","customer_type":"commercial","email":"dana.reyes@example.com","first_name":"Dana","id":"6505eb45-3350-4c08-b6ee-45f2d2cc4319","last_name":"Reyes","name":"Dana Reyes","phone":"405-555-0134","tags":["Spring campaign"]},"event":"customer.created","occurred_at":"2026-10-16T07:52:08"}
| Field | Description |
|---|---|
event | The event's name |
occurred_at | When it happened, in UTC |
data | The object the event is about, in the shape listed under Objects |
The body is compact JSON with its keys in alphabetical order. It comes with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | HelixField-Webhooks/1 |
X-Helix-Event | The event's name |
X-Helix-Delivery | The delivery's id. It is the same on every retry of one delivery, so it can be used to ignore a delivery already handled. |
X-Helix-Signature | sha256= followed by the signature in hex |
A delivery counts as received when the URL answers with any 2xx status within 10 seconds. Redirects are not followed, so a 3xx answer counts as a failure.
Verifying a signature
The signature is an HMAC SHA256 of the exact bytes of the body, keyed with the subscription's secret. Compute it over the raw body as received, before parsing it, and compare it with the header in constant time. A delivery whose signature does not match did not come from Helix Field.
import hashlib
import hmac
def is_from_helix(secret: str, body: bytes, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")
Node.js
const crypto = require("crypto");
function isFromHelix(secret, rawBody, signatureHeader) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Retries and turning off
- The first attempt is made as soon as the change that caused the event is saved.
- A failed delivery is tried again, up to five attempts in all. The waits after each failure are at least 5 minutes, 30 minutes, 2 hours and 8 hours. Retries are sent by a pass that runs once an hour, so a retry goes out on the first pass after its wait, up to about an hour later. After the fifth failed attempt that delivery is given up.
- A URL that answers
410 Goneis asking not to be called again: its subscription is turned off at once. - A subscription whose deliveries fail 25 times in a row is turned off. Any delivery that succeeds sets the count back to zero.
- A subscription is also turned off when the key that made it is revoked.
- While a company's Helix Field account is locked, its owed retries are given up rather than kept.
A subscription that is turned off receives nothing more and no longer appears in GET /hooks. It is not turned back on; subscribe again with POST /hooks.