# Destinations: send themes to Linear, GitHub, Slack and more

> Send Camelus themes to Vicunia, n8n, Zapier, Make, Slack, Discord or any endpoint: signed webhooks, events, payloads, retries and recipes.

A **destination** sends themes from Camelus to another app: Vicunia, an n8n, Zapier or Make workflow (which can open a Linear, GitHub or Jira issue), a Slack or Discord channel, or your own endpoint. They're all the same thing underneath: a signed HTTPS webhook. Presets only fill in the form.

## Add one

**Settings → Destinations → Add destination**, then pick a preset:

| Preset | Sends | Reports back |
| --- | --- | --- |
| [Vicunia](/docs/vicunia) | The theme as a Vicunia request | Build progress and "shipped", through Vicunia's source callback |
| n8n, Zapier, Make | The [canonical JSON](#payload) | Your workflow calls the [status callback](/docs/callbacks) |
| Slack, Discord | A short message to an incoming-webhook URL | No |
| Webhook | The canonical JSON, to any HTTPS endpoint | Your code calls the status callback |

You choose:

- **Products**: all, or some.
- **When to send**: by hand with **Send to…** on a theme, automatically on events (Pro), or both.
- **Filters** for automatic theme events: a minimum score, a minimum number of requesters, types, paying requesters only.
- **Auth header**: an extra header such as `Authorization: Bearer …`, stored encrypted.

The **signing secret** (`whsec_…`) is shown once when you create the destination. **Rotate secret** makes a new one; for 24 hours requests are signed with both, so you can switch your receiver over without missing anything.

Use **Test** to send a `test.ping` event. The **delivery log** shows every request with its status code, timing, body and the first 2 KB of the response, with **Retry** and **Replay**.

## Events

| Event | When |
| --- | --- |
| `theme.sent` | Someone clicks **Send to…** on a theme |
| `theme.created` | A theme gets its second item (or an urgent bug) |
| `theme.rising` | A theme's trend turns to rising |
| `theme.threshold_reached` | A theme passes the destination's filters (once per theme) |
| `theme.status_changed` | A theme's status changes, in Camelus or through a callback |
| `theme.merged` | Two themes are merged (the payload is the surviving theme) |
| `feedback.received` | A feedback item is processed |
| `feedback.urgent` | An item is classified as high urgency |
| `digest.ready` | The daily or weekly digest is computed |
| `test.ping` | The **Test** button |

## Payload

```
POST https://your-endpoint.example.com/camelus
Content-Type: application/json
User-Agent: Camelus-Webhooks/1
Camelus-Event: theme.sent
Camelus-Delivery: dlv_6f1c…
Camelus-Timestamp: 1792915200
Camelus-Signature: v1=5c2a…
Camelus-Version: 2026-10-01
```

```json
{
  "id": "evt_9b2e…",
  "type": "theme.sent",
  "created_at": "2026-10-25T08:00:00.000Z",
  "workspace": { "id": "k17…" },
  "product": { "id": "k57…", "slug": "habits", "name": "Habits", "domain": "habits.app" },
  "data": {
    "theme": {
      "id": "k97d…",
      "title": "CSV export ignores date locale",
      "summary": "Exports use US dates whatever the user's locale.",
      "type": "bug",
      "status": "open",
      "score": 4.2,
      "trend": "rising",
      "requesters": 17,
      "paying_requesters": 6,
      "mrr_affected_cents": null,
      "item_count": 21,
      "sentiment": { "negative": 82, "neutral": 18, "positive": 0 },
      "last_item_at": "2026-10-25T07:41:00.000Z",
      "url": "https://camelus.si/app/themes/k97d…",
      "representative_items": [
        { "id": "k3a…", "summary_en": "CSV export uses US dates for a French user.", "source": "widget", "received_at": "2026-10-25T07:41:00.000Z" }
      ]
    }
  },
  "callback": { "url": "https://api.camelus.si/v1/callbacks/k97d…", "token": "cbt_…" }
}
```

Payloads carry AI summaries and counts, never original text, names or emails. A workspace owner can turn on **Include requester emails** for one destination (for your own CRM, say); that choice is logged. `mrr_affected_cents` is `null` until a billing connector is set up: it's never estimated. Every schema is in the [OpenAPI document](https://api.camelus.si/v1/openapi.json), under `webhooks`.

## Verify the signature

The signature is HMAC-SHA256, keyed with the whole signing secret, over `{timestamp}.{raw body}`. During a rotation the header holds two signatures, `v1=…,v1=…`; accept either.

```js
import crypto from "node:crypto";

export function verifyCamelus(rawBody, headers, secret) {
  const ts = headers["camelus-timestamp"];
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes: a replay
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return (headers["camelus-signature"] ?? "").split(",").some((part) => {
    const sig = part.trim().replace(/^v1=/, "");
    return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

Use the raw body exactly as received, before any JSON parsing.

## Delivery

- Answer with any `2xx` within 10 seconds. Redirects are followed (at most 3).
- Anything else is retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: 7 attempts over a day. `410 Gone` turns the destination off right away.
- After 20 failed deliveries in a row, or 3 days of failures, the destination is paused and the owner gets an email. **Resume** it once the endpoint works.
- Order isn't guaranteed. Use `created_at` to order events, and `Camelus-Delivery` (the same on every retry) to ignore repeats.
- URLs must be public `https://` addresses. Local and private networks are refused, also after DNS resolution and after redirects.

## n8n

Two ready-made workflows: download, then **Import from file** in n8n.

- [Camelus → Linear](/recipes/n8n-linear.json): a sent theme becomes a Linear issue; the callback reports it as planned, and as shipped when the issue is completed.
- [Camelus → GitHub Issues](/recipes/n8n-github.json): the same with a GitHub issue, shipped when it's closed.

After importing: paste your signing secret into the **Verify** node, pick your Linear team or GitHub repository and credentials, then copy the workflow's production webhook URL into a Camelus destination (preset n8n). The callback URL and token travel in a footer of the issue, so closing it reports back without a database.

## Zapier

1. Create a Zap with **Webhooks by Zapier → Catch Raw Hook**, and paste its URL into a destination (preset Zapier).
2. Add a **Code by Zapier** step to check `Camelus-Signature` with the snippet above (the raw body and headers are in the trigger's output).
3. Add **Linear → Create Issue** or **GitHub → Create Issue**, with the theme's title, summary and `url`. Put `callback.url` and `callback.token` in the description.
4. In a second Zap triggered when the issue is completed or closed, add **Webhooks by Zapier → POST** to the callback URL from the description, with the header `Authorization: Bearer <token>` and the body `{"status": "shipped", "external_id": "<issue id>", "external_url": "<issue url>"}`.

## Make

1. Start a scenario with **Webhooks → Custom webhook** and paste its address into a destination (preset Make). Turn on **Get request headers** and **JSON pass-through** so the raw body is available for the signature.
2. Add **Linear → Create an issue** or **GitHub → Create an issue**, mapping `data.theme.title`, `data.theme.summary` and `data.theme.url`, plus the callback block.
3. In a second scenario watching for completed or closed issues, add **HTTP → Make a request**: `POST` to the callback URL, header `Authorization: Bearer <token>`, JSON body with `status: shipped`.

## Slack and Discord

Create an incoming webhook in Slack (**Apps → Incoming Webhooks**) or Discord (**Channel settings → Integrations → Webhooks**) and paste its URL. By default they get new themes, rising themes, urgent feedback and the digest. Mentions in summaries never ping anyone.
