# Birdeye

> Send Birdeye reviews from every review site and location to Dimension Labs with the universal API.

This brings in the reviews Birdeye collects for your business locations, from Google, Facebook and the other review sites Birdeye aggregates, plus reviews left directly on Birdeye: each review as a customer message, with its rating and source, and your business response when there is one. One conversation per review. You run it on your side: a small script pulls from the Birdeye API and posts each message to the [universal API](/api/tracker).

## What you need

- A Birdeye account and its API key. Birdeye says the key can be fetched from the Birdeye dashboard and is confidential: call the API from your server only, never from a browser.
- The Birdeye business ID of each location you want to bring in.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Birdeye

Birdeye's steps are in [Authentication and rate limiting](https://docs.birdeye.com/api/authentication).

1. Get your API key from the Birdeye dashboard. Store it in a secrets manager.
2. Send it on every request in the `x-api-key` header. Birdeye moved the key from a query parameter to this header in February 2026.
3. Find the business ID of each location. For an enterprise account, Birdeye's [Child Businesses](https://docs.birdeye.com/api/business/get-child-businesses) API lists the businesses under the account.

## 2. Pull new reviews

```bash
curl -X POST -H "x-api-key: $BIRDEYE_API_KEY" -H "Content-Type: application/json" \
  -d '{"fromTimestamp": 1759276800000, "statuses": ["all"]}' \
  "https://api.birdeye.com/resources/v1/review/businessId/BUSINESS_ID?sindex=0&count=100"
```

- "Get Reviews" is a `POST` with the filters in a JSON body. The response is an array of reviews.
- **Featured and non-featured:** with no `statuses`, Birdeye returns featured reviews only. `"statuses": ["all"]` returns both.
- **Incremental:** `fromTimestamp` and `toTimestamp` filter by review date, in Unix milliseconds. Birdeye returns the review date itself as a day (`Jan 20, 2014`), with no time.
- **Pagination:** `sindex` (start index) and `count`. Birdeye doesn't support a window past 100,000 records (`sindex` plus `count` must be 100,000 or less), so the code asks for 30 days at a time.
- The business response is in the same review, under `response` and `responseDate`, so there is no second call.
- **Rate limits (Birdeye's):** each API key has a limit, and Birdeye asks you to contact its support team for the current one. Over the limit, it returns `429`.

## 3. Map the fields

Each review is one incoming message under its own `sessionId`. A business response is an outgoing message in the same session.

| Dimension Labs | Birdeye | Notes |
|---|---|---|
| `text` | `title` and `comments` (incoming), `response` (outgoing) | Join the title and body with a line break when there is a title. Reviews with no text are skipped. |
| `userId` | `customerId` | Birdeye's id for the contact the review is attributed to, when there is one. Otherwise the `sessionId`. |
| `incoming` | | `true` for the review, `false` for the business response. |
| `sessionId` | `reviewId` | Prefixed `birdeye-`. |
| `conversationId` | `reviewId` | |
| `dimensionlabs_timestamp` | `reviewDate` (review), `responseDate` (response) | A day with no time, read as midnight UTC. A response on the same day as the review is moved a second later. |
| `platformJson` | business ID, `sourceType`, `rating`, `recommended`, `reviewURL`, `extReferenceId`, `reviewOperationalStatus` | `sourceType` says which site the review came from. `rating` is 0 for direct feedback and Facebook recommendations. `extReferenceId` is your own reference for the location. |

**Personal data.** Birdeye returns the reviewer's name, and can return their email, contact details and custom fields (`needCustomerInfo`, `fetchExtraParams`) and the employees who helped (`fetchAssitedByDetails`). Don't send any of them: the code leaves those options off and uses Birdeye's ids instead. Reviews can still name people in their text: see [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// birdeye.mjs. Environment: BIRDEYE_API_KEY, BIRDEYE_BUSINESS_IDS (comma-separated), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const WINDOW = 30 * DAY; // keeps each request well under Birdeye's 100,000-record window
const COUNT = 100;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Birdeye gives dates as a day ("Jan 20, 2014") or as Unix milliseconds. A day is read as midnight UTC.
function day(d) {
  if (d == null || d === '') return null;
  if (typeof d === 'number' || /^\d+$/.test(String(d))) return toMillis(Number(d));
  const t = Date.parse(`${d} 00:00:00 UTC`);
  return Number.isNaN(t) ? toMillis(d) : t;
}

// Retries Birdeye's rate limits (429) and server errors (5xx), honoring Retry-After when it is sent.
async function getReviews(businessId, sindex, body) {
  const url = `https://api.birdeye.com/resources/v1/review/businessId/${businessId}?sindex=${sindex}&count=${COUNT}`;
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { method: 'POST', body: JSON.stringify(body),
      headers: { 'x-api-key': process.env.BIRDEYE_API_KEY, Accept: 'application/json', 'Content-Type': 'application/json' } });
    // Review ids can be longer than JavaScript numbers hold exactly, so read them as text.
    if (res.ok) return JSON.parse((await res.text()).replace(/"reviewId"\s*:\s*(\d+)/g, '"reviewId":"$1"'));
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Birdeye returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

async function send(r, businessId, historical) {
  const created = day(r.reviewDate);
  if (created == null) return;
  const id = `birdeye-${r.reviewId}`;
  const base = { userId: r.customerId ? `birdeye-customer-${r.customerId}` : id, sessionId: id, conversationId: id,
    platformJson: { businessId, source: r.sourceType, rating: r.rating, recommended: r.recommended,
      reviewUrl: r.reviewURL ?? r.reviewUrl, extReferenceId: r.extReferenceId, status: r.reviewOperationalStatus } };
  const text = [r.title, r.comments].filter((x) => x?.trim()).join('\n');
  if (text) {
    await sendToDimensionLabs({ ...base, text, incoming: true, dimensionlabs_timestamp: created },
      { historical: historical || created < Date.now() - HOUR });
  }
  if (r.response?.trim()) {
    const replied = Math.max(day(r.responseDate) ?? created, created + 1000);
    await sendToDimensionLabs({ ...base, text: r.response.trim(), incoming: false, dimensionlabs_timestamp: replied },
      { historical: historical || replied < Date.now() - HOUR });
  }
}

// Sends every review dated on or after the day before `since` (Unix ms), for each location. Returns the new `since`.
export async function syncBirdeye({ since, historical = false }) {
  const started = Date.now();
  const ids = process.env.BIRDEYE_BUSINESS_IDS.split(',').map((x) => x.trim()).filter(Boolean);
  for (const businessId of ids) {
    // Review dates are days, so start a day early; anything sent twice is removed as a duplicate.
    for (let from = since - DAY; from < started + DAY; from += WINDOW) {
      const body = { fromTimestamp: from, toTimestamp: from + WINDOW, statuses: ['all'] };
      for (let sindex = 0; sindex + COUNT <= 100000; sindex += COUNT) {
        const reviews = await getReviews(businessId, sindex, body);
        for (const r of reviews) await send(r, businessId, historical);
        if (reviews.length < COUNT) break;
      }
    }
  }
  return started;
}
```

Each run asks for every review dated from the day before the cursor, so a day of reviews is sent again on each run: those repeats are duplicates, removed automatically. This picks reviews up by review date, so a business response written after the review was pulled is not sent. To catch those, run a history load over the last few weeks now and then (`node run.mjs` with a recent date): it sends the new responses, and everything else in it is a duplicate. Then run a backfill.

## 5. Run it

Save the code as `birdeye.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncBirdeye as sync } from './birdeye.mjs';`. One Dimension Labs source can hold all your locations: the business ID goes in `platformJson.businessId`.

- **History first:** `node run.mjs 2020-01-01` sends every review since that date to `/trackhistorical`, 30 days at a time. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new reviews. Because Birdeye dates a review by its day, most reviews are already more than an hour "old" when the job sees them, so they go to `/trackhistorical`. Run a backfill over recent dates each week so they are enriched.
- **Re-runs are safe:** duplicates are removed automatically.
- **Rate limits:** Birdeye doesn't publish its limit. The code waits and retries on `429`. With many locations, ask Birdeye support for your limit before a big history load.

If you have reviews in a file instead, [upload it](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## Birdeye reference

- [Introduction](https://docs.birdeye.com/api/introduction)
- [Authentication and rate limiting](https://docs.birdeye.com/api/authentication)
- [Get Reviews](https://docs.birdeye.com/api/reviews/get-reviews)
- [Get Review Responses](https://docs.birdeye.com/api/reviews/get-review-responses)
- [Child Businesses](https://docs.birdeye.com/api/business/get-child-businesses)

Checked against Birdeye's documentation on 2026-10-26.
