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?
Nothing to map
SubSub reads their own field names. One address into their webhook step.
- JobNimbus
Whatever they connect to
One webhook step, mapping their fields onto SubSub's. Works today.
- Zapier
- Make
- n8n
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.
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.
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.
ZapierMake 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 n8nYour 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 addresscURL 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.
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.
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.
| Use | For | Field 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.
| Field | Type | What 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
| Field | Type | What it is |
|---|---|---|
source optional | string | Which system it came from: jobnimbus, acculynx,
servicetitan, housecall_pro, roofsnap or
other. Defaults to other. |
time optional | string | 24-hour HH:MM. Defaults to 07:00. |
client optional | string | The homeowner or customer name. |
area, zip optional | string | City and postal code. |
scope optional | string | What the work involves. This is what a contractor reads on the work order. |
sqft, stories optional | number | Size of the building. |
materialSource, materialsPaidBy optional |
string | Where materials come from and who pays for them. |
materialSupplier, materialBranch optional |
string | The supply house and which branch of it. Accepted and stored; a job with neither is the ordinary case. |
notes optional | string | 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
| Field | Required | What it is |
|---|---|---|
externalId | Yes | 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. |
kind | Yes | 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 propertyId | Yes | 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. |
rooms | Yes | 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[].status | No | Their condition, in your own words — see below. Left out, the room arrives not checked. |
rooms[].note | No | What was written about the room. |
unit | No | The unit, as it reads on the door: 10B, Apt 12. |
tenantName | No | Who 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. |
inspectedOn | No | The day it was walked, as YYYY-MM-DD, which is not the day it was typed up. |
propertyId | No | Exact building id, in place of property. |
source | No | Where 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.
| Endpoint | What 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." }
]
}
| Status | Error | What to do |
|---|---|---|
| 400 | invalid_request | Read errors; each names a field. |
| 400 | invalid_json | The body was not valid JSON. |
| 401 | missing_token | Send Authorization: Bearer <token>. |
| 401 | invalid_token | The token is wrong or has been revoked. Create a new one. |
| 403 | scale_required | The account is on Basic. The API is part of Scale. |
| 403 | integration_paused | The account has switched this integration off under My account → Integrations. Nothing is stored; it starts working again the moment it is switched back on. |
| 403 | not_a_hiring_account | This account is set up as a subcontractor, so it has nobody to assign. |
| 404 | property_not_found | No building with that propertyId on this account. |
| 429 | rate_limited | 300 requests per minute per account. Back off and retry. |
Keeping the token safe
- It is shown once. SubSub stores a SHA-256 of it, not the token, so it cannot be read back — by you or by us. Lost it? Revoke it and make another.
- It belongs to one account and can only create jobs on that account.
- Pause any integration with its switch under My account → Integrations: what it sends is refused with
integration_pauseduntil it is switched back on. - Revoke it any time under My account → Integrations. Anything using it stops immediately; jobs it already created stay, because that work is real.
- Keep it server-side. Anyone holding it can create jobs on your account.