# Feefo

> Send Feefo service and product reviews to Dimension Labs with the universal API.

This brings in the service and product reviews in your Feefo account: each review as a customer message, with its star rating and product, plus the follow-up comments between the customer and your team. One conversation per service review and one per product review. You run it on your side: a small script pulls from the Feefo Reviews API and posts each message to the [universal API](/api/tracker).

## What you need

- **Feefo's agreement to bulk retrieval.** Feefo says its Reviews API is "not suitable as a means of batch retrieving review content if you plan to self-host the data due to network edge caching", and asks you to contact Feefo to discuss the best option if you want to keep your review content. Sending reviews to Dimension Labs keeps a copy of them, so contact Feefo before you run this guide. Because of that caching, the API can also return recent changes late.
- Your Feefo **merchant identifier**. Public review content needs nothing else.
- For the private fields (order and customer references, sale tags, custom question answers), an **App Key** with access to the Reviews API.
- 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 Feefo

Feefo's steps are in [How to connect using an OAuth 2.0 Token](https://help.feefo.com/knowledge-base/how-to-connect-using-an-oauth-2.0-token).

1. In the Feefo Hub, go to **Settings**, then under **Connectivity** select **App Keys**, and click **Create App Key**.
2. Name the key, and select only the **Reviews API** service. An App Key grants access to the selected merchant and all its sub-merchants; you can restrict it with tags.
3. Open the key from **Settings > App Keys** with **Details**, and copy the **Client ID** and **Secret**.
4. Get an access token with a `POST` to `https://api.feefo.com/api/oauth/v2/token` and the body `{"client_id": "...", "client_secret": "...", "grant_type": "client_credentials"}`. The token lasts for `expires_in` seconds (3,600 in Feefo's example). Send it as `Authorization: Bearer ACCESS_TOKEN`. The code below does this when the App Key variables are set.

## 2. Pull new reviews

```bash
curl -H "Authorization: Bearer $FEEFO_TOKEN" \
  "https://api.feefo.com/api/20/reviews/all?merchant_identifier=YOUR_MERCHANT&since_updated_period=month&full_thread=include&page_size=100&page=1"
```

- The `20` in the path is the API version. Feefo lists the current one on [API Versions](https://help.feefo.com/knowledge-base/api-versions).
- `reviews/all` returns service and product reviews. Each entry is one sale: a `service` review and a `products` array.
- **Incremental:** `since_updated_period` returns reviews updated in the last `month`, `year`, or `all` time. The code picks the shortest period that covers the cursor, then skips entries whose `last_updated_date` is older.
- **Follow-ups:** `full_thread=include` adds the later exchanges between the customer and your team (`thread`), including your replies.
- **Pagination:** `page` and `page_size` (up to 100, default 20). `summary.meta.pages` is the number of pages.
- **Rate limits:** Feefo's help center pages don't state one. The code waits and retries on `429` and `5xx`.

## 3. Map the fields

Each service review and each product review is a conversation of its own. The review is an incoming message, a customer comment in the thread is incoming, and a merchant comment is outgoing.

| Dimension Labs | Feefo | Notes |
|---|---|---|
| `text` | `service.title` and `service.review`, `products[].review`, `thread[].comment` | Thread entries that only change a rating or title (`customer_rerate`, `customer_retitle`) have no text and are skipped. |
| `userId` | | Feefo returns no internal customer id, so use the `sessionId`. Don't send `customer.name`, `customer.email` or `customer.mobile`. |
| `incoming` | `thread[].type` | `true` for the review and `customer_comment`, `false` for `merchant_comment`. |
| `sessionId` | `service.id`, `products[].id` | Prefixed `feefo-`. |
| `conversationId` | `service.id`, `products[].id` | |
| `dimensionlabs_timestamp` | `created_at`, `thread[].created_at` | UTC. Convert with `toMillis`. Each message is kept at least a second after the one before it. |
| `platformJson` | `rating.rating`, `product.sku`, `product.title`, `customer.order_ref`, `customer.customer_ref`, `tags`, `merchant.identifier` | `order_ref` and `customer_ref` (with an App Key) are the fields to join to your own data, as long as they aren't email addresses. |

**Personal data.** With an App Key, Feefo returns the customer's name, email address and mobile number from your sale upload. Don't send them: the code leaves them out. `customer.display_name` is left out too. Reviews can still name people in their text: see [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// feefo.mjs. Environment: FEEFO_MERCHANT_IDENTIFIER, DL_API_KEY.
// Optional, for private fields: FEEFO_CLIENT_ID, FEEFO_CLIENT_SECRET (an App Key with the Reviews API).


const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const at = (d, fallback) => { try { return d != null && d !== '' ? toMillis(d) : fallback; } catch { return fallback; } };
let token = null, tokenExpires = 0;

async function accessToken() {
  if (!process.env.FEEFO_CLIENT_ID) return null; // public content only
  if (token && Date.now() < tokenExpires - 5 * 60 * 1000) return token;
  const res = await fetch('https://api.feefo.com/api/oauth/v2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ client_id: process.env.FEEFO_CLIENT_ID, client_secret: process.env.FEEFO_CLIENT_SECRET, grant_type: 'client_credentials' }) });
  if (!res.ok) throw new Error(`Feefo token request returned ${res.status}: ${await res.text()}`);
  const body = await res.json();
  token = body.access_token;
  tokenExpires = Date.now() + Number(body.expires_in ?? 3600) * 1000;
  return token;
}

// Retries Feefo's rate limits (429) and server errors (5xx), honoring Retry-After when it is sent.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const t = await accessToken();
    const res = await fetch(url, { headers: { Accept: 'application/json', ...(t && { Authorization: `Bearer ${t}` }) } });
    if (res.ok) return res.json();
    if (res.status === 401 && attempt === 0) { token = null; continue; }
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Feefo returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// One review (service or product) and its thread, as one conversation.
async function sendReview(el, text, platformJson, historical) {
  const id = `feefo-${el.id}`;
  const base = { userId: id, sessionId: id, conversationId: id, platformJson };
  let last = at(el.created_at, null);
  if (last == null) return;
  if (text) {
    await sendToDimensionLabs({ ...base, text, incoming: true, dimensionlabs_timestamp: last }, { historical: historical || last < Date.now() - HOUR });
  }
  for (const t of el.thread ?? []) {
    if (!['customer_comment', 'merchant_comment'].includes(t.type) || !t.comment?.trim()) continue;
    last = Math.max(at(t.created_at, last), last + 1000);
    await sendToDimensionLabs({ ...base, text: t.comment.trim(), incoming: t.type === 'customer_comment', dimensionlabs_timestamp: last },
      { historical: historical || last < Date.now() - HOUR });
  }
}

// Sends every review updated at or after `since` (Unix ms). Returns the new `since`.
export async function syncFeefo({ since, historical = false }) {
  const started = Date.now();
  const age = started - since;
  const period = age < 25 * DAY ? 'month' : age < 360 * DAY ? 'year' : 'all';
  for (let page = 1, pages = 1; page <= pages; page++) {
    const q = new URLSearchParams({ merchant_identifier: process.env.FEEFO_MERCHANT_IDENTIFIER, since_updated_period: period,
      full_thread: 'include', page_size: '100', page: String(page) });
    const body = await get(`https://api.feefo.com/api/20/reviews/all?${q}`);
    pages = body.summary?.meta?.pages ?? 1;
    for (const r of body.reviews ?? []) {
      if (at(r.last_updated_date, Infinity) < since) continue; // unchanged since the last run
      const sale = { merchant: r.merchant?.identifier, orderRef: r.customer?.order_ref, customerRef: r.customer?.customer_ref, tags: r.tags };
      if (r.service?.id) {
        const text = [r.service.title, r.service.review].filter((x) => x?.trim()).join('\n');
        await sendReview(r.service, text, { ...sale, kind: 'service', rating: r.service.rating?.rating }, historical);
      }
      for (const p of r.products ?? []) {
        if (!p.id) continue;
        await sendReview(p, p.review?.trim() ?? '', { ...sale, kind: 'product', rating: p.rating?.rating,
          sku: p.product?.sku, productTitle: p.product?.title }, historical);
      }
    }
  }
  return started;
}
```

A review that changes (a new comment, a reply, a re-rating) comes back with a newer `last_updated_date` and is sent again under the same `sessionId`. Anything unchanged is a duplicate, and is removed automatically. NPS feedback (`reviews/nps`, which needs an App Key) is not part of this guide.

## 5. Run it

Save the code as `feefo.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 { syncFeefo as sync } from './feefo.mjs';`.

- **History first:** `node run.mjs 2020-01-01` pages through every review (`since_updated_period=all`) and sends those updated since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job reads the last month of updates and sends what changed to `/track`, and new reviews are enriched automatically every day. Anything more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill. Each run reads a month of updates, so for a large account consider running it hourly instead.
- **Re-runs are safe:** duplicates are removed automatically.

If Feefo offers you a different way to get your review content, or you have reviews in a file, [upload it](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill. Feefo's Download Feedback API (a tab-separated file) is deprecated and not recommended for new work.

## Feefo reference

- [Feefo APIs overview](https://help.feefo.com/knowledge-base/feefo-apis-overview)
- [Reviews API](https://help.feefo.com/knowledge-base/reviews-api)
- [Reviews API detail](https://help.feefo.com/knowledge-base/reviews-api-detail)
- [Reviews API response](https://help.feefo.com/knowledge-base/reviews-api-response)
- [Reviews API enum](https://help.feefo.com/knowledge-base/reviews-api-enum)
- [API Versions](https://help.feefo.com/knowledge-base/api-versions)
- [How to connect using an OAuth 2.0 Token](https://help.feefo.com/knowledge-base/how-to-connect-using-an-oauth-2.0-token)
- [Download Feedback API](https://help.feefo.com/knowledge-base/download-feedback-api)

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