Who it is for
The API and webhooks come with the Fleet plan (and its free trial). They are for a business connecting crewduo to its own systems - an accounting or ERP tool, a data warehouse, a website form, a dashboard - or for the developer it hires to do that. Each business has its own keys and its own webhook endpoints; a key only ever sees the business that made it.
- The API reads the core records and creates clients, requests and jobs. Records cannot be edited or deleted through it.
- Webhooks tell your system that something happened (an invoice was sent, a visit moved); your system then reads what through the API.
API keys and permissions
An owner or admin makes keys in the crewduo web app:
- Open Business > Integrations and click Manage API keys.
- Click Make a key, give it a name you will recognise (for example Warehouse sync), tick what it may do and choose when it expires (never, 30 days, 90 days or a year).
- Copy the key from the yellow card. It starts with
cdk_and is shown once; afterwards only its first characters are listed.
A key can never do more than the person who made it could at that moment. Make one key per integration so you can revoke one without breaking the others; a business can have 25 active keys. Revoking a key takes effect on the next call.
| Tick in the app | Permission | Opens |
|---|---|---|
| Read clients | CanViewClients | GET /clients, GET /clients/{id} |
| Create clients | CanCreateClient | POST /clients |
| Read requests | CanViewRequests | GET /requests, GET /requests/{id} |
| Create requests | CanCreateRequest | POST /requests |
| Read quotes | CanViewQuotes | GET /quotes, GET /quotes/{id} |
| Read jobs | CanViewJobs | GET /jobs, GET /jobs/{id} |
| Create jobs | CanCreateJob | POST /jobs |
| Read visits | CanViewAllVisit | GET /visits, GET /visits/{id} |
| Read invoices and payments | CanManageBilling | GET /invoices, GET /payments and one of each |
| Subscribe webhooks | CanManageIntegrations | POST /hooks, DELETE /hooks/{id} |
GET /me works with any key.
Authentication
Send the key in an X-Api-Key header, or as a bearer token. Every call is HTTPS; there is no version header or query parameter to send.
curl https://api.crewduo.com/api/public/v1/me \
-H "X-Api-Key: cdk_your_key_here"
curl https://api.crewduo.com/api/public/v1/me \
-H "Authorization: Bearer cdk_your_key_here"
The base URL for every business is https://api.crewduo.com/api/public/v1. GET /me answers which business the key belongs to and what it may do - a good test of a new key:
{
"organizationId": "01a14129-8ef3-7c55-9a52-1f4be0c3d6a2",
"organizationName": "YourCompany Heating & Cooling",
"keyName": "API key: Warehouse sync",
"permissions": ["CanViewClients", "CanCreateClient", "CanViewJobs"]
}
keyName is the key's name as given in the app, after API key: . Zapier shows a connection as the business name with this key name.
Keys open only the /api/public/v1 routes; the address the crewduo apps sign in with does not accept a key, and
a person's sign-in token does not open the public API. Keep keys on a server - never in a web page or a mobile app.
Endpoints
Everything is JSON with camelCase names. Ids are UUIDs, except addresses, which are whole numbers. Moments
(updatedAt, preferredTime, sentOn, decidedOn, a visit's start,
end, checkInAt, checkOutAt) are UTC and end in Z. Business dates
(quotedOn, expiresOn, issuedOn, dueOn, paidOn,
preferredDate) have no zone: they are the day in the business's own calendar, so read the date part.
Money is a number in the business's currency. A field with no value is null, never left out.
| Call | Does |
|---|---|
GET /me | The key's business, name and permissions |
GET /clients · GET /clients/{id} | Clients, with their addresses |
POST /clients | Create a client, optionally with an address |
GET /requests · GET /requests/{id} | Requests (work asked for) |
POST /requests | Create a request for an existing client |
GET /quotes · GET /quotes/{id} | Quotes |
GET /jobs · GET /jobs/{id} | Jobs |
POST /jobs | Create a one-time job, ready to be booked |
GET /visits · GET /visits/{id} | Visits (the booked appointments) |
GET /invoices · GET /invoices/{id} | Invoices |
GET /payments · GET /payments/{id} | Payments |
POST /hooks · DELETE /hooks/{id} | Subscribe and unsubscribe a webhook (REST hooks) |
Lists take limit, offset and updatedSince - see Pagination. A record of another business, or one that does not exist, answers 404.
Clients
curl "https://api.crewduo.com/api/public/v1/clients?limit=1" -H "X-Api-Key: $CREWDUO_KEY"
{
"data": [
{
"id": "01a14129-a9a2-748b-ab69-8ad7503159f9",
"firstName": "Robin",
"lastName": "Keyes",
"businessName": null,
"email": "[email protected]",
"phone": "902-555-0142",
"leadSource": "Website",
"updatedAt": "2026-10-15T20:03:31.922519Z",
"addresses": [
{
"id": 799,
"address1": "12 Birch Lane",
"address2": null,
"city": "Halifax",
"province": "Nova Scotia",
"postalCode": "B3H 1A1",
"countryCode": "CA"
}
]
}
],
"limit": 1,
"offset": 0,
"hasMore": true
}
POST /clients needs a firstName, lastName or businessName. An address needs address1, city and province; leave countryId out for Canada. The answer is the new client, as above.
curl https://api.crewduo.com/api/public/v1/clients -H "X-Api-Key: $CREWDUO_KEY" -H "Content-Type: application/json" -d '{
"firstName": "Robin", "lastName": "Keyes", "email": "[email protected]", "phone": "902-555-0142",
"leadSource": "Website",
"address": { "address1": "12 Birch Lane", "city": "Halifax", "province": "Nova Scotia", "postalCode": "B3H 1A1" }
}'
Requests
{
"id": "01a14129-acd0-7e7e-93f9-df30344a1f68",
"number": "REQ-0001",
"statusId": 1,
"status": "Submitted",
"requestTypeId": 2,
"requestType": "Repair",
"clientId": "01a14129-a9a2-748b-ab69-8ad7503159f9",
"addressId": 799,
"preferredTime": "2026-10-20T12:00:00Z",
"notes": "Dripping tap in the kitchen",
"updatedAt": "2026-10-15T20:03:32.719908Z"
}
POST /requests takes clientId (required), addressId (the client's first address when left out),
requestType (Maintenance when left out; also Repair, Followup, Warranty,
Estimates, Quote, Installation), preferredTime (a date-time with its offset) and notes.
The business's owners get the same notification as for a request made in the app.
curl https://api.crewduo.com/api/public/v1/requests -H "X-Api-Key: $CREWDUO_KEY" -H "Content-Type: application/json" \
-d '{ "clientId": "01a14129-a9a2-748b-ab69-8ad7503159f9", "requestType": "Repair", "notes": "Dripping tap in the kitchen" }'
Request status is Submitted, Booked, Closed or Canceled.
Quotes
{
"id": "01a1412b-0c44-7d2e-8f61-3b5a7e9c2d10",
"number": "Q-0001",
"statusId": 3,
"status": "Accepted",
"clientId": "01a14129-a9a2-748b-ab69-8ad7503159f9",
"addressId": 799,
"requestId": "01a14129-acd0-7e7e-93f9-df30344a1f68",
"quotedOn": "2026-10-15T16:10:45.45778",
"expiresOn": "2026-11-14T00:00:00",
"sentOn": "2026-10-15T20:12:03.118204Z",
"decidedOn": "2026-10-15T23:46:39.119426Z",
"updatedAt": "2026-10-15T23:46:39.204511Z"
}
Quote status is Drafted, Sent, Accepted, Rejected, Expired, Void or ChangesRequested.
Jobs
{
"id": "01a14129-adce-7e4b-ad1c-3816f5312f8a",
"number": "JOB-0001",
"statusId": 2,
"status": "Ready",
"description": "Replace kitchen faucet cartridge",
"clientId": "01a14129-a9a2-748b-ab69-8ad7503159f9",
"addressId": 799,
"quoteId": null,
"requestId": null,
"preferredDate": "2026-10-21T00:00:00",
"updatedAt": "2026-10-15T20:03:33.028724Z"
}
POST /jobs takes clientId and description (both required), addressId (the client's first
address when left out) and preferredDate (the day the client would like, a wish - the office books the visit). The job is
created Ready. Job status is Blocked, Ready, InProgress, Completed or Canceled.
Visits
{
"id": "01a1412c-1f9e-71b0-a4d2-6c0e8b5f3a77",
"number": "JOB-0001-V1",
"statusId": 2,
"status": "Scheduled",
"jobId": "01a14129-adce-7e4b-ad1c-3816f5312f8a",
"clientId": "01a14129-a9a2-748b-ab69-8ad7503159f9",
"addressId": 799,
"start": "2026-10-21T13:00:00Z",
"end": "2026-10-21T14:30:00Z",
"checkInAt": null,
"checkOutAt": null,
"updatedAt": "2026-10-16T12:05:15.20298Z"
}
Visit status is Unassigned, Scheduled, Confirmed, InProgress, Completed, Skipped, Canceled, Waitlisted or OnHold.
Invoices and payments
{
"id": "01a1412f-32b5-7499-bcfc-da3d25b0e3bf",
"number": "INV-0001",
"statusId": 4,
"status": "PartiallyPaid",
"clientId": "01a14129-a9a2-748b-ab69-8ad7503159f9",
"addressId": 799,
"issuedOn": "2026-10-21T13:00:00",
"dueOn": "2026-11-04T13:00:00",
"subtotal": 220,
"taxAmount": 28.6,
"total": 248.6,
"paidAmount": 100,
"balance": 148.6,
"updatedAt": "2026-10-21T18:06:08.081183Z"
}
{
"id": "01a1412f-5205-71c8-b5e6-cd3980efc5b8",
"invoiceId": "01a1412f-32b5-7499-bcfc-da3d25b0e3bf",
"amount": 100,
"typeId": 2,
"type": "Cheque",
"paidOn": "2026-10-21T13:00:00",
"reference": "Cheque 5783",
"statusId": 1,
"updatedAt": "2026-10-21T18:06:15.985268Z"
}
Invoice status is Draft, Pending, PartiallyPaid, Paid, Canceled,
WrittenOff, PaymentFailed, Revoked or NotStarted; balance is total less
paidAmount. Payment type is Cash, Cheque, BankTransfer, CreditCard,
InPersonCard, InteracETransfer, AccountCredit, CreditNote, ExternalAccounting or Other.
Hooks (REST hooks)
For tools such as Zapier that manage their own subscriptions. POST /hooks with targetUrl and, optionally, one
event (left out: every event). The answer includes the endpoint's signing secret, shown only here.
DELETE /hooks/{id} answers 204, and removes only hooks made this way - never an endpoint the office added by hand.
curl https://api.crewduo.com/api/public/v1/hooks -H "X-Api-Key: $CREWDUO_KEY" -H "Content-Type: application/json" \
-d '{ "targetUrl": "https://example.com/crewduo/webhooks", "event": "invoice.issued" }'
{
"id": "67c54f7b-cacd-4c0b-bd2d-2bc3b674f32e",
"targetUrl": "https://example.com/crewduo/webhooks",
"event": "invoice.issued",
"secret": "whsec_k3Qe...shown once..."
}
Pagination and updatedSince
Every list answers a page:
{ "data": [ ... ], "limit": 50, "offset": 0, "hasMore": true }
limit: records per page, 50 by default, at most 100.offset: how many to skip. Ask again withoffset+limitwhilehasMoreistrue.updatedSince: only records changed at or after this moment, as ISO 8601 with a zone -2026-10-01T00:00:00Zor2026-10-01T09:00:00-03:00(URL-encode the+of a positive offset as%2B).
Records come oldest change first. To keep a copy in step, remember the newest updatedAt you have stored and ask for
updatedSince that moment next time. The record with exactly that time comes back again, so store by id.
A record that changes while you are paging moves to the end of the list, where you will meet it again.
curl "https://api.crewduo.com/api/public/v1/jobs?updatedSince=2026-10-01T00:00:00Z&limit=100&offset=0" -H "X-Api-Key: $CREWDUO_KEY"
Errors
| Status | When | Body |
|---|---|---|
| 400 | The body is missing something or is not valid (no name for a client, an unknown request type, an address of another client) | { "error": "A client needs a firstName, lastName or businessName." } |
| 400 | A query parameter cannot be read (updatedSince=yesterday) | A standard validation problem: { "title": "One or more validation errors occurred.", "status": 400, "errors": { "updatedSince": [ ... ] } } |
| 401 | No key, a key that does not exist, one that was revoked or has expired - or the business's plan no longer includes API access (not on Fleet, or the trial or subscription ended) | Empty |
| 403 | The key does not carry the permission the call needs | Empty |
| 404 | No such record in this business (also an id that is not a UUID) | { "error": "Not found." } (empty for a malformed id) |
| 429 | Over the rate limit | See Rate limits |
| 5xx | Something went wrong on our side | Retry with backoff |
Rate limits
Each key may make 120 calls a minute. The minute starts with the key's first call, and every call counts - a 400, 403 or 404 included. Each key has its own allowance, so one busy integration does not slow another; calls without a working key are counted against the caller's address. Every answer from the public API says where the key stands:
| Header | Means |
|---|---|
X-RateLimit-Limit | Calls allowed in the window (120) |
X-RateLimit-Remaining | Calls left in this window |
X-RateLimit-Reset | Seconds until the window starts again |
Past the limit the answer is 429 Too Many Requests with a Retry-After header (seconds) and a JSON body in crewduo's usual envelope (error carries the same message for clients that read only that):
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
Retry-After: 47
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 47
{
"success": false,
"data": null,
"errorCode": "RateLimited",
"errorMessages": ["Rate limit exceeded for this API key. Retry after 47 seconds."],
"error": "Rate limit exceeded for this API key. Retry after 47 seconds.",
"retryAfterSeconds": 47
}
Back off: wait Retry-After seconds (else X-RateLimit-Reset) before the next call, and slow down when
X-RateLimit-Remaining runs low. For a 5xx or a network error, retry with exponential backoff and jitter
(for example 1, 2, 4, 8 seconds), and give up after a few tries. Bulk work goes faster with limit=100 and updatedSince than with one call per record.
Webhooks
A webhook endpoint is an HTTPS address of yours that crewduo calls when something happens in the business. Add one in the app
(Business > Integrations > Manage webhooks > Add endpoint, pick the events or none for all) or through
POST /hooks. Either way you get a signing secret starting with whsec_, shown once.
A business can have 50 endpoints. The address must be https:// on a public host: addresses on a private network,
localhost or a bare IP of your office network are refused, and redirects are not followed.
Events
| Event | When |
|---|---|
client.saved | A client is created or edited |
client.credit_changed | A client's account credit balance changes |
request.created | A request arrives - from the office, the business's website, online booking or the API |
quote.issued | A quote is sent to a client |
quote.accepted | A client accepts a quote |
job.completed | A job is completed |
service.saved | A service or product is saved |
invoice.issued | An invoice is sent (not while it is a draft) |
invoice.voided | An invoice is voided or cancelled |
invoice.written_off | An invoice is written off |
payment.recorded | A payment is recorded |
payment.refunded | A payment is refunded |
visit.scheduled | A visit is scheduled |
visit.rescheduled | A visit is moved |
visit.reassigned | A visit goes to another technician or team |
visit.cancelled | A visit is cancelled |
Records brought in by an import send nothing. Send test in the app sends the event ping.
What arrives
A POST with a small JSON body that names the event and the record - nothing private travels to your address:
POST /crewduo/webhooks HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: crewduo-webhooks/1
X-Crewduo-Event: client.saved
X-Crewduo-Delivery: fee93179-48f9-492e-aa97-3dfded1a173a
X-Crewduo-Signature: t=1792093898,v1=43c9aa018b482e7a0acca52d806d5d66104579d0e968b1d809fe1fd3e33f373e
{"id":"fee93179-48f9-492e-aa97-3dfded1a173a","event":"client.saved","occurredAt":"2026-10-15T19:51:18.2905472Z","organizationId":"01a1411d-60fd-7b83-9705-34e52d508a7f","data":{"type":"Client","id":"01a1411e-77ca-748c-b8f9-2348e3a58fae"}}
id is the delivery's id, the same as X-Crewduo-Delivery; it stays the same on every retry and resend, so
use it to skip a delivery you already handled. data.type says which list to read the record from:
Client → GET /clients/{id}, Request → /requests, Quote →
/quotes, Job → /jobs, Visit → /visits, Invoice →
/invoices, Payment → /payments. service.saved (type Service) and
ping (type Organization) have no record to read. occurredAt is UTC. Answer with any 2xx within
10 seconds; do slow work after answering. The body is compact JSON exactly as signed - keep the raw text for the signature check.
Verifying signatures
X-Crewduo-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of
<t>.<raw body> keyed with your whole signing secret (including whsec_), in lower-case hex.
Check it against the raw body, before any JSON parsing, compare in constant time, and refuse a t more than
five minutes from your clock.
Node.js
// Node.js 18+. Express: app.post("/crewduo/webhooks", express.raw({ type: "application/json" }), handler)
// so req.body is the raw Buffer - verify req.body.toString("utf8") BEFORE parsing it.
const crypto = require("crypto");
function verifyCrewduoSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(String(header || "").split(",").map(p => p.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 || "")) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
// verifyCrewduoSignature(req.body.toString("utf8"), req.get("X-Crewduo-Signature"), process.env.CREWDUO_WEBHOOK_SECRET)
Python
# Python 3. Flask: raw = request.get_data() (bytes, before any JSON parsing)
import hashlib
import hmac
import time
def verify_crewduo_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
try:
parts = dict(part.strip().split("=", 1) for part in header.split(","))
t = int(parts["t"])
given = parts["v1"]
except (AttributeError, KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
# verify_crewduo_signature(request.get_data(), request.headers.get("X-Crewduo-Signature"), os.environ["CREWDUO_WEBHOOK_SECRET"])
C#
// .NET 8+. ASP.NET Core: read the raw body first -
// using var reader = new StreamReader(Request.Body); string raw = await reader.ReadToEndAsync();
using System.Security.Cryptography;
using System.Text;
static bool VerifyCrewduoSignature(string rawBody, string? header, string secret, int toleranceSeconds = 300)
{
string? t = null, v1 = null;
foreach (string part in (header ?? "").Split(','))
{
string[] kv = part.Trim().Split('=', 2);
if (kv.Length != 2) continue;
if (kv[0] == "t") t = kv[1];
else if (kv[0] == "v1") v1 = kv[1];
}
if (!long.TryParse(t, out long seconds) || v1 is null || v1.Length != 64) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > toleranceSeconds) return false;
byte[] expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
byte[] given;
try { given = Convert.FromHexString(v1); } catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(expected, given);
}
// VerifyCrewduoSignature(raw, Request.Headers["X-Crewduo-Signature"], secret)
Retries and the delivery log
A delivery that does not get a 2xx within 10 seconds is tried again after 1, 5 and 15 minutes, 1, 3, 6 and 12 hours, then 24 hours - eight retries after the first attempt, about two days - and then marked failed. An endpoint that fails 15 attempts in a row is switched off, with the reason shown in the app; fix the receiver and click Turn on. Deliveries go out within about half a minute of the event, one endpoint's in order. A redirect (3xx) counts as a failure. If the business's plan stops including API access, deliveries still waiting are not sent.
Recent deliveries on the webhooks page lists each delivery with its result (Delivered, Retrying, Gave up), the last status code or error and the number of attempts. Resend sends one again within about half a minute, with the same id. New secret rotates an endpoint's signing secret: the new one is shown once and the old one stops signing straight away, so update your receiver at the same time.
Zapier (beta)
crewduo's Zapier app is in beta, available by invitation while Zapier reviews it; it is not listed in Zapier's
public directory yet. On the Fleet plan, open Business > Integrations in the crewduo app and use the
Zapier (beta) card, or ask support for an invitation. You connect it with an API key
(it starts with cdk_): tick Subscribe webhooks and the matching read permission for triggers, and
Create clients / requests / jobs for actions (plus Read clients for Create Request and Create Job).
Each Zapier trigger uses one of the business's 50 webhook endpoints. Make, n8n or any tool that can call an API and receive a
webhook works the same way, with a key and an endpoint.
Help
Questions about the API, or something that does not behave as written here: [email protected]. Signed-in users also have the in-app help article Webhooks and API keys. This page was last checked against the API on October 15, 2026.