Skip to main content

Webhooks

Webhooks let ContractorScope AI notify your system the moment an estimate is approved — no polling, no waiting. We make an HTTPS request to a URL you choose, with the details in the body.

This is a developer feature. If you don't run your own software, you can skip this page — everything here happens automatically inside the app.

What you'll get notified about

EventFires when
estimate.approvedAn estimate moves into an approved state

That's the complete list today. It fires when an estimate enters an approved state — from a customer approving the proposal link, someone on your team marking it accepted or approved, or a QuickBooks sync pushing that status back. All three send the same event.

It fires on the transition only. An estimate moving from accepted to approved is already approved, so it does not fire again.

If you subscribe to a name we don't recognise, registration is rejected with a message listing the valid events — you'll find out immediately rather than by never receiving anything.

Registering a URL

POST /api/v1/webhooks/endpoints
Content-Type: application/json

{
"url": "https://your-site.com/hooks/contractorscope",
"events": ["estimate.approved"],
"secret": "a-long-random-string-you-generate"
}

Requires an authenticated session with a company context — any member of the company can do this; there is no separate permission to grant. Rate-limited, and it accepts an optional Idempotency-Key header if you want to retry the registration call safely.

A successful call returns 201:

{
"status": "success",
"data": {
"endpoint_id": "…",
"url": "https://your-site.com/hooks/contractorscope",
"events": ["estimate.approved"]
}
}

Keep the endpoint_id — it's how you delete the endpoint later.

Choose the secret yourself and store it. We never generate one for you, and we never give it back: it's stripped from every response, including the list endpoint. If you omit it, we send your webhooks without a signature, and you'll have no way to tell our requests from anyone else's.

Managing endpoints

ListGET /api/v1/webhooks/endpoints
DeleteDELETE /api/v1/webhooks/endpoints/{endpoint_id}

Delete returns 404 if the id doesn't exist or belongs to another company.

You can register up to 10 endpoints per company. Past that, registration is rejected — delete one you no longer use first.

URL requirements

It must use https://. We won't send your customers' details over an unencrypted connection.

Use port 443, or no port at all. Ports 80 and 587 are also accepted at registration, but only 443 is useful: we always speak TLS, so a URL on :80 or :587 registers cleanly and then fails at connect. What is rejected outright is the http:// scheme, not the port number.

  • https://your-site.com/hooks/contractorscope
  • https://your-site.com:443/hooks/contractorscope
  • https://your-site.com:8443/hooks/contractorscope

The scheme and port are both checked when you register, so a URL on :8443 is rejected immediately with an explanation. That's deliberate — our systems can only reach those ports, and an accepted-then-never-fires webhook is far worse than an error you can act on.

If your endpoint listens on a non-standard port, put it behind a reverse proxy on 443 and register that address.

It must be publicly reachable. localhost and literal private addresses (192.168.x.x, 10.x.x.x, loopback, link-local) are rejected at registration, so you'll find out immediately.

The gap to watch: a normal-looking public hostname that happens to resolve to a private address passes registration and is then silently dropped at delivery. If you point us at something like hooks.internal.example.com, confirm it resolves publicly — nothing will tell you it didn't.

Verifying the request really came from us

Every request carries an X-Hub-Signature header — but read this carefully, because the header name is misleading. GitHub uses that same name for SHA-1. We use SHA-256.

X-Hub-Signature: sha256=<64 lowercase hex characters>

To verify:

  1. Take the raw bytes of the request body, exactly as received — before any JSON parsing or reformatting.
  2. Compute HMAC-SHA256(secret, body_bytes) and hex-encode it, lowercase.
  3. Prepend sha256=.
  4. Compare to the header using a constant-time comparison.

If they don't match, discard the request. Anyone can POST to your URL; the signature is what proves this one is ours.

What we send

{
"event": "estimate.approved",
"company_id": "…",
"payload": {
"submission_id": "…",
"status": "approved",
"canonical_estimate_status": "approved",
"updated_at": "…"
}
}
payload is the full estimate record

It is not a summary. payload contains the entire stored estimate — every line item, all pricing and margin, the scope data, and the customer's details. The fields above are the stable ones you can rely on; everything else in the record comes along with them.

Treat the receiving endpoint accordingly: it is receiving your customer's personal information and your own cost data, so secure and scope it the way you would any system holding those.

The signature is computed over this whole envelope, not the inner payload.

Delivery behaviour

We attempt delivery once. There are no retries. If your endpoint is down or returns an error, that notification is gone — we log it on our side, but nothing re-sends it. If these notifications matter to your business, reconcile periodically against the API rather than treating webhooks as a guaranteed feed.

Return 2xx quickly and do your real work afterwards. We allow 10 seconds to connect and 10 seconds for the response — per endpoint, and endpoints are contacted one after another, so registering several slow ones multiplies the delay below.

Speed matters more than you'd expect. Delivery happens inline while the estimate status is being updated — including when a customer clicks Approve on a proposal. A slow endpoint of yours will make that click take longer for them. It will never fail their approval or your estimate, but it can delay the page they're looking at.

Redirects are not followed — a 301 or 302 is not a successful delivery. Point us at the final URL.

If something isn't arriving

  1. Check the event nameestimate.approved is the only one. A wrong name is now rejected at registration, so if you registered before that change, re-register.
  2. Check the port rule above.
  3. Confirm the hostname resolves publicly, not just inside your network.
  4. Make sure your server returns 2xx and doesn't redirect.
  5. Confirm the endpoint is still registeredGET /api/v1/webhooks/endpoints.

Still stuck? Contact support with your endpoint URL and roughly when you expected the notification, and we can check what happened on our side.