Back to LogMovers

API and webhooks

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"

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

LimitValuePast it
Calls a minute, per company60429 rate_limited with Retry-After in seconds
Calls a month, per company (calendar month, UTC)20,000429 monthly_cap until the next month
Rows on one page50 by default, 100 at most422
Request body32 KB413
Live keys10Revoke one to make another
Webhook receivers5Remove 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"]
    }
  }
}
StatusCodeWhen
401unauthorizedNo key, a wrong key, a revoked key, or a key for another company
403addon_offAPI and webhooks is switched off for your company
403company_inactiveYour account is not active
404not_foundNo such endpoint, or nothing with that id in your company
405method_not_allowedThe path exists, the method does not
413payload_too_largeThe body is over 32 KB
415unsupported_media_typeThe body is not sent as application/json
422validation_failedA field is missing or wrong, or a field or parameter is unknown
422owner_not_namedNo sales_rep_id or assigned_to_id was sent, and your company owner is not named or not active
429rate_limited, monthly_capPast a limit above
500internal_errorOur 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

ParameterMeaning
created_sinceCreated at or after. YYYY-MM-DD (the start of that day in your company's time) or an ISO 8601 date and time
created_untilCreated before
updated_sinceChanged at or after; use it to poll for changes
statusA status word, any case: booked, new lead
sales_rep_idOne rep's id from /sales-reps
page, per_pageFrom 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.

FieldNeededMeaning
customer.nameYesThe customer's full name
customer.phonesYes, 1 to 510 digits, any punctuation
customer.emailsNo, up to 5
source_id or sourceYesAn id from /lead-sources, or its name
move_type_id or move_typeYesAn id from /move-types, or its name
sales_rep_idNoAn id from /sales-reps. Left out, the lead goes to your company owner
move_dateNoYYYY-MM-DD
estimated_cu_ftNoWhole number
pickup, deliveryNostreet, apartment, city, state (two letters), zip
notesNoUp 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

CallEach row
GET /sales-repsid, first_name, last_name
GET /lead-sourcesid, name
GET /move-typesid, name
GET /statusesid, 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.

EventWhenExtra in data
lead.createdA lead is made, in the office or through the API
quote.sentA quote link is sent to the customertotal
quote.acceptedThe customer accepts and signstotal
job.bookedThe job is bookedmove_date
job.completedA booked job's move day has passed (checked every few minutes)
job.cancelledThe job is cancelledreason
payment.receivedA payment is recorded or a card payment landspayment_id, amount
payment.failedA card payment failspayment_id, amount, reason
followup.createdA follow up is addedfollowup_id, assigned_to_id
followup.doneA follow up is ticked offfollowup_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.