# Guide to Campfire Webhooks: Features and Best Practices

Campfire webhooks tell your systems when accounting data changes: an invoice is paid, a bill is created, a journal entry is edited or a contract is deleted. Campfire is an AI accounting and ERP platform, and webhooks let you keep billing tools, data warehouses and approval workflows in sync without polling its API.

This guide covers how Campfire webhooks are created and signed, the topics and payload, the retry policy, and the practices that keep a Campfire receiver reliable.

## What are Campfire webhooks?

A Campfire webhook is an HTTPS endpoint subscribed to a list of topics, such as `Invoice.paid` or `Bill.created`. When a matching change happens, Campfire sends a signed `POST` with a JSON body describing the object and what changed. Managing webhooks, in the app or the API, needs an admin user.

Each delivery is signed with the webhook's own secret. The signed message is the `Campfire-Webhook-Request-Timestamp` header, a colon, and the raw body. Campfire hex-encodes the HMAC-SHA256 and sends it in `Campfire-Webhook-Signature-v1`. Despite the `-v1` in the header name, the value is a bare hex string with no prefix.

## Campfire webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Developer > Webhooks**, or `POST /integrations/api/v1/webhook`. Admins only |
| Subscription model | A list of exact topic names per webhook |
| Signature header | `Campfire-Webhook-Signature-v1`, bare hex |
| Timestamp header | `Campfire-Webhook-Request-Timestamp`, Unix seconds |
| Scheme | HMAC-SHA256 |
| Signed content | `<timestamp>:<raw body>` (a colon, not a dot) |
| Secret | 50 characters, generated by Campfire. Can't be set or changed |
| Other headers | `Campfire-Webhook-UUID`, the webhook's UUID, the same on every delivery |
| Payload | `webhook_uuid`, `topic`, `data`, `changes` |
| Success | Any `2xx`. Any `4xx` or `5xx` is a failure |
| Retries | Up to 5, roughly a minute apart. A `404` isn't retried. Each retry is re-signed |
| Ordering | Not guaranteed. Duplicates are possible |
| Delivery log | **Recent Events** on the webhooks page, or the last 25 through the API |
| Source IPs | Not published |

## Common events

Campfire documents 28 topics. Topic names are exact, case-sensitive strings.

| Object | Topics |
| --- | --- |
| Invoices | `Invoice.created`, `Invoice.updated`, `Invoice.deleted`, `Invoice.payment`, `Invoice.paid` |
| Bills | `Bill.created`, `Bill.updated`, `Bill.deleted`, `Bill.payment`, `Bill.paid` |
| Credit memos | `CreditMemo.created`, `.updated`, `.deleted`, and the same for `CreditMemoLine` |
| Debit memos | `DebitMemo.created`, `.updated`, `.deleted`, and the same for `DebitMemoLine` |
| Journal entries | `JournalEntry.created`, `JournalEntry.updated`, `JournalEntry.deleted` |
| Contracts | `Contract.created`, `Contract.updated`, `Contract.deleted` |

The payment topics behave differently from the rest. `.payment` fires whenever the amount paid goes up, including partial payments. `.paid` fires when the invoice or bill becomes fully paid, and at that moment Campfire sends both `.paid` and `.payment` as separate deliveries, with no `.updated` for the change.

Every delivery has four fields. Here is Campfire's `Invoice.paid` example, with the invoice object shortened:

```json
{
  "webhook_uuid": "3c88df19-1c1f-4b1c-b4a7-768793055a60",
  "topic": "Invoice.paid",
  "data": {
    "id": 12345,
    "invoice_number": "INV-1042",
    "payment_status": "paid",
    "amount_paid": 1500.0
  },
  "changes": {
    "payment_status": { "old": "open", "new": "paid" },
    "amount_paid": { "old": 500.0, "new": 1500.0 }
  }
}

```

`data` is the full object in the same shape as the matching `GET` endpoint. For `*.deleted` topics it holds only `{"id": ...}` and `changes` is `null`. On `*.created` topics every field appears in `changes` with `"old": null`.

## Setting up Campfire webhooks

In the app:

1. Go to **Settings > Developer > Webhooks** and click **New Webhook**.
2. Enter your HTTPS URL, check **Active**, and select topics. You can select a whole group, such as Invoices, or single topics.
3. Save. The webhook card shows an **HMAC-SHA256 Secret**. Click **Show** and store it, for example as `CAMPFIRE_WEBHOOK_SECRET`.

Through the API, with an admin API key:

```bash
curl -X POST https://api.meetcampfire.com/integrations/api/v1/webhook \
  -H "Authorization: Token $CAMPFIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/campfire",
    "active": true,
    "topics": [{"name": "Invoice.paid"}, {"name": "Bill.created"}]
  }'

```

The response includes the webhook's integer `id`, its `uuid` and the signing secret as `token`. `url` and `active` are required on create and update, and updating `topics` replaces the whole list, so send every topic the webhook should keep. Set `"active": false` to pause deliveries. The secret can't be changed: to rotate it, delete the webhook and create a new one.

**With Hookdeck.** Campfire is a supported source type in Hookdeck. Create a source with the Campfire type, paste the signing secret into it, and use the Hookdeck source URL as the webhook URL in Campfire. The source accepts `POST` only and checks `Campfire-Webhook-Signature-v1` against `<timestamp>:<raw body>`. A request that fails the check still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Campfire webhooks

Build the signed message from the timestamp header, a literal `:`, and the raw body bytes. Compute an HMAC-SHA256 with the secret as a UTF-8 string and compare the hex digests in constant time.

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

function verifyCampfireSignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding

  // A colon, not a dot, between the timestamp and the body.
  const signed = Buffer.concat([Buffer.from(`${timestamp}:`, 'latin1'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// Optional replay check, off by default (0). Hookdeck retries and replays resend the original timestamp.
const TOLERANCE_SECONDS = Number(process.env.CAMPFIRE_WEBHOOK_TOLERANCE_SECONDS || 0);

const app = express();

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

  const timestamp = req.get('campfire-webhook-request-timestamp');
  if (!verifyCampfireSignature(req.body, req.get('campfire-webhook-signature-v1'), timestamp, secret)) {
    return res.sendStatus(401);
  }
  if (TOLERANCE_SECONDS > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.sendStatus(401);
  }

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

```

The same check in Python:

```python
import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_campfire_signature(raw_body: bytes, signature, timestamp, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False
    signed = timestamp.encode("latin-1") + b":" + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)

```

Campfire recommends rejecting timestamps older than 5 minutes, and notes that its own retries are re-signed with a fresh timestamp, so the check doesn't reject Campfire's retries. Hookdeck works differently: it applies no timestamp tolerance, and its retries and replays resend the original headers, including the original timestamp. If Hookdeck sits in front of your service, leave the window off, or set it wide enough to cover your Hookdeck retry schedule, and deduplicate instead.

Campfire's Node sample compares the signatures with `crypto.timingSafeEqual` after a length check on the strings, and builds the message from `rawBody` in a template string. Building it from bytes, as above, avoids any re-encoding of the body.

> **Make Campfire webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Campfire-Webhook-Signature-v1` header, stores every invoice, bill and journal entry event, and delivers it to your services with retries you control.

## Campfire webhook limitations and pain points

### Five retries, a minute apart

**The Problem:** A short outage on your side loses events.

**Why It Happens:** Campfire retries a failed delivery up to five times, roughly one minute apart, and treats any `4xx` or `5xx` as a failure. A `404` isn't retried at all. A deploy or outage of more than a few minutes uses up every attempt.

**Workarounds:**

* Keep the webhook route up through deploys, and never let it return `404`.
* Check **Recent Events**, or `GET /integrations/api/v1/webhook/{id}/events`, after an outage, and fetch anything that failed from the API.

**How Hookdeck Can Help:** Hookdeck answers Campfire with a `200` as soon as a request arrives and stores it, then delivers to your service with [automatic retries](/docs/retries) over a window you set, and lets you replay any event.

### Out-of-order and duplicate deliveries

**The Problem:** An older update overwrites a newer one, or the same payment is recorded twice.

**Why It Happens:** Campfire doesn't guarantee order, and retries can resend an event your handler already processed. A paid invoice also produces two deliveries, `.paid` and `.payment`.

**Workarounds:**

* Treat `data` as the current state of the object, as Campfire suggests, rather than applying `changes` as increments.
* Decide which of `.paid` and `.payment` drives each action, so a full payment isn't counted twice.

**How Hookdeck Can Help:** [Deduplication](/docs/deduplication) can drop repeat requests before they reach your service, and [filters](/docs/filters) can route `Invoice.paid` and `Invoice.payment` to different destinations.

### No delivery ID

**The Problem:** There's no header or field that identifies one event.

**Why It Happens:** `Campfire-Webhook-UUID` and `webhook_uuid` identify the webhook, not the event, and are the same on every delivery.

**Workarounds:**

* Build an idempotency key from `topic`, `data.id` and a version field from the object, such as an updated-at timestamp if the object has one.

**How Hookdeck Can Help:** Hookdeck gives every request its own ID and keeps the full headers and body, so you can trace and replay a single delivery.

### A secret you can't rotate in place

**The Problem:** Rotating the secret interrupts deliveries.

**Why It Happens:** Campfire generates the secret and won't change it. The only way to rotate is to delete the webhook and create a new one with a new secret.

**Workarounds:**

* Create the new webhook first, update your receiver to accept both secrets, then delete the old webhook.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives on the source, and services behind it don't need to know it.

## Best practices

### Verify against the raw body

Campfire warns that re-serialising the JSON changes whitespace and key order, which breaks the signature. Use `express.raw()` in Express, `await request.arrayBuffer()` in a Next.js route handler, and `await request.body()` in FastAPI.

### Subscribe to what you use

Updating topics replaces the full list, so keep the list in code and send it whole. Subscribe to the payment topics only if you handle partial payments, and answer `2xx` to anything you don't handle.

### Use `data` as the source of truth

Because order isn't guaranteed, write the object in `data` as the latest state, and compare a version or timestamp field before overwriting newer data. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Campfire asks you to respond quickly and process slow work afterwards. Verify, store the event, return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Campfire signs each delivery with `Campfire-Webhook-Signature-v1`, a bare hex HMAC-SHA256 of `<timestamp>:<raw body>`, keyed with the webhook's 50-character secret. Verify against the raw bytes with a colon, route on `topic`, treat `data` as the latest state, and plan for a retry policy that gives up after five attempts about a minute apart.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Campfire signature, and holds each event until your service has processed it.

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