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%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.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 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 Setup

Errors

401unauthorizedMissing or invalid API key.
400unknown_serviceThe service value must match 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.
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