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%20Assembly" \
-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", "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.
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",
"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",
"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" }'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." }'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 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), 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",
"externalRef": "ORD-10432", "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", "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 must match one of these exactly.
Above Ground Pool InstallationAppliance InstallationAppliance RepairArt Installation / Picture HangingAutomotive Accessory InstallationBasketball Goal InstallationBasketball Goal RelocationBasketball Goal RepairBicycle AssemblyBicycle RepairCabinet InstallationCarport InstallationChildrens Toy AssemblyChristmas / Holiday Lighting InstallationCubicle & Office Furniture InstallationDelivery ServicesE-Bike AssemblyFence InstallationFitness Equipment AssemblyFitness Equipment RelocationFitness Equipment Repair / MaintenanceFurniture AssemblyGame Room AssemblyGarage & Closet Storage InstallationGazebo & Outdoor Shade InstallationGreenhouse & Solarium InstallationGrill & Outdoor Cooking AssemblyGrill CleaningGrill RepairHome Maintenance & Handyman ServicesIn-store Product AssemblyLandscape Lighting InstallationOutdoor Power Equipment AssemblyPlayset / Swing Set / Playhouse AssemblyPlayset / Swing Set / Playhouse RelocationPlayset / Swing Set / Playhouse RepairPrivacy Booth / Office Pod InstallationRetail Fixture InstallationSauna AssemblySign InstallationSmart Home Device InstallationStorage Shed AssemblyStorage Shed RelocationTrampoline AssemblyTrampoline RelocationTrampoline RepairTV Mounting / Home Theater SetupErrors
| 401 | unauthorized | Missing or invalid API key. |
| 400 | unknown_service | The service value must match 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. |
| 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