# Guide to CompanyCam Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /public_api/v1/webhooks` |
| Subscription | A list of scopes, including `*` and wildcards such as `project.*` |
| Event name | `event_type` in the body |
| Signature header | `X-CompanyCam-Signature` |
| Scheme | HMAC-SHA1, base64, no prefix |
| Signed content | The raw request body only |
| Secret | The `token` you supply on create. Optional |
| Extra header | An optional `authorization_header`, set on create |
| Acknowledgement | Exactly `200` |
| Retries | Exponential backoff, up to 10 attempts |
| Auto-disable | When the total error count exceeds 25. Reset by each success |
| Delivery log | `GET /public_api/v1/webhooks/{webhook_id}/deliveries`, kept for 30 days |
| Source IPs | Not published |

## Common events

CompanyCam's scope table lists these events:

| Family | Events |
| --- | --- |
| Projects | `project.created`, `project.updated`, `project.label_added`, `project.contact_created`, `project.contact_updated`, `project.merged`, `project.archived`, `project.deleted` |
| Photos | `photo.created`, `photo.updated`, `photo.tag_added`, `photo.description_updated` |
| Comments | `comment.created` |
| Documents | `document.created`, `document.updated` |
| Videos | `video.created`, `video.updated` |
| Checklists | `todo_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:

```json
{
  "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`:

```bash
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.

```javascript
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:

```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](/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](/docs/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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-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](https://hookdeck.com) sits in front of your receiver, checks every CompanyCam signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle CompanyCam webhooks reliably in minutes.