Skip to content

02 Get your data in /Integrations

Feefo

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

  1. F FeefoTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

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.

  • 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.

Feefo’s steps are in 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.
Terminal window
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.
  • 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.

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 LabsFeefoNotes
textservice.title and service.review, products[].review, thread[].commentThread entries that only change a rating or title (customer_rerate, customer_retitle) have no text and are skipped.
userIdFeefo returns no internal customer id, so use the sessionId. Don’t send customer.name, customer.email or customer.mobile.
incomingthread[].typetrue for the review and customer_comment, false for merchant_comment.
sessionIdservice.id, products[].idPrefixed feefo-.
conversationIdservice.id, products[].id
dimensionlabs_timestampcreated_at, thread[].created_atUTC. Convert with toMillis. Each message is kept at least a second after the one before it.
platformJsonrating.rating, product.sku, product.title, customer.order_ref, customer.customer_ref, tags, merchant.identifierorder_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.

// 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).
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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.

Save the code as feefo.mjs in a project set up as in Set up your project, and run it with the 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 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 and check it first with the file checker, then run a backfill. Feefo’s Download Feedback API (a tab-separated file) is deprecated and not recommended for new work.

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