---
title: "Migrate from Octolens"
description: "Switch from Octolens by changing two settings, copy your keywords, feeds and filters with one script, and move your alerts."
canonical: https://docs.rumoro.dev/migrate/octolens
markdown: https://docs.rumoro.dev/migrate/octolens.mdx
---

# Migrate from Octolens

Switch from Octolens by changing two settings, copy your keywords, feeds and filters with one script, and move your alerts.

Rumoro speaks Octolens' `/api/v2` API at `https://api.rumoro.dev/compat/octolens`, with the same paths and JSON. Code you wrote for Octolens, the `octolens` CLI and integrations built on its API keep working after you change the base URL and the API key. For prices and features side by side, see [Octolens and Rumoro compared](https://rumoro.dev/alternatives/octolens).

| | Octolens | Rumoro |
| --- | --- | --- |
| Base URL | `https://app.octolens.com` | `https://api.rumoro.dev/compat/octolens` |
| Key | `Bearer <octolens key>` | `Bearer ref_...` |
| Paths, JSON, errors | `/api/v2/...` | The same |
| Rate limit | 500 requests an hour | 600 a minute per workspace |
| Price | Plans with a monthly mention quota | $5 per keyword a month and $0.008 per matched mention, prepaid, no plan |

Keywords you create through this API are normal Rumoro keywords, with the same balance checks and alerts. Switch to the [`/v1` API](/api) when you want keyword groups, people, share of voice, Telegram alerts or the MCP server.

## Step 1. Change the base URL

Create a key on the **API keys** page, then point your tools at Rumoro.

### The octolens CLI

```bash
export OCTOLENS_BASE_URL=https://api.rumoro.dev/compat/octolens
export OCTOLENS_API_KEY=ref_...

octolens whoami --json
octolens keywords list
octolens mentions list --source twitter
```

`whoami` shows your Rumoro workspace, and you don't need `octolens login`. Tested with `octolens` 0.1.8.

### Your own code

```ts
const BASE = 'https://api.rumoro.dev/compat/octolens'; // was https://app.octolens.com
const headers = { Authorization: `Bearer ${process.env.RUMORO_API_KEY}`, 'Content-Type': 'application/json' };

const res = await fetch(`${BASE}/api/v2/mentions`, { method: 'POST', headers, body: JSON.stringify({ limit: 20 }) });
const { data, pagination } = await res.json();
```

HTTP steps in Zapier or n8n change the same way.

## Step 2. Copy your workspace

Set both keys and run the script once. It copies your company profile, keywords, feeds and global filters, and tells you about anything it can't copy.

```js title="copy-from-octolens.mjs"
// node copy-from-octolens.mjs  (Node 18 or newer, nothing to install)
const FROM = { base: process.env.OCTOLENS_BASE_URL ?? 'https://app.octolens.com', key: process.env.OCTOLENS_API_KEY };
const TO = { base: process.env.RUMORO_COMPAT_URL ?? 'https://api.rumoro.dev/compat/octolens', key: process.env.RUMORO_API_KEY };

async function api(side, method, path, body) {
  const response = await fetch(`${side.base}/api/v2${path}`, {
    method,
    headers: { Authorization: `Bearer ${side.key}`, 'Content-Type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(`${method} ${path}: ${json.error?.code ?? response.status} ${json.error?.message ?? ''}`.trim());
  return json;
}
async function attempt(label, work) {
  try { return await work(); } catch (error) { console.warn(`${label}: not copied (${error.message})`); return undefined; }
}

// Company profile. Rumoro scores relevance against it.
const company = await api(FROM, 'GET', '/org/company');
const profileFields = ['name', 'description', 'productUseCases', 'competitors', 'relevanceContext', 'relevanceGuidelines', 'twitter', 'linkedin'];
const profile = Object.fromEntries(profileFields.filter((field) => company[field]).map((field) => [field, company[field]]));
await attempt('company profile', () => api(TO, 'PATCH', '/org/company', profile));

// Keywords. Remember each new id, because the feeds refer to keywords by id.
const keywordIds = new Map();
for (const keyword of (await api(FROM, 'GET', '/keywords')).data) {
  const { id, isSubReddit, symbolSensitive, ...rest } = keyword;
  if (isSubReddit) { console.warn(`keyword ${keyword.keyword}: skipped (subreddit keywords are a global filter in Rumoro)`); continue; }
  const fields = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== null)); // drop null fields, which a create doesn't accept
  const created = await attempt(`keyword ${keyword.keyword}`, () => api(TO, 'POST', '/keywords', fields));
  if (created) keywordIds.set(String(id), String(created.id));
}

// Feeds. If none of a feed's keywords made it across, skip the feed so it doesn't suddenly match everything.
const translate = (condition) => {
  if (condition.field !== 'Keywords') return { condition, lost: false };
  const values = String(condition.values).split(',').map((value) => keywordIds.get(value.trim())).filter(Boolean);
  const excludes = String(condition.operator ?? 'in').startsWith('not');
  return { condition: { ...condition, values: values.join(',') }, lost: values.length === 0 && !excludes };
};
for (const feed of (await api(FROM, 'GET', '/feeds')).data) {
  if (feed.isDefault) continue;
  let lost = false;
  const conditions = (list) => list.map(translate).filter((item) => { lost ||= item.lost; return item.condition.field !== 'Keywords' || item.condition.values; }).map((item) => item.condition);
  const body = { name: feed.name };
  if (feed.simpleFilters) body.simpleFilters = { conditions: conditions(feed.simpleFilters.conditions) };
  if (feed.advancedFilters) body.advancedFilters = { ...feed.advancedFilters,
    groups: feed.advancedFilters.groups.map((group) => ({ ...group, conditions: conditions(group.conditions) })).filter((group) => group.conditions.length) };
  if (lost) { console.warn(`feed ${feed.name}: skipped (none of its keywords was copied)`); continue; }
  await attempt(`feed ${feed.name}`, () => api(TO, 'POST', '/feeds', body));
}

// Workspace-wide filters.
await attempt('global filters', async () => api(TO, 'PATCH', '/filters/global', await api(FROM, 'GET', '/filters/global')));
console.log('done');
```

```bash
OCTOLENS_API_KEY=... RUMORO_API_KEY=ref_... node copy-from-octolens.mjs
```

Copied keywords start collecting right away. On X, Bluesky, GitHub, Stack Overflow, TikTok and Hacker News they also fetch up to 10 matches from the last 30 days, and on Instagram from the last 10 days.

Rumoro only creates a keyword while your balance covers one more day of all running keywords. The welcome credit of $5.80 counts toward that. Keywords that don't fit are reported as `QUOTA_EXCEEDED`. Add funds and run the script again, and keywords already copied won't be duplicated. The script also reports keywords that would only track Product Hunt, Medium, newsletters, podcasts, Reddit comments or review pages.

## Step 3. Move your notifications

Octolens notifications become Rumoro [alerts](/alerts). Create them under **Alerts** or with [`POST /v1/alerts`](/api/alerts/create-alert). Alerts go to Slack, Telegram, email or a webhook.

| Octolens | Rumoro alert |
| --- | --- |
| `frequency: hourly` or `hourlyAtTopOfHour`, `deliveryMode: batch` | `mode: hourly`, sent five minutes past each UTC hour and skipped when empty. Not available by email. |
| `frequency: daily` or `weekly`, with `time`, `timezone`, `dayOfWeek` | `mode: daily` or `weekly`, with `schedule.hour`, `minute`, `timezone`, `weekday` |
| `deliveryMode: individual`, and every webhook | `mode: instant`, one message per mention |

Rumoro webhooks are signed with `X-Mentions-Signature-V2` (see [Webhooks](/webhooks)), so add the check to your handler when you move it.

## Step 4. Switch over

1. Run step 2, keep both tools running for a day, and compare the results of `POST /api/v2/mentions`.
2. Point your code and the CLI at Rumoro.
3. Set up alerts to replace your notifications, and test each one.
4. Pause your Octolens keywords, then cancel your Octolens plan.

## Differences in the compatible API

| Topic | How it works in Rumoro |
| --- | --- |
| Ids | Numbers, given the first time an item is seen and kept after that. `sourceId` is the Rumoro id (`mm_...`), and `GET /api/v2/mentions/{sourceId}` accepts the number too. |
| Rows | One row per matched keyword, where Octolens combines them into one. |
| Titles and images | `title` and `imageUrl` when the platform has them |
| Lists | Only relevant mentions unless `includeAll: true`. Muted people and snoozed mentions are left out. At most 50 keyword ids. Pages of 20, up to 100. |
| Relevance | `relevanceScore` 0 means a score of 70 or more, 1 means 40 to 69, 2 means lower. `relevant` means 40 or more. Writing 0 or 1 sets the score to 100, 2 marks a mention not relevant, and 3 restores the original. Filters accept high, high and medium, low, or all. Other combinations return `501`. |
| Tags | Rumoro's intents under Octolens' names (`product_question`, `user_feedback` and so on), plus `own_brand_mention`, `competitor_mention` and `ai_generated` |
| Platforms | `twitter` is X, `dev` is DEV, and `reddit_comment` is Reddit replies. Keywords created or updated here always include Instagram. Product Hunt, Medium, newsletters and podcasts aren't searched. Review pages are added per keyword in the dashboard or through `/v1`. |
| Keyword matching | Whole phrases only (`symbolSensitive` has no effect). `*` wildcards work only at the start or end. `isSubReddit` returns `501`, so use `positiveSubreddits`. |
| Filters | AND and OR groups become [`anyOf`](/conventions#or-across-groups-anyof), up to 10 alternatives. `Content` returns `501`, so use `search`. `TwitterFollowerCount` accepts `>=`, `<=` and `=`. |
| Global filters | `add` skips duplicates and lists what it refused under `dropped`. Emptying a list needs `allowEmpty: true`. |
| Feeds | Saved as views, without the icon. Destinations return `501`. A view this API can't represent is read-only here (`409 FEED_FILTER_WRITE_CONFLICT`). |
| Exports | Up to 5,000 mentions. `author` exports one person's mentions. Read-only keys can export. |
| Keywords | 2 to 80 characters. Sending an existing term returns that keyword, and with `allowDuplicate: true` it returns `409 ITEM_EXISTS`. |
| Company | `classificationGuidelines` returns `501`, so use `relevanceGuidelines`. |
| Analytics | UTC days, 30 by default, high and medium relevance unless you set `relevance=0,1,2`. Tag or sentiment filters return `501`. |
| Paths | Paths outside `/api/v2`, or with a trailing slash, return `404`. Unknown `/api/v2` paths return `501`. |
| Usage | `mentions.limit` is how many mentions your balance still covers this month. |
| Rate limit | Shared with `/v1` and MCP, with `X-RateLimit-*` headers |

## Not available

These return `501 FEATURE_DISABLED`. Agency workspaces, setup scans, the AI filter wizard and recommendations, on-demand search, keyword suggestions and estimates, notifications and export download links. Several of them have an equivalent in `/v1`, such as [keyword health](/guides/keyword-health) and [alerts](/alerts).
