SubSub Integrations & API

Everything that connects to SubSub, in one place: jobs from your CRM, move-in and move-out inspections from your inspection app, and the reads an integration is built on. One API key, no SDK, and a webhook address for every system that cannot set an HTTP header.

The API is part of the Scale plan. Create your key in SubSub under My account → Integrations → API keys. It is shown once, when you create it: SubSub stores only a fingerprint of it, so nobody here can read it back to you.

Overview

What you can connect

Will it work with mine?

Built in

Nothing to map

SubSub reads their own field names. One address into their webhook step.

  • JobNimbus
Through an automation tool

Whatever they connect to

One webhook step, mapping their fields onto SubSub's. Works today.

  • Zapier
  • Make
  • n8n
Everything else

Anything that sends a request

A CRM's own outgoing webhook, a script, a scheduler, or Postman to try it by hand.

  • Postman
  • cURL
  • Your own code

Only the first tier is a claim about SubSub. The other two are a claim about the thing doing the sending, which is why they are separated: a system in the second or third tier works today and always has, and a system in the first needs somebody here to have had a real payload from it in front of them. That is the whole difference, and it is the difference that decides whether anything needs mapping.

Ways to start

Pick whichever matches who is setting it up. None of them needs anything installed from SubSub.

For developers

Using Postman

Every SubSub API call, ready to send. Import the collection, paste your API key into its token variable, and post a test job before you write any code.

Try Postman + SubSub
Not a developer?

Zapier

Connect SubSub to thousands of apps with Zapier's drag-and-drop editor. When something happens in your CRM, a form or a spreadsheet, a job lands in SubSub. No code.

Zapier
Already use one?

Make or n8n

Add an HTTP or webhook step that posts to your SubSub webhook address, and map your fields onto SubSub's. Same idea as Zapier, in the tool you already run.

Make and n8n
No extra tools

Your CRM's own webhook

Many CRMs can send a webhook on their own when a job is scheduled. Paste your SubSub webhook address into that step. JobNimbus needs no field mapping at all.

Use the webhook address
Your own code

cURL or any language

One POST with a JSON body and a bearer token. Anything that can send an HTTPS request works: a script, a scheduled job, your own back office.

See a complete example

What happens next

The job lands on your Jobs screen with its trades unassigned, and it counts toward Unassigned trade slots on your dashboard. From there it is the ordinary SubSub flow: assign a contractor per trade, or ask several of yours to quote it, and SubSub checks their insurance and license before the work order goes out.

Get started with SubSub

Connect your CRM

When a job gets scheduled in your CRM, send it here. It arrives in SubSub as a job with its trades unassigned (the queue you open SubSub to clear), and you pick who does the work. There are two ways in, because most of the systems that would send you a job cannot set an HTTP header.

The endpoint

POST https://api.subsub.work/api/v1/jobs
Authorization: Bearer ssk_your_token_here
Content-Type: application/json

No header? Use the webhook address

Most CRMs and automation builders have a step that takes a URL and nothing else. There is no field for an Authorization header, so a token that can only arrive in a header cannot arrive at all. For those, the token travels in the path:

POST https://api.subsub.work/api/v1/hooks/generic/<your-webhook-token>
Content-Type: application/json

SubSub builds that address for you — it is on the same panel as the token, beside it, at the moment you create it. It is not a weaker door: every check the header endpoint makes, this one makes. But the token is in the address, so treat the whole URL like a password.

The segment before the token names which system is sending, and that is the only thing it changes: SubSub reads that system's field names instead of its own.

UseForField names it expects
generic Zapier, Make, n8n, your own script — and any CRM not listed below SubSub's own, the ones in the tables on this page
jobnimbus JobNimbus automations Theirs. jnid, date_start, address_line1 and the rest — nothing to map.

A system with no entry of its own is not unsupported. It uses generic, and whatever sends the request maps its fields onto SubSub's on the way out — in Zapier or Make that is the webhook step's own field mapping, and it takes a minute. An entry is added above when somebody has a real payload from that system in front of them: inventing plausible field names would produce an integration that looks supported and fails silently on first contact, which is worse for everybody than saying generic.

Trades arrive as your own words

No CRM has a concept of a trade. What yours sends is a job type, a status and tags — your own words, and nothing in them means roofing until somebody says so. So the webhook address does not ask for trades. It reads those words and looks them up against the rules on your account, under My account → Integrations → Job types and trades. Rules apply to every system you post from, so it is one list however many you connect.

A job whose words match nothing still arrives. It lands with no trades, the reply says so, and the word goes on a list on that same screen where one tap answers it. Refusing the job would be worse in the way that is hardest to notice: your CRM would not get a 200, so it would retry, so it would keep not getting one — and nobody would be told the work never reached SubSub.

HTTP/1.1 201 Created

{
  "ok": true,
  "source": "generic",
  "duplicate": false,
  "jobId": "5b0f…",
  "trades": [],
  "needsTrades": true,
  "unmapped": ["type:Roof Replacement"],
  "url": "https://app.subsub.work/?job=5b0f…"
}

Watch needsTrades while you are still setting up. It is the difference between a job that landed ready to assign and one waiting on a word nobody has translated yet.

A complete example

curl -X POST https://api.subsub.work/api/v1/jobs \
  -H "Authorization: Bearer $SUBSUB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "externalId": "CRM-1041",
    "source": "generic",
    "title": "Reroof — 14 Alder Way",
    "trades": ["roofing", "gutters"],
    "date": "2026-10-14",
    "time": "08:00",
    "address": "14 Alder Way",
    "area": "Seattle",
    "zip": "98101",
    "client": "M. Okafor",
    "sqft": 2400,
    "stories": 2,
    "scope": "Tear off and replace, 30yr architectural."
  }'
HTTP/1.1 201 Created

{
  "ok": true,
  "duplicate": false,
  "jobId": "5b0f…",
  "trades": ["roofing", "gutters"],
  "url": "https://app.subsub.work/?job=5b0f…"
}

Required fields

Five things, and only five. Everything a job can carry is accepted, but requiring a square footage no CRM holds would make the integration fail over a number nobody needs — so required here means the job cannot do its work without it.

FieldTypeWhat it is
externalId required string Your own id for this job. This is what makes a retry safe — see below. Max 200 characters.
title required string What the job is called. Max 200 characters.
trades required string[] At least one. These become the unassigned slots you fill. See the list below.
date required string YYYY-MM-DD. The scheduled day.
address or propertyId required string Where the work is. Use propertyId for a building already on your account, address for anywhere else.

Optional fields

FieldTypeWhat it is
source optionalstring Which system it came from: jobnimbus, acculynx, servicetitan, housecall_pro, roofsnap or other. Defaults to other.
time optionalstring 24-hour HH:MM. Defaults to 07:00.
client optionalstring The homeowner or customer name.
area, zip optionalstring City and postal code.
scope optionalstring What the work involves. This is what a contractor reads on the work order.
sqft, stories optionalnumber Size of the building.
materialSource, materialsPaidBy optional stringWhere materials come from and who pays for them.
materialSupplier, materialBranch optional stringThe supply house and which branch of it. Accepted and stored; a job with neither is the ordinary case.
notes optionalstring Anything else worth carrying across.

Retries will not duplicate

Webhooks retry. If your CRM does not get a 200 or 201 back, it sends again — and without something to recognize the second delivery by, one scheduled job becomes four jobs on your roster and four contractors asked to show up on a Tuesday.

That is what externalId is for. Send the same one twice and the second call returns 200 with "duplicate": true and the id of the job that already exists. Nothing is created. You can retry as many times as you like.

HTTP/1.1 200 OK

{
  "ok": true,
  "duplicate": true,
  "jobId": "5b0f…",
  "url": "https://app.subsub.work/?job=5b0f…"
}

Ids are scoped to your account and your source, so another SubSub customer using the same CRM with the same job number does not collide with you.

Trades

Use these exact ids in trades:

roofing siding windows_doors gutters soffit_fascia coping masonry solar framing concrete foundation excavation demolition electrical plumbing hvac insulation drywall painting flooring tile_stone cabinets_counters trim_carpentry deck_fence hardscaping landscaping garage_doors restoration cleaning

Send inspections

The same token posts move-in and move-out inspections, for an account that keeps buildings. Already walking units in an inspection app, a tablet form or a spreadsheet? Post the walk and it lands in SubSub as a draft inspection with its rooms and conditions on it — ready to read, finish, and raise the work from.

POST https://api.subsub.work/api/v1/inspections
Authorization: Bearer ssk_your_token_here
Content-Type: application/json

Or, where there is no field for a header, the same webhook address with the object on the end:

POST https://api.subsub.work/api/v1/hooks/generic/<your-webhook-token>/inspections
Content-Type: application/json

What to send

FieldRequiredWhat it is
externalIdYes Your own id for the walk. A retry carrying the same one updates nothing and returns the inspection that already exists, rather than creating a second.
kindYes move_in, move_out or checkup (a one-off walk in the middle of a lease). Most spellings are read — Move Out, moveout, check-out, routine. A word SubSub cannot place is refused rather than guessed: a move-in and a move-out are opposite documents.
property or propertyIdYes The building. propertyId is exact; property is matched whole and case-insensitively against the name and the address of your own buildings. Two matches are refused, so send the id where a name is not unique. GET /api/v1/properties lists them with their ids.
roomsYes An array, one entry per room or item, each with a name. A bare string is a room name with nothing said about it yet.
rooms[].statusNo Their condition, in your own words — see below. Left out, the room arrives not checked.
rooms[].noteNo What was written about the room.
unitNoThe unit, as it reads on the door: 10B, Apt 12.
tenantNameNoWho was moving. Not a SubSub user — the person moving out may have no login left and the one moving in may not have one yet.
inspectedOnNoThe day it was walked, as YYYY-MM-DD, which is not the day it was typed up.
propertyIdNoExact building id, in place of property.
sourceNoWhere it came from: generic, or other for your own system. The webhook address names this itself.

Photographs are not accepted here. A webhook carries JSON and a photograph is bytes; the rooms, the conditions and the notes arrive, and pictures are added on the inspection itself. Common field names are read as well as SubSub's own — external_id, items, condition, notes, completed_at and the rest — so most systems need no mapping.

Conditions arrive as your own words

Every inspection app has its own scale: pass and fail, good and fair and poor, 1 to 5, A to C. SubSub has four room statuses — not checked, OK, follow-up and fail — and reads the ordinary words for the last three without being told.

A word it does not know lands the room not checked, which is deliberate: anything else would assert that somebody walked the room and found nothing wrong. The walk still arrives — it is never refused over a word — and the reply says which words were not understood, in unmapped, with needsAnswers counting the rooms left without one. Say what those mean once under My account → Integrations → Inspections from your inspection app and every walk after that reads properly.

What comes back

{
  "ok": true,
  "source": "generic",
  "duplicate": false,
  "inspectionId": "9f2c…",
  "status": "draft",
  "rooms": 11,
  "needsAnswers": 2,
  "unmapped": ["Grade C"],
  "url": "https://app.subsub.work/?inspection=9f2c…"
}

It is always a draft, and nothing finishes it but a person. Finishing is what makes the inspection a document somebody can quote back, what writes its summary and what lets it be sent to the building's owner — so it is one tap on the screen rather than something a webhook does. The rooms, the notes and the photographs are all editable until then.

An account with no buildings answers no_buildings: there are no units to walk. That is a different gate from the jobs endpoint, which a general contractor can use.

Using Postman

The SubSub API collection holds every call on this page with a working example body: the three reads, posting a job, posting an inspection, and the webhook-address versions of both. In Postman, choose Import, then Link, and paste:

https://api.subsub.work/api/v1/postman.json

Or download the collection and import the file. Then open the collection's Variables, paste your API key into token, and send Who this token belongs to first: it is a read, so it tests the key without creating anything. Make the key in SubSub under My account → Integrations → API keys.

Zapier

Not a developer? This is the way in.

SubSub has a Zapier app, so a Zap can create a job without anybody pasting a URL: the token is a Zapier connection, and Trades and Property are dropdowns rather than text boxes. It uses this endpoint — Zapier can set a header, so it does not need the webhook address above.

It is a private app while it is new, which means you join it by invitation rather than finding it in Zapier's directory. Ask us for the link. If you would rather not wait, a Webhooks by Zapier step pointed at your generic webhook address does the same job today — the app is convenience and findability, not capability.

Make and n8n

Neither needs an app from SubSub. Add an HTTP step (Make) or an HTTP Request node (n8n), set it to POST to your generic webhook address, and map your fields onto SubSub's: externalId, title, trades, date and an address or propertyId are the five it needs. Both tools can set a header too, so the Authorization: Bearer endpoint works from either if you prefer it.

Reference

The reads, the errors and how to keep a key safe. These apply to every endpoint on this page.

Three reads, for building an integration

Both take the same Authorization: Bearer header, and both are checked the same way a POST is — same plan gate, same token lookup. A read endpoint that is a lighter door is a lighter door.

EndpointWhat it answers
GET /api/v1/me Which account this token belongs to. Use it to check a key before anything depends on it — otherwise the first thing anybody learns about a wrong token is a job that did not arrive. It names the account, never a person: no person made the call.
GET /api/v1/trades Every trade id with a label, so a form can offer a list instead of asking somebody to type windows_doors from memory. It is the same list that validates trades on a job, so what you pick from and what is accepted cannot disagree.
GET /api/v1/properties The buildings on your account — id, name, address and a ready-made label — so propertyId can be a list too. A property id is a UUID, and one typed from memory is a job refused as property_not_found. It offers exactly the buildings a job will be accepted for. Paged 100 at a time: add ?page=1 for the next, and a short page is the last one.

When something is wrong

Errors name the field, with a code your integration can branch on and a message for whoever ends up reading the log.

HTTP/1.1 400 Bad Request

{
  "error": "invalid_request",
  "message": "Some fields were missing or not understood. See `errors` for which.",
  "docs": "https://subsub.work/developers",
  "errors": [
    { "field": "trades", "code": "unknown_trade", "message": "\"juggling\" is not a trade SubSub knows." },
    { "field": "date", "code": "invalid", "message": "date must be a real calendar day, as YYYY-MM-DD." }
  ]
}
StatusErrorWhat to do
400invalid_requestRead errors; each names a field.
400invalid_jsonThe body was not valid JSON.
401missing_tokenSend Authorization: Bearer <token>.
401invalid_tokenThe token is wrong or has been revoked. Create a new one.
403scale_requiredThe account is on Basic. The API is part of Scale.
403integration_pausedThe account has switched this integration off under My account → Integrations. Nothing is stored; it starts working again the moment it is switched back on.
403not_a_hiring_accountThis account is set up as a subcontractor, so it has nobody to assign.
404property_not_foundNo building with that propertyId on this account.
429rate_limited300 requests per minute per account. Back off and retry.

Keeping the token safe