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
| Event | Fires when |
|---|---|
estimate.approved | An 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
| List | GET /api/v1/webhooks/endpoints |
| Delete | DELETE /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:
- Take the raw bytes of the request body, exactly as received — before any JSON parsing or reformatting.
- Compute
HMAC-SHA256(secret, body_bytes)and hex-encode it, lowercase. - Prepend
sha256=. - 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 recordIt 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
- Check the event name —
estimate.approvedis the only one. A wrong name is now rejected at registration, so if you registered before that change, re-register. - Check the port rule above.
- Confirm the hostname resolves publicly, not just inside your network.
- Make sure your server returns
2xxand doesn't redirect. - Confirm the endpoint is still registered —
GET /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.