ManageSync API
Send leads and work into ManageSync from your own software, and get signed webhooks back as things move. Base URL: https://app.managesync.com/api/v1
Authentication
Every request carries an API key in the Authorization header:
Authorization: Bearer msk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxKeys come in two scopes. Platform keysare issued by ManageSync to partner programs — they submit estimate requests into the ManageSync team's triage queue. Organization keysbelong to a contractor or location on ManageSync and act on that organization's behalf; org admins mint them under Settings → Integrations. A key is shown once at creation and can be revoked at any time. Keys can carry an optional expiry picked at mint time — an expired key fails with a 401 exactly like a revoked one.
Errors & rate limits
Errors are JSON with a stable machine-readable code:
{ "error": { "code": "validation_failed", "message": "state: Select a state" } }Codes: invalid_api_key (401), key_not_permitted / feature_disabled (403), not_found (404), invalid_json (400), validation_failed (422), rate_limited (429). Write endpoints allow 30 requests per 10 minutes per key by default — contact ManageSync if your integration needs more; limits are adjustable per key.
Create an estimate request
POST /api/v1/estimate-requests— submit a lead / project-estimate request. Platform keys land in the ManageSync triage queue; location keys submit on the org's behalf and may choose managementType: "unmanaged" to publish straight to the contractor marketplace.
| Field | Required | Notes |
|---|---|---|
contactName | Yes | Site contact, max 200 chars |
contactPhone / contactEmail | Yes | At least one of the two |
addressLine1 | Yes | Job-site street address |
addressLine2 | No | |
city | Yes | |
state | Yes | Two-letter US state code |
zip | Yes | |
projectType | No | "residential" (default) or "commercial" |
trade | No | Optional trade classification |
description | Yes | 20–5000 chars |
managementType | No | Location keys only: "managed_by_ms" (default) or "unmanaged" |
curl -X POST https://app.managesync.com/api/v1/estimate-requests \
-H "Authorization: Bearer msk_..." \
-H "Content-Type: application/json" \
-d '{
"contactName": "Jane Doe",
"contactEmail": "jane@example.com",
"addressLine1": "123 Main St",
"city": "Baltimore",
"state": "MD",
"zip": "21201",
"projectType": "commercial",
"description": "Roof membrane inspection and repair estimate."
}'
// 201
{ "id": "…", "status": "new", "managementType": "managed_by_ms", "createdAt": "…" }Read estimate requests
GET /api/v1/estimate-requests— your requests, newest first. Platform keys list the requests they submitted; location keys list any of their organization's requests. Query params: status (new, in_review, open, closed), limit (max 100, default 25), offset.
{
"estimateRequests": [
{ "id": "…", "status": "open", "managementType": "managed_by_ms",
"projectType": "commercial", "trade": "roofing",
"city": "Baltimore", "state": "MD", "createdAt": "…", "closedAt": null }
],
"total": 12, "limit": 25, "offset": 0
}GET /api/v1/estimate-requests/{id} — one request, plus a contractor-routing summary:
{
"id": "…",
"status": "open", // new | in_review | open | closed
"managementType": "managed_by_ms",
"projectType": "commercial",
"trade": "roofing",
"city": "Baltimore",
"state": "MD",
"createdAt": "…",
"closedAt": null,
"contractors": { "sent": 3, "converted": 1, "declined": 1 }
}Contractor request inbox
With a contractor key, the same endpoints read your organization's bid-request inbox — the estimate requests you were invited to or engaged from the marketplace. Each item carries the full site detail plus your organization's response state under target; other contractors' involvement is never included.
GET /api/v1/estimate-requests // list, ?status=&limit=&offset=
GET /api/v1/estimate-requests/{id} // one request you were invited to
{
"id": "…",
"status": "open",
"projectType": "commercial",
"trade": "roofing",
"description": "…",
"contactName": "…", "contactPhone": "…", "contactEmail": "…",
"addressLine1": "…", "city": "Baltimore", "state": "MD", "zip": "…",
"createdAt": "…", "closedAt": null,
"target": {
"status": "converted", // sent | declined | converted
"origin": "ms_sent", // ms_sent | marketplace
"sentAt": "…",
"respondedAt": "…",
"declinedReason": null,
"bidPwoNumber": "PWO-1042" // the project spawned from this request
}
}List sites & services
GET /api/v1/sites— location keys only. Returns the organization's active sites and the services priced at each; use a site's id and a service's serviceRateId to create work orders.
{
"sites": [
{
"id": "…", "name": "Store #12", "addressLine1": "…",
"city": "…", "state": "…", "zip": "…",
"services": [
{ "serviceRateId": "…", "name": "HVAC — rooftop unit", "rateType": "hourly",
"standardRate": "95.00", "priorityRate": "120.00", "emergencyRate": "160.00" }
]
}
]
}Create a work order
POST /api/v1/work-orders— behavior depends on the key's organization type.
Location keyscreate a marketplace work order at one of the org's sites (contractors serving the area can accept it):
| Field | Required | Notes |
|---|---|---|
siteId | Yes | From GET /api/v1/sites |
serviceRateId | Yes | A service priced at that site |
urgency | No | "standard" (default), "priority", "emergency" |
description | Yes | 20–2000 chars |
propertyType | No | "residential" or "commercial" |
onSiteContactName / onSiteContactPhone | No | Defaults to the site's contact |
scheduledFor | No | ISO 8601 datetime |
Contractor keyscreate a work order in the contractor's own pipeline (status unassigned) with free-text facility fields:
| Field | Required | Notes |
|---|---|---|
description | Yes | 1–2000 chars |
urgency / propertyType / scheduledFor | No | As above |
facilityName, facilityAddressLine1/2, facilityCity, facilityState, facilityZip | No | Free-text job-site details |
onSiteContactName / onSiteContactEmail / onSiteContactPhone | No |
// 201
{ "id": "…", "woid": "WOID-1234", "status": "unassigned" }Read work orders
GET /api/v1/work-orders— your organization's work orders, newest first (contractor keys: the org's assigned pipeline; location keys: the work orders the org submitted). Query params: status (e.g. unassigned, complete), limit (max 100, default 25), offset.
GET /api/v1/work-orders/{id} — one work order in the same shape.
{
"id": "…", "woid": "WOID-1234",
"status": "on_site", // unassigned | assigned | en_route | on_site
"isSuspended": false, // | complete | closed | canceled | revisions_requested
"urgency": "priority",
"propertyType": "commercial",
"description": "…",
"source": "api",
"facility": { "name": "…", "addressLine1": "…", "city": "…", "state": "…", "zip": "…" },
"scheduledFor": null,
"firstArrivedAt": "…", "completedAt": null, "closedAt": null,
"createdAt": "…"
}Webhooks
Add a webhook URL to a key (in-app, next to the key) to receive POSTed events for estimate requests that key submitted. Saving a URL mints a signing secret (whsec_…), shown once.
Events:
request.status_changed—{ status, previousStatus }request.routed— sent to contractors,{ sentTo }target.converted— a contractor took the request,{ contractorName, origin, pwoNumber }target.declined— an invited contractor declined,{ contractorName, reason }target.won— the project a contractor spawned from your request was approved / awarded,{ contractorName, pwoNumber }work_order.status_changed— a work order your key created changed status,{ workOrderId, woid, status, previousStatus, corrected? }
POST <your url>
X-ManageSync-Event: target.converted
X-ManageSync-Signature: t=1783900000,v1=5f3a…
{
"id": "<delivery id — dedupe on this>",
"event": "target.converted",
"createdAt": "…",
"data": { "requestId": "…", "contractorName": "…", "origin": "invitation" }
}Verify the signature by computing HMAC-SHA256 over `${t}.${rawBody}` with your secret and comparing to v1:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(signatureHeader, rawBody, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("="))
);
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Respond with a 2xx quickly. Deliveries are at-least-once (dedupe on the delivery id); a failed delivery retries after ~15s and ~45s, then daily, up to 5 attempts total. Recent deliveries and their outcomes are visible in-app next to the key (the delivery-log icon).
