Gareth Wilson Gareth Wilson

Guide to CompanyCam Webhooks: Features and Best Practices

Published


CompanyCam webhooks tell your systems what is happening on job sites: a project is created or archived, a photo is uploaded or annotated, a checklist is completed. If your contractors document their work in CompanyCam, webhooks are how that activity reaches your project management, CRM or reporting tools without polling the API.

This guide covers how CompanyCam webhooks are created and signed, the scopes you can subscribe to, the retry and disable rules, and the practices that keep a CompanyCam receiver reliable.

What are CompanyCam webhooks?

A CompanyCam webhook is created through the API with a URL and a list of scopes. When an event matching one of the scopes happens, CompanyCam sends a POST to the URL with a JSON envelope naming the event and carrying the affected object.

Each delivery has an X-CompanyCam-Signature header containing Base64(HMAC-SHA1(webhook.token, raw_body)). The key is the token you supply when you create the webhook. The token is optional, so a webhook created without one has no key to sign with.

CompanyCam is moving to a new API at developers.companycam.com. Its older API, documented at companycam.readme.io, is deprecating in early 2027. Both document the same signature scheme.

CompanyCam webhook features

FeatureDetails
ConfigurationAPI only: POST /public_api/v1/webhooks
SubscriptionA list of scopes, including * and wildcards such as project.*
Event nameevent_type in the body
Signature headerX-CompanyCam-Signature
SchemeHMAC-SHA1, base64, no prefix
Signed contentThe raw request body only
SecretThe token you supply on create. Optional
Extra headerAn optional authorization_header, set on create
AcknowledgementExactly 200
RetriesExponential backoff, up to 10 attempts
Auto-disableWhen the total error count exceeds 25. Reset by each success
Delivery logGET /public_api/v1/webhooks/{webhook_id}/deliveries, kept for 30 days
Source IPsNot published

Common events

CompanyCam's scope table lists these events:

FamilyEvents
Projectsproject.created, project.updated, project.label_added, project.contact_created, project.contact_updated, project.merged, project.archived, project.deleted
Photosphoto.created, photo.updated, photo.tag_added, photo.description_updated
Commentscomment.created
Documentsdocument.created, document.updated
Videosvideo.created, video.updated
Checkliststodo_list.created, todo_list.completed, todo_list.deleted, task.completed

Subscribe with * for everything or a family wildcard such as photo.*. photo.created fires once the photo has been processed, and task.completed applies to checklist tasks, not project tasks. That table comes from the legacy documentation, and the new API may add scopes, so log unknown event_type values rather than rejecting them.

The body is an envelope with four fields:

{
  "event_type": "project.created",
  "created_at": 1661361759,
  "payload": { "id": "...", "name": "..." },
  "webhook_id": 42
}

event_type is the concrete event that fired, never a wildcard. created_at is when CompanyCam sent the request, webhook_id identifies the webhook, and payload is the affected object in the same shape as the API's: a project.* event carries a Project. CompanyCam's example is annotated pseudo-JSON with the payload elided, so capture a real delivery before you write field-level parsing.

Setting up CompanyCam webhooks

Create the webhook with an OAuth bearer token, and always include a token:

curl -X POST "https://app.companycam.com/public_api/v1/webhooks" \
  -H "Authorization: Bearer $COMPANYCAM_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": {
      "url": "https://example.com/webhooks/companycam",
      "scopes": ["project.*", "photo.created", "task.completed"],
      "token": "a-long-random-string-you-generate"
    }
  }'

Generate the token yourself, for example with openssl rand -hex 32, and store it server-side as COMPANYCAM_WEBHOOK_TOKEN. The 201 response includes the webhook's id and enabled flag.

If you receive CompanyCam webhooks through Hookdeck, use the Hookdeck source URL as url and paste the same token into the source. Without a token, deliveries are unsigned and fail verification.

Securing CompanyCam webhooks

Compute an HMAC-SHA1 of the raw body with the token as the key, base64-encode it, and compare it with X-CompanyCam-Signature in constant time. The algorithm is SHA-1 and the encoding is base64, not hex.

const crypto = require('crypto');
const express = require('express');

function verifyCompanyCamSignature(rawBody, header, token) {
  if (typeof header !== 'string' || !token) return false;
  const expected = Buffer.from(
    crypto.createHmac('sha1', token).update(rawBody).digest('base64'),
    'utf8'
  );
  const received = Buffer.from(header.trim(), 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/companycam', express.raw({ type: '*/*' }), (req, res) => {
  const token = process.env.COMPANYCAM_WEBHOOK_TOKEN;
  if (!token) return res.sendStatus(500); // fail closed

  if (!verifyCompanyCamSignature(req.body, req.get('x-companycam-signature'), token)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event_type, then process event.payload asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import base64
import hashlib
import hmac


def verify_companycam_signature(raw_body: bytes, header, token: str) -> bool:
    if not header or not token:
        return False
    expected = base64.b64encode(
        hmac.new(token.encode("utf-8"), raw_body, hashlib.sha1).digest()
    )
    return hmac.compare_digest(header.strip().encode("utf-8"), expected)

CompanyCam's legacy sample calls crypto.timingSafeEqual without comparing lengths first, which throws when the header is the wrong length. Keep the length check shown above. Compare the base64 strings exactly: unlike hex, base64 is case-sensitive.

Make CompanyCam webhooks production-ready. Hookdeck Event Gateway verifies the X-CompanyCam-Signature header, answers CompanyCam with 200, and stores every project, photo and checklist event before your service sees it, so errors on your side never count towards CompanyCam's disable limit.

CompanyCam webhook limitations and pain points

The token is optional

The Problem: Deliveries arrive without a signature, or with one you can't verify.

Why It Happens: The create call needs only url and scopes. A webhook created without token has no key for CompanyCam to sign with.

Workarounds:

  • Always pass a long random token on create.
  • If you have webhooks without one, delete and recreate them with a token.

How Hookdeck Can Help: Hookdeck's CompanyCam source type requires the token. Deliveries without a valid signature are marked as failed verification and are not delivered to your destinations, so a webhook created without a token shows up straight away.

Exactly 200, and 25 errors disables the webhook

The Problem: A healthy endpoint gets retried, and eventually the webhook stops.

Why It Happens: CompanyCam expects exactly 200. A 201, 202 or 204 counts as an error, is retried up to 10 times, and adds to the webhook's error total. Once that total passes 25, the webhook is disabled. Only a successful response resets the count.

Workarounds:

  • Return 200 from the webhook route, not 202 or 204.
  • Check the webhook's enabled flag and the deliveries log, and alert when deliveries fail.

How Hookdeck Can Help: Hookdeck answers each delivery with 200 as soon as it arrives and stores it, then delivers to your service with automatic retries. Your own errors and downtime then don't count towards CompanyCam's limit.

SHA-1 and base64

The Problem: A verifier copied from another provider never matches.

Why It Happens: Most webhook signatures today use SHA-256 and hex. CompanyCam uses SHA-1 and base64, so a SHA-256 or hex verifier produces a different string every time.

Workarounds:

  • Use sha1 and digest('base64'), as above.
  • Verify against the raw body, as CompanyCam stresses: "not a re-serialized parse".

How Hookdeck Can Help: Hookdeck's CompanyCam source type is set up for HMAC-SHA1 with base64, so you only paste the token.

No timestamp or delivery ID in the signature

The Problem: A captured request still verifies if sent again, and retries look like new events.

Why It Happens: Only the body is signed. created_at in the body is not described as a replay defence, and no delivery ID header is documented.

Workarounds:

  • Make handlers idempotent, keyed on event_type and the payload object's ID.
  • Re-read the object from the API before acting on high-value changes.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries based on the body fields you choose.

Best practices

Verify against the raw body

Use express.raw() in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse only after the check passes.

Subscribe with wildcards carefully

* and family wildcards subscribe you to events CompanyCam adds later. That keeps you up to date, but your handler must answer 200 to types it doesn't recognise.

Use the deliveries log

GET /public_api/v1/webhooks/{webhook_id}/deliveries lists attempts for 30 days with their status codes and errors, and fetching a single failed delivery returns its request_body. Use it to debug and to recover events after an outage.

Acknowledge fast and process asynchronously

Verify, store the event, return 200, and do the work afterwards, such as downloading photos. See why to process webhooks asynchronously and our guide to webhook idempotency.

Conclusion

CompanyCam signs each delivery with X-CompanyCam-Signature, a base64 HMAC-SHA1 of the raw body keyed with the token you set when you create the webhook. Always set that token, verify with SHA-1 and base64 rather than SHA-256 and hex, route on event_type, and return exactly 200, because anything else counts towards the 25 errors that disable the webhook.

Hookdeck Event Gateway sits in front of your receiver, checks every CompanyCam signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle CompanyCam webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.