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

This key posts jobs. It is a secret. Use it from your server only, never in a web page or a mobile app. The embed key for the installer finder is a different thing entirely: that one is public and belongs in your page source.

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 key

Send 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 installer

The 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.completed

Simulated 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-installationAbove Ground Pool Installation
appliance-installationAppliance Installation
appliance-repairAppliance Repair
art-installation-picture-hangingArt Installation / Picture Hanging
automotive-accessory-installationAutomotive Accessory Installation
basketball-goal-installationBasketball Goal Installation
basketball-goal-relocationBasketball Goal Relocation
basketball-goal-repairBasketball Goal Repair
bicycle-assemblyBicycle Assembly
bicycle-repairBicycle Repair
blinds-window-treatment-installationBlinds / Window Treatment Installation
cabinet-installationCabinet Installation
carport-installationCarport Installation
childrens-toy-assemblyChildrens Toy Assembly
christmas-holiday-lighting-installationChristmas / Holiday Lighting Installation
cubicle-office-furniture-installationCubicle & Office Furniture Installation
delivery-servicesDelivery Services
e-bike-assemblyE-Bike Assembly
fence-installationFence Installation
fitness-equipment-assemblyFitness Equipment Assembly
fitness-equipment-relocationFitness Equipment Relocation
fitness-equipment-repair-maintenanceFitness Equipment Repair / Maintenance
furniture-assemblyFurniture Assembly
game-room-assemblyGame Room Assembly
garage-closet-storage-installationGarage & Closet Storage Installation
gazebo-outdoor-shade-installationGazebo & Outdoor Shade Installation
greenhouse-solarium-installationGreenhouse & Solarium Installation
grill-outdoor-cooking-assemblyGrill & Outdoor Cooking Assembly
grill-cleaningGrill Cleaning
grill-repairGrill Repair
home-maintenance-handyman-servicesHome Maintenance & Handyman Services
in-store-product-assemblyIn-store Product Assembly
landscape-lighting-installationLandscape Lighting Installation
outdoor-power-equipment-assemblyOutdoor Power Equipment Assembly
playset-swing-set-playhouse-assemblyPlayset / Swing Set / Playhouse Assembly
playset-swing-set-playhouse-relocationPlayset / Swing Set / Playhouse Relocation
playset-swing-set-playhouse-repairPlayset / Swing Set / Playhouse Repair
privacy-booth-office-pod-installationPrivacy Booth / Office Pod Installation
retail-fixture-installationRetail Fixture Installation
sauna-assemblySauna Assembly
sign-installationSign Installation
smart-home-device-installationSmart Home Device Installation
storage-shed-assemblyStorage Shed Assembly
storage-shed-relocationStorage Shed Relocation
trampoline-assemblyTrampoline Assembly
trampoline-relocationTrampoline Relocation
trampoline-repairTrampoline Repair
tv-mounting-home-theater-setupTV Mounting / Home Theater Setup

Errors

401unauthorizedMissing or invalid API key.
400unknown_serviceThe service value must be a valid slug or label from the list above.
400invalid_zipZIP must be 5 digits.
400customer_contact_requiredName plus a phone or email is required.
409not_openThe job is no longer accepting selections.
409already_reviewedA review already exists for this job and source.
429rate_limitedSlow 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