For developers
The Installers.org API
Post an assembly job when an order ships, and ask us whether we have installers in a customer's area before you offer them assembly at all. REST, JSON, one bearer token.
Two calls to start
Check coverage, post a job. Everything else is optional.
No SDK to install
It's HTTP and JSON. Use whatever you already have.
Needs the Advanced plan
Worth knowing now rather than after you've built it.
Your API key
Partners on the Advanced plan issue their own key from the dashboard, and can rotate it whenever they like. We never see it again after it's shown once, because we only store a hash of it.
Talk to us about a keySend it as a bearer token on every request.
Authorization: Bearer ik_live_... # live: real jobs, real installers
Authorization: Bearer ik_test_... # sandbox: nothing reaches an installerThe prefix tells you which one you are holding. If a key you believe is live reads ik_test_, your jobs are going nowhere: nothing is broadcast and no installer will ever see them.
Endpoints
GET/api/v1/coverage
Is anyone available to do this work here? Call it on your product page to decide whether to show an “Add assembly” option. Returns a count only: never installer details.
curl "https://installers.org/api/v1/coverage?zip=40204&service=furniture-assembly" \
-H "Authorization: Bearer $INSTALLERS_KEY"
{ "zip": "40204", "service": "Furniture Assembly", "available": true, "providers": 6 }POST/api/v1/jobs
Create a job. We immediately notify every installer who covers that ZIP and does that kind of work. Set payout_type to fixed to name your price, or quote to have them bid.
curl -X POST "https://installers.org/api/v1/jobs" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{
"service": "fitness-equipment-assembly",
"zip": "40204",
"address": "1420 Bardstown Rd",
"details": "Treadmill TX-500. Second floor, stairs.",
"payout_type": "fixed",
"payout": 185,
"customer_name": "Dana Reyes",
"customer_phone": "(502) 555-0148",
"customer_email": "dana@example.com",
"manual_urls": ["https://cdn.example.com/manuals/tx-500.pdf"],
"external_ref": "ORD-10432"
}'
{ "id": "recABC123", "status": "Open", "customerToken": "a1b2c3d4e5f6g7h8", "manuals": [ ... ], "notified": 6, ... }manual_urls (or a single manual_url) links the assembly instructions to the job. Installers can read them before they claim it, which gets you better-informed quotes. Links only here, up to five, and they must be publicly reachable. Bad links are skipped rather than failing the job. To upload an actual file, or to attach forms you want back, use the dashboard.
customerTokenis an opaque token for the customer's job page. Give them the link apptmgmt.com/{customerToken} to let them track status, message the installer, confirm completion, and leave a review - no login required. If you handle customer notifications yourself, include this link in your own emails.
Customer details are stored but never shown to installers until you select one.
GET/api/v1/jobs
Every job you've posted, newest first, with how many installers are waiting on you and how many messages you haven't read. Use action to find the jobs where you are the holdup.
curl "https://installers.org/api/v1/jobs" \
-H "Authorization: Bearer $INSTALLERS_KEY"
{
"jobs": [
{
"id": "recABC123", "status": "Open", "service": "Fitness Equipment Assembly",
"customerToken": "a1b2c3d4e5f6g7h8", "requestCount": 3, "unread": 1,
"action": { "level": "high", "label": "3 installers waiting on you", "hint": "..." },
"manuals": [ { "id": "recM1", "filename": "tx-500.pdf", "url": "/api/attachments/recM1" } ]
}
]
}Deliverables aren't in the list. Fetch a single job for those.
GET/api/v1/jobs/:id
The job, plus every installer who has requested it and their quote. Once you select someone, their phone and email appear here.
curl "https://installers.org/api/v1/jobs/recABC123" \
-H "Authorization: Bearer $INSTALLERS_KEY"
{
"id": "recABC123",
"status": "Open",
"customerToken": "a1b2c3d4e5f6g7h8",
"manuals": [
{ "id": "recM1", "filename": "tx-500.pdf", "url": "/api/attachments/recM1", "isExternal": true }
],
"deliverables": { "templates": [], "submissions": [] },
"requests": [
{
"providerId": "recP1", "providerName": "Miller's Assembly", "quote": 165, "selected": false,
"coi": { "status": "current", "expires": "2027-03-01", "verified": true }
}
]
}manuals are the instructions on the job. deliverables.templates are forms you want back; submissions are what the installer has returned. Every url is a link on this site, not a storage link: fetch it with your API key and it redirects to the file.
Each request carries the installer's insurance coi: status is current, expiring, expired, pending or none, and verified says whether we read the date off the certificate or the installer typed it. The certificate itself is only readable once you select that installer.
POST/api/v1/jobs/:id/select
Pick an installer. They get the customer's details, you get theirs, everyone else is told the job is filled. The job moves to Assigned.
curl -X POST "https://installers.org/api/v1/jobs/recABC123/select" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider_id": "recP1" }'
{ "ok": true, "status": "Assigned",
"provider": { "name": "Miller's Assembly", "phone": "...", "email": "..." } }POST/api/v1/jobs/:id/status
Move the job along: Scheduled, In Progress, Completed, Closed, or Cancelled. The assigned installer can also advance it, but only you can cancel or close.
curl -X POST "https://installers.org/api/v1/jobs/recABC123/status" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "Scheduled", "scheduled_for": "2026-07-20" }'Rescheduling
Send scheduled_for without a status to reschedule. The job stays in its current status and the installer is notified. Add an optional timeslot to set a specific time or arrival window.
# Reschedule with a specific time
curl -X POST "https://installers.org/api/v1/jobs/recABC123/status" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_for": "2026-08-05",
"timeslot": { "type": "specific_time", "time": "09:00" }
}'
# Reschedule with an arrival window
curl -X POST "https://installers.org/api/v1/jobs/recABC123/status" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_for": "2026-08-05",
"timeslot": { "type": "arrival_window", "start": "08:00", "end": "12:00" }
}'timeslot is also accepted when setting status to Scheduled. Times are 24-hour format (HH:MM). The job response includes scheduledTimeslot with the saved value.
POST/api/v1/jobs/:id/notes
Message an installer about a job. Threads are private per installer. They can't see each other's messages.
curl -X POST "https://installers.org/api/v1/jobs/recABC123/notes" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{ "provider_id": "recP1", "body": "Customer says park in the alley." }'GET/api/v1/jobs/:id/notes
Read installer message threads on a job. Pass provider_id to get one thread, or omit it to get every thread on the job.
curl "https://installers.org/api/v1/jobs/recABC123/notes?provider_id=recP1" \
-H "Authorization: Bearer $INSTALLERS_KEY"
{
"messages": [
{
"id": "recN1", "author": "Partner", "authorName": "Acme Fitness",
"body": "Customer says park in the alley.",
"created": "2026-08-15T10:30:00.000Z"
},
{
"id": "recN2", "author": "Provider", "authorName": "Sam Miller",
"body": "Got it, thanks.",
"created": "2026-08-15T10:45:00.000Z"
}
]
}PATCH/api/v1/jobs/:id/notes
Add an internal note to a job. Internal notes are private to you - installers and customers never see them. Use these for order references, scheduling context, or anything your team needs to track.
curl -X PATCH "https://installers.org/api/v1/jobs/recABC123/notes" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{ "note": "Customer called - prefers morning install" }'
{
"ok": true,
"notes": [
{ "date": "2026-08-15T10:30:00.000Z", "text": "Order ref: ORD-10432" },
{ "date": "2026-08-15T14:00:00.000Z", "text": "Customer called - prefers morning install" }
]
}Each note is timestamped automatically. Notes are returned in the job detail response as partnerNotes.
GET/api/v1/jobs/:id/customer-messages
Read customer message threads on a job. Two threads exist: one between the customer and you (the partner), and one between the customer and the assigned installer. You can see both.
curl "https://installers.org/api/v1/jobs/recABC123/customer-messages" \
-H "Authorization: Bearer $INSTALLERS_KEY"
# Filter to one thread
curl "https://installers.org/api/v1/jobs/recABC123/customer-messages?thread=partner" \
-H "Authorization: Bearer $INSTALLERS_KEY"
{
"messages": [
{
"id": "recCM1", "thread": "partner", "author": "Customer",
"authorName": "Dana Reyes", "body": "When will the installer arrive?",
"created": "2026-08-15T10:30:00.000Z"
},
{
"id": "recCM2", "thread": "partner", "author": "Partner",
"authorName": "Acme Fitness", "body": "They're scheduled for Thursday morning.",
"created": "2026-08-15T10:45:00.000Z"
}
],
"unread": 0
}thread is partner or provider. Omit it to get both.
POST/api/v1/jobs/:id/customer-messages
Send a message to the customer on your thread. The customer sees it on their job page.
curl -X POST "https://installers.org/api/v1/jobs/recABC123/customer-messages" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{ "thread": "partner", "body": "Your installer is scheduled for Thursday." }'
{ "ok": true, "id": "recCM3" }Partners can only post to the partner thread.
PATCH/api/v1/jobs/:id/customer-messages
Mark customer messages as read. Pass thread to mark only one thread, or omit it for both.
curl -X PATCH "https://installers.org/api/v1/jobs/recABC123/customer-messages" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{ "thread": "partner" }'
{ "ok": true, "marked": 2 }POST/api/v1/reviews
Submit a review for the provider on a completed or closed job. One review per source per job. Use source to distinguish between your own rating and a review from the end customer.
# Partner review (your assessment of the installer)
curl -X POST "https://installers.org/api/v1/reviews" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{
"job_id": "recABC123",
"rating": 5,
"text": "Professional, fast, cleaned up after."
}'
{ "ok": true, "id": "recR1" }
# Customer review (from your end customer, via your integration)
curl -X POST "https://installers.org/api/v1/reviews" \
-H "Authorization: Bearer $INSTALLERS_KEY" \
-H "Content-Type: application/json" \
-d '{
"job_id": "recABC123",
"source": "customer",
"reviewer_name": "Dana Reyes",
"rating": 5,
"text": "Arrived on time, very careful with our furniture."
}'
{ "ok": true, "id": "recR2" }source defaults to partner (your own review). Set it to customer to submit on behalf of the end customer. Customer reviews require reviewer_name.
Reviews appear on the installer's public profile alongside their Google reviews. One partner review and one customer review per job.
Sandbox
Build against a test key first. Jobs posted with it behave exactly like real ones, except that no installer is ever told about them: they aren't broadcast, they never appear on anyone's job board, and they can't be claimed. Test in production and you email real tradespeople a job that doesn't exist, and one of them may drive to the address.
The mode lives in the key, not the payload, so a test value can never be mistaken for real data. Sandbox keys read ik_test_... and live keys read ik_live_..., so which one is deployed is visible at a glance. Everything else is identical: same endpoints, same responses, same webhooks.
Driving a test job
The events worth testing all need an installer to do something, and the sandbox has none. So you move a test job yourself. Each call runs the real code and fires the real webhook, so what you receive is identical to production.
POST/api/v1/jobs/:id/simulate
event is one of requested, selected, scheduled, in_progress, completed, closed, cancelled, message. Send scheduled twice to get a job.rescheduled, which is usually the one you most want to test.
# post a job with your TEST key, then walk it through the lifecycle
curl -X POST "https://installers.org/api/v1/jobs/recABC123/simulate" \
-H "Authorization: Bearer $INSTALLERS_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{ "event": "requested" }' # -> job.requested
curl ... -d '{ "event": "selected" }' # -> job.selected
curl ... -d '{ "event": "scheduled", "date": "2026-08-01" }' # -> job.scheduled
curl ... -d '{ "event": "scheduled", "date": "2026-08-05" }' # -> job.rescheduled
curl ... -d '{ "event": "completed" }' # -> job.completedSimulated activity comes from a fixed fictional installer, so names and contacts render the way they will in production. Real jobs cannot be simulated: they move when an installer moves them.
Webhooks
Rather than polling, have us POST a signed JSON event to your endpoint whenever one of your jobs changes. Set the endpoint and get your signing secret on the Team Notifications page. Useful for telling the customer the installer moved the date, or keeping your own order record current.
Events
job.created, job.requested, job.selected, job.scheduled, job.rescheduled, job.in_progress, job.completed, job.closed, job.cancelled, job.reopened, message.created (installer messages only), customer_message.created (customer messages), deliverable.submitted.
POST your-endpoint
X-Installers-Event: job.rescheduled
X-Installers-Delivery: evt_9f2c...
X-Installers-Signature: t=1770000000,v1=8d4a...
{
"id": "evt_9f2c...",
"type": "job.rescheduled",
"created": "2026-07-18T14:03:11.000Z",
"data": {
"job": {
"id": "recABC123", "status": "Scheduled", "service": "Fitness Equipment Assembly",
"zip": "40204", "city": "Louisville", "scheduledFor": "2026-07-24",
"scheduledTimeslot": { "type": "arrival_window", "start": "08:00", "end": "12:00" },
"externalRef": "ORD-10432", "customerToken": "a1b2c3d4e5f6g7h8",
"selectedProviderId": "recP1",
"customer": {
"name": "Dana Reyes", "phone": "(502) 555-0148",
"email": "dana@example.com", "address": "1420 Bardstown Rd"
},
"provider": {
"id": "recP1", "name": "Miller's Assembly",
"phone": "(502) 555-0110", "email": "sam@millers.example.com"
}
},
"from": "2026-07-22", "to": "2026-07-24",
"timeslot": { "type": "arrival_window", "start": "08:00", "end": "12:00" },
"by": "Provider"
}
}Every event carries the customer block, so you can email or text them about the change without calling us back. provider is nulluntil you select an installer, and carries their name, phone and email from then on. That mirrors the rule everywhere else: an installer's contact details are yours once you have chosen them, not before.
Because payloads contain customer contact details, treat your endpoint as you would any other place that data lives: https only, and don't log whole bodies where you wouldn't log a customer record.
Verify the signature before trusting the body. Anyone who learns your endpoint can POST to it; only we can sign. The header is t=<unix>,v1=<hex>, where the hex is HMAC-SHA256 of `${t}.${rawBody}`with your signing secret. Compare with a timing-safe equality check, and reject anything more than a few minutes old so an old body can't be replayed.
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return ok && fresh; // use the RAW body, not the re-serialized object
}Delivery. Reply 2xx quickly and do the work afterwards: we time out at 10 seconds and count that as a failure. Failures retry at 1m, 5m, 30m, 2h and 6h, then stop and stay in the log with a Resend button. Delivery is at-least-once, so dedupe on id: a timeout after you committed looks identical to a failure from our side, and we resend.
Service values
The service field accepts either the slug (recommended) or the display label. Slugs are stable and will never change; labels may be adjusted over time.
| Slug (recommended) | Display label |
|---|---|
| above-ground-pool-installation | Above Ground Pool Installation |
| appliance-installation | Appliance Installation |
| appliance-repair | Appliance Repair |
| art-installation-picture-hanging | Art Installation / Picture Hanging |
| automotive-accessory-installation | Automotive Accessory Installation |
| basketball-goal-installation | Basketball Goal Installation |
| basketball-goal-relocation | Basketball Goal Relocation |
| basketball-goal-repair | Basketball Goal Repair |
| bicycle-assembly | Bicycle Assembly |
| bicycle-repair | Bicycle Repair |
| blinds-window-treatment-installation | Blinds / Window Treatment Installation |
| cabinet-installation | Cabinet Installation |
| carport-installation | Carport Installation |
| childrens-toy-assembly | Childrens Toy Assembly |
| christmas-holiday-lighting-installation | Christmas / Holiday Lighting Installation |
| cubicle-office-furniture-installation | Cubicle & Office Furniture Installation |
| delivery-services | Delivery Services |
| e-bike-assembly | E-Bike Assembly |
| fence-installation | Fence Installation |
| fitness-equipment-assembly | Fitness Equipment Assembly |
| fitness-equipment-relocation | Fitness Equipment Relocation |
| fitness-equipment-repair-maintenance | Fitness Equipment Repair / Maintenance |
| furniture-assembly | Furniture Assembly |
| game-room-assembly | Game Room Assembly |
| garage-closet-storage-installation | Garage & Closet Storage Installation |
| gazebo-outdoor-shade-installation | Gazebo & Outdoor Shade Installation |
| greenhouse-solarium-installation | Greenhouse & Solarium Installation |
| grill-outdoor-cooking-assembly | Grill & Outdoor Cooking Assembly |
| grill-cleaning | Grill Cleaning |
| grill-repair | Grill Repair |
| home-maintenance-handyman-services | Home Maintenance & Handyman Services |
| in-store-product-assembly | In-store Product Assembly |
| landscape-lighting-installation | Landscape Lighting Installation |
| outdoor-power-equipment-assembly | Outdoor Power Equipment Assembly |
| playset-swing-set-playhouse-assembly | Playset / Swing Set / Playhouse Assembly |
| playset-swing-set-playhouse-relocation | Playset / Swing Set / Playhouse Relocation |
| playset-swing-set-playhouse-repair | Playset / Swing Set / Playhouse Repair |
| privacy-booth-office-pod-installation | Privacy Booth / Office Pod Installation |
| retail-fixture-installation | Retail Fixture Installation |
| sauna-assembly | Sauna Assembly |
| sign-installation | Sign Installation |
| smart-home-device-installation | Smart Home Device Installation |
| storage-shed-assembly | Storage Shed Assembly |
| storage-shed-relocation | Storage Shed Relocation |
| trampoline-assembly | Trampoline Assembly |
| trampoline-relocation | Trampoline Relocation |
| trampoline-repair | Trampoline Repair |
| tv-mounting-home-theater-setup | TV Mounting / Home Theater Setup |
Errors
| 401 | unauthorized | Missing or invalid API key. |
| 400 | unknown_service | The service value must be a valid slug or label from the list above. |
| 400 | invalid_zip | ZIP must be 5 digits. |
| 400 | customer_contact_required | Name plus a phone or email is required. |
| 409 | not_open | The job is no longer accepting selections. |
| 409 | already_reviewed | A review already exists for this job and source. |
| 429 | rate_limited | Slow down: 60 job posts per minute. |
Questions? Get in touch.
Want to build against it?
Tell us what you sell and where you ship it. If we don't have installers in your customers' areas yet, we'll say so rather than sign you up and let you down.
Talk to us