Skip to content
Canada Proudly Canadian. Your business data is stored in Canada.
crewduo
Home / Integrations / Developers
Developers

The crewduo API and webhooks

Read your clients, requests, quotes, jobs, visits, invoices and payments, create clients, requests and jobs, and hear about changes the moment they happen - with an API key, signed webhooks and a published rate limit.

On the Fleet plan JSON over HTTPS Data stored in Canada

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:

  1. Open Business > Integrations and click Manage API keys.
  2. 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).
  3. 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 appPermissionOpens
Read clientsCanViewClientsGET /clients, GET /clients/{id}
Create clientsCanCreateClientPOST /clients
Read requestsCanViewRequestsGET /requests, GET /requests/{id}
Create requestsCanCreateRequestPOST /requests
Read quotesCanViewQuotesGET /quotes, GET /quotes/{id}
Read jobsCanViewJobsGET /jobs, GET /jobs/{id}
Create jobsCanCreateJobPOST /jobs
Read visitsCanViewAllVisitGET /visits, GET /visits/{id}
Read invoices and paymentsCanManageBillingGET /invoices, GET /payments and one of each
Subscribe webhooksCanManageIntegrationsPOST /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.

CallDoes
GET /meThe key's business, name and permissions
GET /clients · GET /clients/{id}Clients, with their addresses
POST /clientsCreate a client, optionally with an address
GET /requests · GET /requests/{id}Requests (work asked for)
POST /requestsCreate a request for an existing client
GET /quotes · GET /quotes/{id}Quotes
GET /jobs · GET /jobs/{id}Jobs
POST /jobsCreate 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 with offset + limit while hasMore is true.
  • updatedSince: only records changed at or after this moment, as ISO 8601 with a zone - 2026-10-01T00:00:00Z or 2026-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

StatusWhenBody
400The 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." }
400A query parameter cannot be read (updatedSince=yesterday)A standard validation problem: { "title": "One or more validation errors occurred.", "status": 400, "errors": { "updatedSince": [ ... ] } }
401No 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
403The key does not carry the permission the call needsEmpty
404No such record in this business (also an id that is not a UUID){ "error": "Not found." } (empty for a malformed id)
429Over the rate limitSee Rate limits
5xxSomething went wrong on our sideRetry 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:

HeaderMeans
X-RateLimit-LimitCalls allowed in the window (120)
X-RateLimit-RemainingCalls left in this window
X-RateLimit-ResetSeconds 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

EventWhen
client.savedA client is created or edited
client.credit_changedA client's account credit balance changes
request.createdA request arrives - from the office, the business's website, online booking or the API
quote.issuedA quote is sent to a client
quote.acceptedA client accepts a quote
job.completedA job is completed
service.savedA service or product is saved
invoice.issuedAn invoice is sent (not while it is a draft)
invoice.voidedAn invoice is voided or cancelled
invoice.written_offAn invoice is written off
payment.recordedA payment is recorded
payment.refundedA payment is refunded
visit.scheduledA visit is scheduled
visit.rescheduledA visit is moved
visit.reassignedA visit goes to another technician or team
visit.cancelledA 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.

Connect crewduo to the rest of your business

The free plan runs the work for as long as you like. Want to see everything first? There is a 14-day free trial on Fleet, our top plan with every feature - no card needed.