> ## Documentation Index
> Fetch the complete documentation index at: https://docs.campfire.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Extending Campfire with your app

> Add buttons, embedded panels, sidebar pages, a manage link and event subscriptions to Campfire from your OAuth app

An [OAuth app](/guides/oauth-apps) can do more than call the API. It can also put itself **inside Campfire**:

* **Buttons** on invoice and bill pages that open a link, call your server, or open your page in a drawer.
* **Panels**: your page embedded as a tab on a list page, a detail page, or an edit modal.
* **Navigation**: a section in Campfire's sidebar with full-width pages from your app.
* **A manage link** from the connection page to your app's settings screen.
* **Event subscriptions**: signed notifications when records change in any workspace that connected your app.

You define all of this once, on the app. It then shows up in every workspace where someone has connected the app, and each workspace's admins can switch individual pieces off.

<CardGroup cols={2}>
  <Card title="FakeTax sample app" icon="github" href="https://github.com/Campfire-eng/campfire_faketax_app">
    A complete, open-source Flask app that uses every feature on this page. All the screenshots here come from it.
  </Card>

  <Card title="App manifest" icon="file-code" href="/guides/app-manifest">
    Define the whole app, including everything on this page, as one JSON document you keep in your repository.
  </Card>
</CardGroup>

## The sample app: FakeTax

[FakeTax](https://github.com/Campfire-eng/campfire_faketax_app) puts made-up sales tax on invoices. The tax is fake, but the plumbing is real, so it's a working reference for each capability:

| Capability | What FakeTax does with it |
| - | - |
| Webhook button | **Calculate tax** on an invoice reads the invoice, writes tax on every line, and tells Campfire to show a toast and refresh. |
| Bulk webhook button | **Calculate FakeTax** on the invoice list does the same for up to 50 selected invoices. |
| Iframe button | **Tax breakdown** opens FakeTax in a drawer where you can override the rate and apply it. |
| Link button | **View in FakeTax** opens a per-line report in a new tab, after verifying a single-use context token. |
| Panels | A **FakeTax** tab on the invoice page and edit modal, and a settings tab on the invoice list. |
| Navigation | A **FakeTax** sidebar section with Jurisdictions, Tax codes, and Settings pages. |
| Manage link | **Manage in FakeTax** on the connection page opens FakeTax's settings, signed in. |
| Events | `Invoice.created` triggers automatic tax calculation when that option is on. |
| Install config | Per-workspace settings (default rate, auto-calculate) stored in Campfire. |

The repository's README covers local setup. In short: create an app, paste [`manifest.json`](https://github.com/Campfire-eng/campfire_faketax_app/blob/main/manifest.json) into its **Manifest** tab, rotate the signing secret, put the credentials in `.env`, and run `python app.py`.

## Before you start

<Steps>
  <Step title="Have extensions turned on">
    Buttons, panels, navigation and events only appear in, and are only sent to, workspaces that have app extensions turned on. Contact your Campfire support team to turn them on for your workspace and for the workspaces you're building for. You can define everything on your app before that. It just won't show.
  </Step>

  <Step title="Add your allowed domains">
    On your app's page (**Settings > Developer > Apps > Manage**), open the **Security** tab. Every button URL, panel and page URL, and event endpoint must be `https` on one of these hosts. `example.com` matches only that host. `*.example.com` matches its subdomains, but not `example.com` itself. You can add up to 20. Plain `http` is accepted only for `localhost`.

    <Frame>
      <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/app-security.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=8b1e6333fba84844e7238d43805da782" alt="The Security tab with an allowed domain and the signing secret" width="1800" height="1125" data-path="images/oauth-apps/app-security.png" />
    </Frame>
  </Step>

  <Step title="Rotate the signing secret">
    Campfire signs every webhook call, event delivery and context token with your app's **signing secret** (`whsec_...`). This is separate from your OAuth client secret. A signing secret is created automatically, but its value is never shown, so click **Rotate Secret** to get one you can copy. It's shown once.

    After a rotation, Campfire signs with **both** the new and the previous secret for 24 hours, so you can deploy the new secret without dropping requests.
  </Step>
</Steps>

<Note>
  Extensions show only in workspaces where at least one person has connected your app. Your own workspace counts. Other workspaces see them only once your app is [published and approved](/guides/oauth-apps#private-vs-published-apps). Disconnecting the last connection in a workspace removes them there.
</Note>

## Buttons

Buttons add actions to Campfire pages. Manage them on your app's **Buttons** tab.

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/app-buttons.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=396a469adf09c67e19e02c98c51f6c3a" alt="The Buttons tab listing FakeTax's five invoice buttons" width="1800" height="1125" data-path="images/oauth-apps/app-buttons.png" />
</Frame>

On an invoice, FakeTax's **Calculate tax** is a secondary button in the page header, and its other buttons are grouped under the app's name in the **Actions** menu:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/invoice-buttons.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=a87fc03b3947d0df333617e38a45137b" alt="An invoice page with a Calculate tax button and a FakeTax group in the Actions menu containing Tax breakdown and View in FakeTax" width="1800" height="1125" data-path="images/oauth-apps/invoice-buttons.png" />
</Frame>

Click **Add Button**, or click an existing button, to edit it:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/button-editor.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=9f9c8073e055082748c351296e1310e5" alt="The Edit Calculate tax sheet with label, key, object, placement, style, action, icon, URL template, timeout, confirmation and visibility rule" width="1800" height="1125" data-path="images/oauth-apps/button-editor.png" />
</Frame>

| Field | Notes |
| - | - |
| **Label** | Up to 60 characters. |
| **Key** | A stable identifier, unique within the app. Defaults to a slug of the label. Campfire sends it back to you as `slot_key`. |
| **Object** | `invoice`, `bill`, `journal_entry`, or `vendor`. |
| **Placement** | `detail_header` (the record's page), `list_header` (the list page, acting on selected rows), or `row_menu` (a list row's menu). |
| **Style** | `menu` (default), `secondary`, or `primary`. See [How buttons are styled](#how-buttons-are-styled). |
| **Action** | `link`, `webhook` (Call webhook), or `iframe` (Embedded page). |
| **Icon** | A [Lucide](https://lucide.dev/icons) icon name in kebab-case, for example `calculator`. |
| **URL template** | Where the action goes. See [URL templates](#url-templates). |
| **Confirmation** | Optional, up to 200 characters. Campfire asks the person to confirm before running the action. |
| **Show only when** | Optional rules that hide the button for records that don't match. See [Visibility rules](#visibility-rules). |

An app can have up to 50 buttons.

<Info>
  **Where buttons appear today:** the invoice page header, the invoice list header (with rows selected), and the bill page header. You can define buttons for other objects and placements now. They'll appear as Campfire adds them to more pages.
</Info>

### Link buttons

A link button opens your URL in a new tab, with a signed [context token](#context-tokens) added as `cf_ctx`:

```text theme={null}
https://your-app.com/report/18545770?cf_ctx=eyJhbGciOiJIUzI1NiIs...
```

Verify the token before showing anything. FakeTax's **View in FakeTax** opens a report only after the token checks out, and refuses it on reload because the token is single-use:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/button-link-report.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=7e1d3823690fc4e46b766ea83bbd1922" alt="The FakeTax report for an invoice, opened from Campfire with a context token" width="1800" height="1125" data-path="images/oauth-apps/button-link-report.png" />
</Frame>

### Webhook buttons

A webhook button calls your server and shows the result without the person leaving Campfire. Optionally, Campfire asks them to confirm first:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/button-confirm.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=249efad2c50a5225fa1b11841a9c6232" alt="A Calculate tax confirmation dialog asking Overwrite tax on every line?" width="1800" height="1125" data-path="images/oauth-apps/button-confirm.png" />
</Frame>

Campfire then sends a signed `POST` to your URL:

```http theme={null}
POST /slots/calculate HTTP/1.1
Content-Type: application/json
Campfire-App-Id: 0ca4c60f-db0a-4bc3-9efa-d2dec4292130
Campfire-Delivery-Kind: slot
Campfire-Webhook-Request-Timestamp: 1791990000
Campfire-Webhook-Signature-v1: 5d1c0e...

{
  "kind": "slot.invoked",
  "slot_key": "calculate",
  "object_type": "invoice",
  "object_ids": ["18545770"],
  "surface": "detail_header",
  "user": {"id": 31017},
  "customer": {"id": 52},
  "context_token": "eyJhbGciOiJIUzI1NiIs..."
}
```

Check the [signature](#verifying-signatures), do the work (usually by calling the Campfire API with the connection's OAuth token), and reply with up to four **effects**:

```json theme={null}
{
  "effects": [
    {"type": "toast", "level": "success", "message": "FakeTax: Tax $174.00 written on 1 invoice"},
    {"type": "refresh"}
  ]
}
```

| Effect | Fields | What Campfire does |
| - | - | - |
| `toast` | `level` (`success`, `info`, `warning`, `error`), `message` (up to 200 characters) | Shows a notification. |
| `refresh` | none | Reloads the record or list, so your changes show immediately. |
| `open_url` | `url` (on an allowed domain) | Opens the URL in a new tab. |
| `open_iframe` | `url` (on an allowed domain), `presentation` (`drawer` or `modal`) | Opens your page inside Campfire, with the same context token. Use this when you need input from the person. |

An empty `200` response is fine too.

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/button-webhook-toast.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=86bab3dba7c5a33078a6d309b43f788d" alt="The invoice after Calculate tax, with $174.00 of tax on the hardware line and a success toast" width="1800" height="1125" data-path="images/oauth-apps/button-webhook-toast.png" />
</Frame>

Your endpoint has `timeout_ms` to respond: 8,000 ms by default, adjustable from 1,000 to 10,000. Campfire doesn't follow redirects and reads at most 64 KB of the response. If your server returns a non-`2xx` status, invalid JSON, or an unknown effect, the person sees *"\<App> could not complete this action."* If it times out, they see *"\<App> did not respond in time."* Either way, the failure is recorded on your app's [Activity](#activity-and-testing) tab.

**List header buttons** act on the selected rows. `object_ids` contains up to 50 ids. Reply with one summary toast:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/bulk-menu.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=40e4d1471677977e7b20735bb5e3a5c7" alt="Two invoices selected on the invoice list with the Actions menu open showing Calculate FakeTax under FAKETAX" width="1800" height="1125" data-path="images/oauth-apps/bulk-menu.png" />
</Frame>

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/bulk-toast.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=b247e887a500b4e3974a69dc2fee64a6" alt="A toast reading FakeTax: Tax $174.00 written on 2 invoices" width="1800" height="1125" data-path="images/oauth-apps/bulk-toast.png" />
</Frame>

<Tip>
  If your change needs approval in the workspace, the API answers `202` with a draft instead of saving the record. Tell the person it was submitted for approval rather than reporting success. FakeTax does this.
</Tip>

### Iframe buttons

An iframe button opens your page in a **drawer** (default) or **modal**, `sm`, `md` (default), or `lg` wide. Campfire doesn't add anything to the URL. Your page gets its context over the [bridge](#the-bridge) instead:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/button-iframe-drawer.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=060086e2db16d0592a9b041756b8d2f3" alt="The Tax breakdown drawer showing FakeTax's per-line breakdown with an Apply to invoice button" width="1800" height="1125" data-path="images/oauth-apps/button-iframe-drawer.png" />
</Frame>

### URL templates

URL templates can include these placeholders:

| Placeholder | Value |
| - | - |
| `{{record.id}}` | The record's id. Not available on `list_header` buttons; use `object_ids` from the request. |
| `{{record.number}}` | The invoice number, bill number, or journal entry reference. |
| `{{customer.id}}` | The workspace id. |
| `{{install.<key>}}` | A value from the workspace's [install config](#install-config). |

Values are URL-encoded. A placeholder at the very **start** of a template, such as `{{install.base_url}}/report`, is inserted as-is, so each workspace can point at its own host. The result must still be on an allowed domain.

### Visibility rules

**Show only when** takes up to five rules, all of which must match. Each rule is a `field`, an operator (`eq`, `neq`, `in`, `not_in`), and a value:

| Object | Fields you can test |
| - | - |
| Invoice | `payment_status`, `currency`, `tax_exempt` |
| Bill | `payment_status`, `currency`, `bill_type`, `source` |
| Journal entry | `type`, `currency`, `created_automatically` |
| Vendor | `vendor_type`, `is_1099`, `country` |

FakeTax shows **Calculate tax** only on draft and open invoices:

```json theme={null}
"visible_when": [{"field": "payment_status", "op": "in", "value": ["draft", "open"]}]
```

Campfire checks the rules again when the button is clicked. On a list, the button shows if any selected row matches, and only the matching rows are sent to you.

### How buttons are styled

In your own workspace, buttons get the style you chose. In other workspaces, they start as `menu` items until a workspace admin promotes them. To keep pages tidy, each page area shows at most one `primary` and two `secondary` app buttons. Extras become menu items. If the page already has its own primary button, yours becomes secondary. App buttons are drawn in Campfire's partner color so people can tell them apart from Campfire's own.

### Who sees a button

A person sees your button only if:

* someone in their workspace has connected your app,
* they can view that kind of record, and
* a workspace admin hasn't switched the button off or limited it to other roles.

Clicks are limited to 30 per minute per person.

## Panels

Panels embed one of your pages as a **tab** next to Campfire's own tabs. Manage them on the **Panels** tab.

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/app-panels.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=805d90db6bea22945d4a8df06eb5e4d2" alt="The Panels tab with a FakeTax panel on the invoice detail page and edit modal, and one on the list page" width="1800" height="1125" data-path="images/oauth-apps/app-panels.png" />
</Frame>

| Field | Notes |
| - | - |
| **Label** | The tab's title, up to 60 characters. |
| **Key** | Unique within the app. |
| **Object** | `invoice`, `bill`, `journal_entry`, or `vendor`. |
| **Placements** | One or more of `list`, `detail`, and `edit`. |
| **URL template** | The page to embed. `{{record.*}}` isn't available when `list` is one of the placements. |
| **Show only when** | The same [visibility rules](#visibility-rules) as buttons. |

An app can have up to 20 panels.

<Info>
  **Where panels appear today:** invoices (list page, invoice page, edit modal) and bills (list page, edit modal).
</Info>

FakeTax's breakdown on an invoice page:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/panel-detail.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=7f0ac0943fa8469f6ce2838feaeb6943" alt="An invoice page with the FakeTax tab selected, showing the tax breakdown embedded in the page" width="1800" height="1125" data-path="images/oauth-apps/panel-detail.png" />
</Frame>

And its settings on the invoice list. A list panel's token names no record:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/panel-list.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=4ba8f40a3724511a5ea71292e480294d" alt="The invoice list with a FakeTax tab showing default rate and auto-calculate settings" width="1800" height="1125" data-path="images/oauth-apps/panel-list.png" />
</Frame>

Panels load in a sandboxed iframe (`allow-scripts allow-forms allow-same-origin allow-popups`, no referrer). They start 480 px tall and can resize themselves between 120 and 2,000 px over the [bridge](#the-bridge).

## Navigation

Navigation adds a section to Campfire's main sidebar, after Campfire's own sections. Each page opens full-width inside Campfire, under a header with the page's label and your app's name. Manage it on the **Navigation** tab.

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/app-navigation.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=ff1ece9acc0621bf80b3fb0f8b51c194" alt="The Navigation tab with a FakeTax section containing Jurisdictions, Tax codes and Settings pages" width="1800" height="1125" data-path="images/oauth-apps/app-navigation.png" />
</Frame>

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/nav-page.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=775c2a31af45c1e7dada8b186f8367e1" alt="The FakeTax section expanded in Campfire's sidebar with the Jurisdictions page open, showing nexus states and rates" width="1800" height="1125" data-path="images/oauth-apps/nav-page.png" />
</Frame>

* A top-level entry with **no URL** is a section that groups pages. A top-level entry **with** a URL is a page on its own. If it also has pages under it, it shows first as **Overview**.
* Pages nest one level deep. An app can have up to 30 entries in total, and keys must be unique across all of them.
* Labels are up to 40 characters. Icons are Lucide names, as for buttons.
* URL templates can use `{{customer.id}}` and `{{install.<key>}}`, but not `{{record.*}}`.
* Pages are framed exactly like panels, start 720 px tall, and receive a context token with `surface: "nav"`.

Everyone in a connected workspace sees your navigation, unless an admin switches it off.

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/nav-page-tax-codes.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=e7a64b433b5e56e409ce43956d497ef1" alt="The FakeTax Tax codes page embedded full-width in Campfire" width="1800" height="1125" data-path="images/oauth-apps/nav-page-tax-codes.png" />
</Frame>

## The manage link and onboarding

### Manage URL

Set **Manage URL** on the app's **Details** tab to give connected workspaces a way into your app's settings. The connection page then shows **Manage in \<App>**:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/connection-page.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=9fab33e38accbc7161f006d541f95e94" alt="The FakeTax connection page with Manage in FakeTax and Disconnect buttons, and switches for each button" width="1800" height="1125" data-path="images/oauth-apps/connection-page.png" />
</Frame>

Clicking it opens your Manage URL in a new tab with a context token (`surface: "manage"`, `object_type: "workspace"`):

```text theme={null}
https://your-app.com/manage?cf_ctx=eyJhbGciOiJIUzI1NiIs...
```

Verify the token, then sign the person in to your app for that workspace. FakeTax uses this as its sign-in, so people never need a separate FakeTax account.

<Note>Manage URL is part of the app's versioned details, so a change takes effect when you [promote](/guides/oauth-apps#versions-and-approval) it.</Note>

### Onboarding after connect

When someone clicks **Connect** on your app's connection page, Campfire sends them to your Connect URL with a `return_to` parameter holding the Campfire page they came from:

```text theme={null}
https://your-app.com/connect?return_to=https%3A%2F%2Fapp.meetcampfire.com%2Fv2%2Fsettings%2Fconnections%2Fapps%2F...
```

Use that to run your own setup after the OAuth callback, then send the person back. **Only redirect to `return_to` if it starts with Campfire's own origin**, to avoid an open redirect.

FakeTax's onboarding asks where the workspace collects tax, then uses its app token to create a `Tax code` custom field on invoice lines, then returns to Campfire:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/onboarding-nexus.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=6b1a166d1b84bd3f49f589ec60a18ae8" alt="FakeTax setup step 1, choosing states with nexus and a default tax code" width="1800" height="1125" data-path="images/oauth-apps/onboarding-nexus.png" />
</Frame>

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/onboarding-done.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=2146e9e8cfc5ad0110fa17d484163b54" alt="FakeTax setup done, with a Return to Campfire button" width="1800" height="1125" data-path="images/oauth-apps/onboarding-done.png" />
</Frame>

## Install config

Your app often needs settings per workspace rather than per person. **Install config** stores a small set of strings on Campfire, one set per app per workspace. Read and write it with any of the workspace's OAuth tokens:

```bash theme={null}
curl https://api.meetcampfire.com/ca/api/v1/oauth-app-install/config \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json theme={null}
{
  "customer_id": 52,
  "customer_name": "Example Co",
  "user": {"id": 31017, "name": "Jordan Lee", "email": "jordan@example.com"},
  "install_config": {"default_rate": "5.00", "auto_calculate": "false"}
}
```

`PUT` the same path with `{"install_config": {...}}` to **replace** the whole object. Limits: up to 32 keys, lowercase identifiers, string values only, and 2,048 characters in total. This endpoint only accepts OAuth app tokens.

<Tip>The `GET` response tells you which workspace and person a token belongs to, so it's also a handy "who am I" call right after the token exchange.</Tip>

Values fill `{{install.<key>}}` placeholders in your URL templates.

## Event subscriptions

Event subscriptions notify your server when records change in **every** workspace where your app is connected. Manage them on the **Events** tab.

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/app-events.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=44635ad59e881d7a16c792814593ae76" alt="The Events tab with one subscription to Invoice and CreditMemo topics, with Send Test, Edit and Delete" width="1800" height="1125" data-path="images/oauth-apps/app-events.png" />
</Frame>

Each subscription has an endpoint URL (on an allowed domain; only `{{customer.*}}` and `{{install.*}}` placeholders) and a list of topics. An app can have up to 10 subscriptions.

| Topics | Scope your app needs |
| - | - |
| `Invoice.created`, `.updated`, `.deleted`, `.paid`, `.payment` | `invoices:read` |
| `Bill.created`, `.updated`, `.deleted`, `.paid`, `.payment` | `bills:read` |
| `CreditMemo.*`, `CreditMemoLine.*` (`created`, `updated`, `deleted`) | `credit_memos:read` |
| `DebitMemo.*`, `DebitMemoLine.*` (`created`, `updated`, `deleted`) | `debit_memos:read` |
| `JournalEntry.created`, `.updated`, `.deleted` | `journal_entries:read` |
| `Contract.created`, `.updated`, `.deleted` | `contracts:read` |

Every topic must be covered by your app's permissions. In each workspace, an event is only sent if someone there has granted a scope that covers it.

Events are **thin**: they say what changed, not the new values. Re-read the record through the API with that workspace's token:

```http theme={null}
POST /events HTTP/1.1
Content-Type: application/json
Campfire-App-Id: 0ca4c60f-db0a-4bc3-9efa-d2dec4292130
Campfire-Delivery-Kind: event
Campfire-Webhook-Request-Timestamp: 1791990000
Campfire-Webhook-Signature-v1: 9a7f42...

{
  "kind": "event",
  "topic": "Invoice.updated",
  "id": 18545770,
  "changed_fields": ["tax"],
  "customer": {"id": 52},
  "occurred_at": "2026-10-14T18:26:06Z"
}
```

`changed_fields` is empty for `.deleted` events. **Send Test** posts an example with `"test": true` and `"id": 0`.

**Delivery.** Events are sent after the change is committed, with a 10-second timeout. Timeouts, connection failures, `429` and `5xx` responses are retried up to 5 times, with exponential backoff starting at 1 minute and capped at 1 hour. Other `4xx` responses are not retried. Respond `2xx` quickly and do slow work in the background.

<Warning>
  If your app writes to the record it was notified about, that write produces another `.updated` event. Use `changed_fields` (or remember what you just wrote) to avoid loops.
</Warning>

### App events vs. workspace webhooks

| | App event subscriptions | [Workspace webhooks](/guides/webhooks) |
| - | - | - |
| Defined | Once, on the app | Per workspace |
| Delivered for | Every connected workspace | The workspace that created it |
| Payload | Thin: topic, id, changed fields | The full record, plus changes |
| Signed with | The app's signing secret | That webhook's own token |
| Ends when | The workspace's last connection is removed | The webhook is deleted |

## Security

### Verifying signatures

Every webhook button call and event delivery has these headers:

| Header | Value |
| - | - |
| `Campfire-App-Id` | Your app's id. |
| `Campfire-Delivery-Kind` | `slot` (button) or `event`. |
| `Campfire-Webhook-Request-Timestamp` | Unix seconds. |
| `Campfire-Webhook-Signature-v1` | Hex HMAC-SHA256 of `"{timestamp}:{raw body}"`, keyed with the full signing secret (including `whsec_`). |
| `Campfire-Webhook-Signature-v1-Previous` | The same, with the previous secret. Sent for 24 hours after a rotation. |

Accept a request when either signature header matches, and reject timestamps more than 5 minutes off. Always compute the HMAC over the **raw** body bytes, before parsing the JSON.

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, time

  def verify(headers, body: bytes, secrets: list[str]) -> bool:
      ts = headers.get("Campfire-Webhook-Request-Timestamp", "")
      if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
          return False
      sent = [s for s in (headers.get("Campfire-Webhook-Signature-v1"),
                          headers.get("Campfire-Webhook-Signature-v1-Previous")) if s]
      for secret in secrets:
          expected = hmac.new(secret.encode(), ts.encode() + b":" + body, hashlib.sha256).hexdigest()
          if any(hmac.compare_digest(expected, s) for s in sent):
              return True
      return False
  ```

  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  export function verify(headers, rawBody, secrets) {
    const ts = headers["campfire-webhook-request-timestamp"] ?? "";
    if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
    const sent = [headers["campfire-webhook-signature-v1"], headers["campfire-webhook-signature-v1-previous"]].filter(Boolean);
    return secrets.some((secret) => {
      const expected = crypto.createHmac("sha256", secret).update(`${ts}:`).update(rawBody).digest("hex");
      return sent.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
    });
  }
  ```
</CodeGroup>

### Context tokens

Whenever Campfire sends a person to your app, it includes a **context token**: on link buttons and the manage link as `cf_ctx`, in webhook bodies as `context_token`, and to embedded pages over the bridge. It's an HS256 JWT signed with your signing secret, valid for **5 minutes**:

```json theme={null}
{
  "iss": "campfire",
  "aud": "YOUR_CLIENT_ID",
  "jti": "6f0d8a8e-...",
  "iat": 1791990000,
  "exp": 1791990300,
  "customer_id": 52,
  "user_id": 31017,
  "slot_key": "view",
  "object_type": "invoice",
  "object_ids": ["18545770"],
  "surface": "detail_header"
}
```

| Opened from | `surface` | `slot_key` | `object_type` / `object_ids` |
| - | - | - | - |
| A button | `detail_header`, `list_header`, or `row_menu` | The button's key | The record type and ids |
| A panel | `panel:list`, `panel:detail`, or `panel:edit` | The panel's key | The record type; ids except on lists |
| A navigation page | `nav` | The entry's key | `workspace`, `[]` |
| The manage link | `manage` | `manage` | `workspace`, `[]` |

To verify one:

1. Check the signature against your current and previous signing secrets.
2. Require `exp`, `iat`, `jti`, `aud`, and `iss`. Check that `aud` is your client ID and `iss` is `campfire`.
3. Check that the record you're about to show is in `object_ids`.
4. Record the `jti` and refuse it if you've seen it before. Campfire doesn't enforce single use for you.

```python theme={null}
import jwt

claims = jwt.decode(
    token, signing_secret, algorithms=["HS256"],
    audience=CLIENT_ID, issuer="campfire",
    options={"require": ["exp", "iat", "jti", "aud", "iss"]},
)
```

<Warning>
  A context token proves **who** opened your page and **what** they were looking at. It is **not** an API token. Make API calls with the workspace's OAuth access token, which you store when someone connects your app.
</Warning>

### The bridge

Pages embedded in drawers, modals, panels and navigation talk to Campfire with `window.postMessage`. Campfire only accepts messages from your frame's origin, and you should only accept messages from Campfire's origin (`https://app.meetcampfire.com`) sent by `window.parent`.

Every message from your page has this shape:

```js theme={null}
{ source: "campfire-app-bridge", version: 1, type: "...", payload: { ... }, requestId: "..." }
```

| `type` | `payload` | What Campfire does |
| - | - | - |
| `ready` | none (send a `requestId`) | Replies with the context (below). |
| `toast` | `{level, message}` | Shows a notification. |
| `refresh` | none | Reloads the record or list behind your page. |
| `resize` | `{height}` | Sets the frame's height (120 to 2,000 px). |
| `close` | none | Closes the drawer or modal. Ignored in panels and pages. |
| `open_url` | `{url}` | Opens a URL on your frame's origin in a new tab. |

Campfire's reply to `ready`:

```js theme={null}
{
  source: "campfire-host", version: 1, type: "context", requestId: "...",
  payload: { context_token: "eyJ...", object_type: "invoice", object_ids: ["18545770"],
             surface: "detail_header", theme: "light" }
}
```

Send the `context_token` to your own server to verify it, then load your data. FakeTax's [`static/app-bridge.js`](https://github.com/Campfire-eng/campfire_faketax_app/blob/main/static/app-bridge.js) is a 70-line wrapper you can copy. It provides `ready()`, `toast()`, `refresh()`, `resize()`, `autoResize()`, `close()`, and `openUrl()`.

<Tip>
  An embedded page usually makes several requests to your server, but the context token is single-use. FakeTax claims the token on the first request and opens a short-lived server-side session for the frame. Later requests use that session.
</Tip>

## What workspace admins control

On the connection page (**Settings > Connections**, then your app), a workspace admin can switch off each button, panel, navigation entry and subscription, and change a button's style for their workspace. These settings survive changes to your app, because they're matched by key (and by endpoint URL for subscriptions).

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/connection-page-settings.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=b0c0ecaabfc7e8b2e5d064e9d8ea04ca" alt="The connection page's Panels, Navigation and Subscriptions switches and the connection activity log" width="1800" height="1125" data-path="images/oauth-apps/connection-page-settings.png" />
</Frame>

## Activity and testing

The **Activity** tab on your app's page lists the latest 100 button clicks and event deliveries across every workspace, with status, HTTP code and response time. Use it to debug failures:

<Frame>
  <img src="https://mintcdn.com/campfire/EZaYuU9NXAcXYXMc/images/oauth-apps/app-activity.png?fit=max&auto=format&n=EZaYuU9NXAcXYXMc&q=85&s=f375a8afd958c01e8773314a97fa6ce2" alt="The Activity tab listing recent calculate, view, breakdown and calculate-bulk button clicks with status, HTTP code and timing" width="1800" height="1125" data-path="images/oauth-apps/app-activity.png" />
</Frame>

To test without real records, use **Send Test** on a subscription.

<Note>
  Extensions are **not versioned**. Allowed domains, the signing secret, buttons, panels, navigation and event subscriptions take effect in every connected workspace as soon as you save. Only the app's details and permissions go through [versions](/guides/oauth-apps#versions-and-approval). Test changes in your own workspace with a separate development app before you change the one your customers use.
</Note>

## Limits

| Item | Limit |
| - | - |
| Buttons per app | 50 |
| Panels per app | 20 |
| Navigation entries per app | 30 (one level of nesting) |
| Event subscriptions per app | 10 |
| Allowed domains | 20 |
| Webhook button timeout | 1,000 to 10,000 ms (default 8,000) |
| Webhook response size | 64 KB |
| Effects per response | 4 |
| Rows per list-header click | 50 |
| Clicks and panel opens | 30 per minute per person |
| Context token lifetime | 5 minutes |
| Install config | 32 keys, 2,048 characters, string values |
