Gareth Wilson Gareth Wilson

Guide to Campfire Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationSettings > Developer > Webhooks, or POST /integrations/api/v1/webhook. Admins only
Subscription modelA list of exact topic names per webhook
Signature headerCampfire-Webhook-Signature-v1, bare hex
Timestamp headerCampfire-Webhook-Request-Timestamp, Unix seconds
SchemeHMAC-SHA256
Signed content<timestamp>:<raw body> (a colon, not a dot)
Secret50 characters, generated by Campfire. Can't be set or changed
Other headersCampfire-Webhook-UUID, the webhook's UUID, the same on every delivery
Payloadwebhook_uuid, topic, data, changes
SuccessAny 2xx. Any 4xx or 5xx is a failure
RetriesUp to 5, roughly a minute apart. A 404 isn't retried. Each retry is re-signed
OrderingNot guaranteed. Duplicates are possible
Delivery logRecent Events on the webhooks page, or the last 25 through the API
Source IPsNot published

Common events

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

ObjectTopics
InvoicesInvoice.created, Invoice.updated, Invoice.deleted, Invoice.payment, Invoice.paid
BillsBill.created, Bill.updated, Bill.deleted, Bill.payment, Bill.paid
Credit memosCreditMemo.created, .updated, .deleted, and the same for CreditMemoLine
Debit memosDebitMemo.created, .updated, .deleted, and the same for DebitMemoLine
Journal entriesJournalEntry.created, JournalEntry.updated, JournalEntry.deleted
ContractsContract.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:

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

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.

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:

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 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 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 can drop repeat requests before they reach your service, and 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.

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.

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 sits in front of your receiver, checks every Campfire signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Campfire 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.