Developers
Plug TowSitePros into anything: a motor-club feed, your billing system, Zapier, your own dispatch software.
What you can build
Create, read, update and assign jobs, add notes and read the timeline through the API; photos, archiving and link management stay in the dashboard for now. Everything that happens to a job can be pushed to your system as it happens. A job created by the API shows up on the dispatch board, on the driver's page and on the customer's tracking page exactly as if a dispatcher had typed it, and it queues the same texts.
- Push jobs in from a motor club, a police rotation, a fleet account or your own intake form, with your reference on each one.
- Pull jobs out to billing, accounting or a data warehouse, with every milestone timestamp and the final price.
- React in real time with webhooks: a customer message, a rating, a truck going en route.
- Migrate history from a spreadsheet with the CSV import under Developers in the dashboard (up to 500 rows or 2 MB per file; imported jobs never text anyone).
Authentication
Create a key under Developers in the dashboard and send it as a bearer token. Give each system its own key with only the scopes it needs, so you can revoke one without touching the others. Keys are shown once; we store only a hash.
Authorization: Bearer tsp_live_…
| Scope | Allows |
|---|---|
| jobs:read | Read jobs, their timeline, units on them. |
| jobs:write | Create and update jobs, assign units, add notes. |
| roster:read | Read the roster and last positions. |
| services:read | Read services and the rate card. |
| webhooks:manage | Add, pause, rotate and delete webhook endpoints. |
| accounts:read | Read the commercial accounts directory: names, kinds and contacts. Never rates, billing addresses or internal notes. |
Limits: 600 reads and 120 writes per minute per key. Over the limit you get 429 with a Retry-After header.
Endpoints
Base URL https://www.towsitepros.com/api/v1. JSON in, JSON out. Every error has the same shape: { "error": { "code", "message", "details" } }.
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /me | jobs:read | Who this key is: account, sites, scopes. |
| GET | /jobs | jobs:read | List jobs. Filter by status, since, updated_since, external_ref. Cursor-paged. |
| POST | /jobs | jobs:write | Create a job. Mints the tracking link and queues the "we got your request" text. |
| GET | /jobs/{id} | jobs:read | One job with its unit, position and tracking link. |
| PATCH | /jobs/{id} | jobs:write | Change anything writable. A status change stamps the milestone and texts the customer. |
| POST | /jobs/{id}/assign | jobs:write | Hand the job to a unit; the driver gets their job link. |
| DELETE | /jobs/{id}/assign | jobs:write | Take the job back off the board. |
| POST | /jobs/{id}/notes | jobs:write | Add a note, optionally visible to the customer. |
| POST | /jobs/{id}/reopen | jobs:write | Bring a finished job back to confirmed, with a reason. The only way out of completed, cancelled or goa. |
| GET | /jobs/{id}/events | jobs:read | The append-only timeline. |
| GET | /units | roster:read | Drivers and trucks, with the last position each shared. |
| GET | /services | services:read | Services and the rate card. |
| GET · POST | /webhooks | webhooks:manage | List or add endpoints. The secret comes back once. |
| PATCH · DELETE | /webhooks/{id} | webhooks:manage | Pause, resume, change events, rotate the secret, delete. |
| POST | /webhooks/{id}/test | webhooks:manage | Send a ping now and see what your endpoint answered. |
| GET | /webhooks/{id}/deliveries | webhooks:manage | The last 100 deliveries. |
Create a job
curl -X POST https://www.towsitepros.com/api/v1/jobs \
-H "Authorization: Bearer tsp_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: mc-1042" \
-d '{
"external_ref": "MC-1042",
"source": "motor_club",
"payer_type": "motor_club",
"service_type": "flatbed",
"urgency": "urgent",
"customer": { "name": "Jane Doe", "phone": "(336) 555-0100", "sms_consent": true },
"vehicle": { "year": "2019", "make": "Honda", "model": "Civic", "drive_type": "fwd" },
"pickup": { "location": "I-40 exit 214, Greensboro NC" },
"destination": "Bob'"'"'s Auto, High Point NC"
}'Send an Idempotency-Key and a retry with the same body returns the first response instead of a second truck. external_ref is unique per account: a duplicate answers 409 with the existing job's id. Set customer.sms_consent only when the customer agreed to texts; that consent is yours to hold, not ours.
How do I change a job's status safely?
Send status on PATCH /jobs/{id}. Every move is checked against the same rules the dispatch board, the driver page and the tracking page are checked against, so the API can never put a job somewhere a dispatcher could not have put it. Three rules are worth knowing before you write the integration.
- A finished job cannot be moved. Once a job is
completed,cancelledorgoa, aPATCHthat changes its status is refused with409job_transition_refused. UsePOST /jobs/{id}/reopeninstead, which asks you to say why. - Cancelling needs a reason. Send
cancel_reason(3 to 500 characters) with"status": "cancelled".goaaccepts one and does not insist, because a truck arriving to an empty shoulder is its own explanation. Both land in the same field and both show on the job. - Send
expected_statusand the write becomes conditional. If the job has moved since you read it, nothing is written and you get409job_stalewith the status it is in now. This is how you stop a queued update from undoing a driver who got there first. It is optional: leave it out and the call behaves exactly as it always has.
curl -X PATCH https://www.towsitepros.com/api/v1/jobs/{id} \
-H "Authorization: Bearer tsp_live_…" \
-H "Content-Type: application/json" \
-d '{
"status": "cancelled",
"expected_status": "dispatched",
"cancel_reason": "Customer found another truck"
}'PATCH ignores Idempotency-Key. expected_status is the safe way to retry a status change, because a retry that arrives after the job moved is refused rather than replayed onto a job that has moved on.
Reopen a finished job
A reopen brings the job back to confirmed, stamps timestamps.reopened_at, increments reopen_count, and clears the finished run's cancel_reason and the customer's rating. All four are written to the timeline first, so nothing is lost. The milestones (completed_at and the rest) are write-once and keep the first run's values, which is what reopen_count is for: without it a job that ran twice looks exactly like one that ran once.
curl -X POST https://www.towsitepros.com/api/v1/jobs/{id}/reopen \
-H "Authorization: Bearer tsp_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reopen-MC-1042" \
-d '{ "reason": "Winch line slipped, vehicle still on the shoulder" }'What does each error code mean?
Every one of these is safe to retry after you re-read the job, and in every one of them nothing was written, with one exception: a PATCH that changes the status and other fields is two writes, so if the second fails the response carries details.transition_applied with the from and to that did commit. Re-read the job before retrying that one. Branch on error.code; error.message is the sentence a dispatcher would have been shown, and error.details repeats it as database_message alongside the fields worth branching on.
| Code | HTTP | When you get it |
|---|---|---|
| job_transition_refused | 409 | The job cannot make this move. A finished job (completed, cancelled, goa) cannot be moved by PATCH at all, and a reopen of a job that is not finished is refused the same way. details.from and details.to name the pair. |
| job_stale | 409 | You sent expected_status and the job is in another one. Nothing was written. details.expected_status and details.current_status name both. |
| job_reason_required | 422 | The move needs a reason and the body did not carry a usable one. Cancelling needs cancel_reason; reopening needs reason. Both are 3 to 500 characters after trimming. |
| invalid_state | 409 | The job is archived. Restore it in the dashboard, then try again. |
| conflict | 409 | Another job on this account already carries that external_ref. |
The job.reopened webhook
Subscribe to job.reopened to hear about it. It is the one event whose detail carries what the move erased, so if you store ratings or cancellation reasons on your side you can keep them. The same move also emits job.status_changed from the finished status to confirmed, so an endpoint subscribed only to status changes still sees the job come back, just without the explanation.
{
"type": "job.reopened",
"api_version": "2026-09-01",
"data": {
"job": {
"status": "confirmed",
"cancel_reason": null, // cleared by the reopen
"rating": null, // cleared by the reopen
"reopen_count": 1,
"timestamps": {
"completed_at": "2026-09-14T17:02:11Z", // still the first run
"closed_at": "2026-09-14T17:02:11Z",
"reopened_at": "2026-09-14T18:41:02Z"
}
},
"event": {
"type": "job.reopened",
"from": "completed",
"to": "confirmed",
"actor": "system",
"detail": {
"reason": "Winch line slipped, vehicle still on the shoulder",
"reopen_count": 1,
"cancel_reason": null,
"rating": 5, // what the reopen erased
"rating_comment": "Fast and friendly",
"rated_at": "2026-09-14T17:20:00Z"
}
}
}
}Webhooks
Add an https endpoint under Developers (or with POST /webhooks) and pick the events you want, or * for all of them. We POST one JSON event per delivery, retry a failure six times over the next 33 hours, and pause an endpoint after 25 failures in a row. Answer 2xx within 10 seconds; do the work afterwards.
{
"id": "0b1f…", // delivery id: de-duplicate on it
"object": "event",
"type": "job.status_changed",
"api_version": "2026-09-01",
"account_id": "…",
"attempt": 1,
"created_at": "2026-09-05T18:41:02Z",
"data": {
"job": { "id": "…", "status": "en_route", "unit": { "name": "Truck 2", … }, … },
"event": { "type": "job.status_changed", "from": "dispatched", "to": "en_route", "actor": "driver" }
}
}Verify the signature
Every delivery carries X-TSP-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" with your endpoint's secret. Reject anything older than five minutes and compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (age > 300) return false; // replay window
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`) // timestamp + raw body
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Events
- ping
- job.created
- job.status_changed
- job.assigned
- job.reassigned
- job.unassigned
- job.quote_updated
- job.eta_updated
- job.note_added
- job.customer_located
- job.photo_added
- job.payment_updated
- job.driver_acknowledged
- job.customer_message
- job.rating_received
- job.payment_link_opened
- job.archived
- job.restored
- job.signature_captured
- job.reopened
One note on job.payment_updated: it is written both when a payment is recorded on the job itself and when an invoice raised against that job is issued, paid, voided or written off. Check event.detail.invoice_id to tell them apart. When it is absent the event carries final_price_cents and payment_method; when it is present it carries invoice_number, total_cents and amount_paid_cents, and to is the invoice status (issued, paid, void, written_off).
No code? Use Zapier, Make or n8n
Any automation tool with a "webhook trigger" and an "HTTP request" step can drive this API. Point a catch-hook at our webhooks to react to job events; call POST /jobs with your bearer key to log one. Our GoHighLevel connection under Integrations already syncs leads and can text from your own number.
Versioning and support
The API version is 2026-09-01. New fields are added without notice; nothing is removed or renamed without a new version and a long overlap. Questions: support@www.towsitepros.com.