API and webhooks
Version 1, 2 October 2026
Read your leads and jobs, create leads, add notes and follow ups, and hear about what happens in your office as it happens. It works with n8n, Zapier, Make, or code of your own.
Your key
A manager makes a key under Settings, Integrations, API.
The key is shown once. Copy it into your tool straight away; if it is lost,
revoke it and make another. Send it on every call in the
x-api-key header.
Every call goes to your own company's address, which the same screen shows and lets you copy:
https://yourcompany.logmovers.com/api/v1
curl https://yourcompany.logmovers.com/api/v1/statuses \ -H "x-api-key: lmk_3f9a1c2b7d40_EXAMPLEexampleEXAMPLEexampleEXAMPLEexample1"
A key reads and changes your company's data. Keep it in your tool's secret store, never in a web page. A key works only on its own company's address. A key belongs to your company, not to the person who made it: it keeps working when that person changes role or is deactivated. It stops working when somebody revokes it under Settings, Integrations, API, or when API and webhooks is switched off for your company. Writes made with it are recorded in the job's activity under the name of the person who made it, marked "through the API".
API and webhooks is an add-on that LogMovers switches on or off for
your company. While it is off every call answers 403, no
key or receiver can be made, and no webhook is sent.
Limits
| Limit | Value | Past it |
|---|---|---|
| Calls a minute, per company | 60 | 429 rate_limited with Retry-After in seconds |
| Calls a month, per company (calendar month, UTC) | 20,000 | 429 monthly_cap until the next month |
| Rows on one page | 50 by default, 100 at most | 422 |
| Request body | 32 KB | 413 |
| Live keys | 10 | Revoke one to make another |
| Webhook receivers | 5 | Remove one to add another |
Every answer carries X-RateLimit-Limit,
X-RateLimit-Remaining, X-Monthly-Limit and
X-Monthly-Remaining. The settings screen shows the calls
used this month.
Errors
Every error is JSON with a code you can branch on:
{
"error": {
"code": "validation_failed",
"message": "Some fields are missing or wrong.",
"fields": {
"customer.phones": ["Enter a valid 10-digit phone number"],
"source_id": ["Pick the source this lead came from"]
}
}
}
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | No key, a wrong key, a revoked key, or a key for another company |
| 403 | addon_off | API and webhooks is switched off for your company |
| 403 | company_inactive | Your account is not active |
| 404 | not_found | No such endpoint, or nothing with that id in your company |
| 405 | method_not_allowed | The path exists, the method does not |
| 413 | payload_too_large | The body is over 32 KB |
| 415 | unsupported_media_type | The body is not sent as application/json |
| 422 | validation_failed | A field is missing or wrong, or a field or parameter is unknown |
| 422 | owner_not_named | No sales_rep_id or assigned_to_id was sent, and your company owner is not named or not active |
| 429 | rate_limited, monthly_cap | Past a limit above |
| 500 | internal_error | Our fault. Try again |
Unknown fields are refused, never ignored, so a typo in a field name is caught the first time rather than silently dropped.
Leads and jobs
A lead and a job are the same record at different stages, so they come from one list. The status is the word your Leads screen shows: New Lead, Active, Quoted, Booked, Completed, Missed, Cancelled and any your company added. Money is a string with two decimals.
Co jobs are in the list too. A co job carries
head_job_id, the id of its main job; a main job's is
null. A co job's total and
balance are its family's, the same figures as its main
job, as on the job file. To add up money across the list, add only the
rows whose head_job_id is null, so each family
is counted once.
GET /jobs
| Parameter | Meaning |
|---|---|
created_since | Created at or after. YYYY-MM-DD (the start of that day in your company's time) or an ISO 8601 date and time |
created_until | Created before |
updated_since | Changed at or after; use it to poll for changes |
status | A status word, any case: booked, new lead |
sales_rep_id | One rep's id from /sales-reps |
page, per_page | From 1; up to 100 a page |
curl "https://yourcompany.logmovers.com/api/v1/jobs?status=booked&created_since=2026-10-01&per_page=2" \ -H "x-api-key: $LOGMOVERS_KEY"
{
"data": [
{
"id": "7c1e4a52-3b9d-4f0e-9a6c-2d8b5e1f4a90",
"head_job_id": null,
"job_number": "10482",
"lead_id": "Mark #10482",
"status": "Booked",
"created_at": "2026-10-01T14:05:11.000Z",
"updated_at": "2026-10-02T09:12:40.000Z",
"move_date": "2026-10-18",
"move_time": "09:00",
"customer": { "id": "0b6f2d14-8e3a-4c71-b5d9-6a2e8f1c3b47", "name": "Jordan Pike" },
"sales_rep": { "id": "5a8d3c21-7f4e-4b06-9c1a-e2b7d6f05c38", "first_name": "Mark", "last_name": "Ellery" },
"source": { "id": "e4c71b9a-2d5f-4a38-8b60-1f9e3c7a2d54", "name": "Google" },
"move_type": { "id": "9f2a6e13-4c8b-4d75-a1e0-7b3c5d9f8e21", "name": "Local" },
"total": "1426.00",
"balance": "1126.00"
}
],
"page": 1,
"per_page": 2,
"total": 1,
"has_more": false
}
GET /jobs/{id}
By the job's id or by its job number (10482, or
10482-1 for a co job). Adds the customer's phones and emails
and every address.
curl https://yourcompany.logmovers.com/api/v1/jobs/10482 -H "x-api-key: $LOGMOVERS_KEY"
{
"data": {
"id": "7c1e4a52-3b9d-4f0e-9a6c-2d8b5e1f4a90",
"job_number": "10482",
"lead_id": "Mark #10482",
"status": "Booked",
"move_date": "2026-10-18",
"move_time": "09:00",
"customer": {
"id": "0b6f2d14-8e3a-4c71-b5d9-6a2e8f1c3b47",
"name": "Jordan Pike",
"phones": ["2125550134"],
"emails": ["jordan.pike@example.com"]
},
"addresses": [
{ "type": "pickup", "position": 0, "street": "41 Alder Court", "apartment": "3B", "city": "Brooklyn", "state": "NY", "zip": "11215" },
{ "type": "delivery", "position": 1, "street": "9 Harbor View Road", "apartment": null, "city": "Stamford", "state": "CT", "zip": "06902" }
],
"total": "1426.00",
"balance": "1126.00",
"...": "and every field of the list row"
}
}
Create a lead
POST /leads
A lead made here is made by the same code as the New Request window in
the office: the next job number, the move date two weeks out and nine to
eleven if none is given, your automatic charges, the rep you name or else
your company owner, and a lead.created webhook.
| Field | Needed | Meaning |
|---|---|---|
customer.name | Yes | The customer's full name |
customer.phones | Yes, 1 to 5 | 10 digits, any punctuation |
customer.emails | No, up to 5 | |
source_id or source | Yes | An id from /lead-sources, or its name |
move_type_id or move_type | Yes | An id from /move-types, or its name |
sales_rep_id | No | An id from /sales-reps. Left out, the lead goes to your company owner |
move_date | No | YYYY-MM-DD |
estimated_cu_ft | No | Whole number |
pickup, delivery | No | street, apartment, city, state (two letters), zip |
notes | No | Up to 2,000 characters, added as a note on the lead |
curl -X POST https://yourcompany.logmovers.com/api/v1/leads \
-H "x-api-key: $LOGMOVERS_KEY" \
-H "content-type: application/json" \
-d '{
"customer": { "name": "Jordan Pike", "phones": ["(212) 555-0134"], "emails": ["jordan.pike@example.com"] },
"source": "Google",
"move_type": "Local",
"move_date": "2026-10-18",
"pickup": { "street": "41 Alder Court", "apartment": "3B", "city": "Brooklyn", "state": "NY", "zip": "11215" },
"delivery": { "street": "9 Harbor View Road", "city": "Stamford", "state": "CT", "zip": "06902" },
"notes": "Two bedrooms, piano on the second floor."
}'
Answers 201 with the new lead, in the shape of GET /jobs/{id}.
Change a lead or job
PATCH /leads/{id}
Contact details, move date, addresses and source, on a lead or job in
any status, co jobs included. Send only what changes. Phones and emails
replace the whole list. The fields are customer.name,
customer.phones, customer.emails,
move_date, source_id or source,
and pickup and delivery. Money cannot be
changed through the API. A locked day still refuses a booked job's new
date.
curl -X PATCH https://yourcompany.logmovers.com/api/v1/leads/10482 \
-H "x-api-key: $LOGMOVERS_KEY" \
-H "content-type: application/json" \
-d '{ "move_date": "2026-10-25", "customer": { "phones": ["2125550134", "9175550188"] } }'
Notes and follow ups
POST /jobs/{id}/notes
curl -X POST https://yourcompany.logmovers.com/api/v1/jobs/10482/notes \
-H "x-api-key: $LOGMOVERS_KEY" -H "content-type: application/json" \
-d '{ "text": "Customer asked for a call after 5pm." }'
{ "data": { "id": "c3e9a7b1-5d2f-4e86-9a40-8b1f6d2c7e53", "created_at": "2026-10-02T15:31:07.000Z" } }
POST /jobs/{id}/followups
due_date (YYYY-MM-DD) and note are
needed; assigned_to_id is a person from
/sales-reps, else your company owner.
curl -X POST https://yourcompany.logmovers.com/api/v1/jobs/10482/followups \
-H "x-api-key: $LOGMOVERS_KEY" -H "content-type: application/json" \
-d '{ "due_date": "2026-10-06", "note": "Send the revised quote", "assigned_to_id": "5a8d3c21-7f4e-4b06-9c1a-e2b7d6f05c38" }'
{ "data": { "id": "4b7e2a90-1c6d-4f38-b5e2-9d0a3c8f1e67" } }
Payments
GET /jobs/{id}/payments
A co job's payments are its main job's, as on the job file.
{
"data": [
{
"id": "a91f3c7e-6b2d-4a85-8e10-5c4d7b2f9a36",
"amount": "300.00",
"refunded_amount": "0.00",
"status": "paid",
"kind": "manual",
"method": "Check",
"received_at": "2026-10-02T13:00:00.000Z",
"reference": "Check 2207",
"failure_reason": null,
"voided": false
}
]
}
Customers
GET /customers?search=
Matches a name, an email, or four or more digits of a phone. Paged like /jobs.
curl "https://yourcompany.logmovers.com/api/v1/customers?search=pike" -H "x-api-key: $LOGMOVERS_KEY"
{
"data": [
{
"id": "0b6f2d14-8e3a-4c71-b5d9-6a2e8f1c3b47",
"name": "Jordan Pike",
"phones": ["2125550134"],
"emails": ["jordan.pike@example.com"],
"created_at": "2026-10-01T14:05:11.000Z"
}
],
"page": 1, "per_page": 50, "total": 1, "has_more": false
}
GET /customers/{id}
One customer, with jobs: every job of theirs in the list row shape.
Reps, sources, move types, statuses
| Call | Each row |
|---|---|
GET /sales-reps | id, first_name, last_name |
GET /lead-sources | id, name |
GET /move-types | id, name |
GET /statuses | id, name, booked |
{ "data": [ { "id": "e4c71b9a-2d5f-4a38-8b60-1f9e3c7a2d54", "name": "Google" }, { "id": "b2d8f6a1-9c3e-4b57-8d20-6e1a4f7c9b35", "name": "Yelp" } ] }
Webhooks
Add a receiver under Settings, Integrations, API: an
https address and the events it wants. Its signing secret is
shown once. Addresses on a private network, and this CRM's own addresses,
are refused.
| Event | When | Extra in data |
|---|---|---|
lead.created | A lead is made, in the office or through the API | |
quote.sent | A quote link is sent to the customer | total |
quote.accepted | The customer accepts and signs | total |
job.booked | The job is booked | move_date |
job.completed | A booked job's move day has passed (checked every few minutes) | |
job.cancelled | The job is cancelled | reason |
payment.received | A payment is recorded or a card payment lands | payment_id, amount |
payment.failed | A card payment fails | payment_id, amount, reason |
followup.created | A follow up is added | followup_id, assigned_to_id |
followup.done | A follow up is ticked off | followup_id |
A delivery is a POST of JSON carrying the event and the ids,
with a short summary. Call GET /jobs/{id} for the rest.
POST /your/receiver HTTP/1.1
Content-Type: application/json
X-LogMovers-Event: job.booked
X-LogMovers-Delivery: 2f8c1d6e-4a9b-4e37-8c05-7d1b3a6e9f42
X-LogMovers-Signature: t=1791040331,v1=5d0f8e3b...
{
"id": "6e3b9d1a-7c4f-4a28-b0e5-3f9a1c7d2e84",
"type": "job.booked",
"created_at": "2026-10-02T15:12:11.000Z",
"data": {
"job_id": "7c1e4a52-3b9d-4f0e-9a6c-2d8b5e1f4a90",
"job_number": "10482",
"customer_id": "0b6f2d14-8e3a-4c71-b5d9-6a2e8f1c3b47",
"customer_name": "Jordan Pike",
"move_date": "2026-10-18"
}
}
Answer with any 2xx within 10 seconds. Anything else, a
timeout or a redirect counts as a failure, and the delivery is tried
again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12
hours: seven tries over about 21 hours. Then it is marked failed. The
delivery log on the settings screen shows every try and its result.
Deliveries can arrive more than once or out of order; use the event
id to skip one you have already handled.
Checking a signature
X-LogMovers-Signature is t= the time in Unix
seconds and v1= an HMAC SHA256, in hex, of the time, a dot
and the raw body, made with your signing secret. Check it before you
trust the body, and refuse a time more than five minutes old.
// Node
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!(age <= 300)) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
# Python
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
In n8n, use a Webhook node, then a Code node with the Node check above. In Zapier, use Catch Raw Hook so the body reaches you unchanged.
Questions
Use the contact form on the LogMovers home page, or Get help inside the CRM.
QuickBooks Payments services are provided by Intuit Payments Inc.