# Yotpo

> Send Yotpo product and site reviews to Dimension Labs with the universal API.

This brings in the reviews in your Yotpo store, product reviews and site (store) reviews: each review as a customer message, with its score, product and Yotpo's sentiment score. One conversation per review. You run it on your side: a small script pulls from the Yotpo API and posts each message to the [universal API](/api/tracker).

## What you need

- Your Yotpo **store ID** (formerly app key) and **API secret**. Yotpo says only the account administrator can see the API secret; teammates with Staff permissions can't.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

Yotpo has no sandbox: every call reaches a real store. The calls in this guide only read data.

## 1. Get API access in Yotpo

Yotpo's steps are in [Store ID and API Secret](https://apidocs.yotpo.com/docs/finding-your-app-key-and-api-secret) and [Authenticate and make your first call](https://apidocs.yotpo.com/docs/authenticate-and-make-your-first-call).

1. Have the account administrator copy the store ID and API secret. Yotpo's [help article](https://support.yotpo.com/v1/docs/finding-your-yotpo-app-key-and-secret-key) shows where they are. Keep the secret in a secrets manager.
2. Get an access token from your server:

```bash
curl -X POST https://api.yotpo.com/oauth/token -H "Content-Type: application/json" \
  -d '{"client_id": "YOUR_STORE_ID", "client_secret": "YOUR_API_SECRET", "grant_type": "client_credentials"}'
```

3. Send the token in the `X-Yotpo-Token` header. Yotpo asks you to store the token and reuse it, not request a new one for every call. If a call returns `401`, get a new token and retry once. The code below does this.

## 2. Pull new reviews

```bash
curl -H "X-Yotpo-Token: $YOTPO_TOKEN" -H "Accept: application/json" \
  "https://api.yotpo.com/v1/apps/YOUR_STORE_ID/reviews?since_updated_at=2026-10-01&count=100&page=1"
```

- "Retrieve all reviews" returns product and site reviews together. Site reviews have `sku` set to `yotpo_site_reviews`.
- **Incremental:** `since_updated_at` returns reviews updated on or after a date, so an edited review comes back too. `since_date` filters by creation date instead.
- **Pagination:** `page` and `count`. Yotpo recommends up to 100 reviews per request; the default is 10.
- **Delay:** Yotpo says data on this endpoint comes with a delay of about an hour.
- Deleted reviews are left out unless you pass `deleted=true`.
- Review text is HTML-encoded (for example `&#x27;` for an apostrophe). The code decodes it.
- This endpoint doesn't return store replies to reviews, so only the review is sent.
- **Rate limits (Yotpo's):** 5,000 requests a minute per store ID for this endpoint. Over the limit, Yotpo returns `429`, with a `ratelimit-reset` header in seconds.

## 3. Map the fields

Each review is one incoming message under its own `sessionId`.

| Dimension Labs | Yotpo | Notes |
|---|---|---|
| `text` | `title` and `content` | Join the title and body with a line break. HTML entities are decoded. |
| `userId` | | Yotpo returns no internal reviewer id, so use the `sessionId`. Don't send `name` or `email`. |
| `incoming` | | Always `true`. |
| `sessionId` | `id` | Prefixed `yotpo-`. |
| `conversationId` | `id` | |
| `dimensionlabs_timestamp` | `created_at` | ISO 8601. Convert with `toMillis`. |
| `platformJson` | `score`, `sku`, `sentiment`, `votes_up`, `votes_down`, `reviewer_type`, `is_incentivized`, `incentive_type`, `user_reference` | `sku` is your product ID, the field to join to your product data. `user_reference` is the external user reference sent when the review was created, if your store sends one. |

**Personal data.** Yotpo returns the reviewer's name and email address on every review. Don't send them: the code uses the review id instead. Reviews can still name people in their text: see [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// yotpo.mjs. Environment: YOTPO_STORE_ID, YOTPO_API_SECRET, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const LAG = 3 * HOUR; // Yotpo's data arrives about an hour late; look back further than that
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const API = 'https://api.yotpo.com';
let token = null; // reused while the process lives, as Yotpo asks

async function accessToken() {
  if (token) return token;
  const res = await fetch(`${API}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ client_id: process.env.YOTPO_STORE_ID, client_secret: process.env.YOTPO_API_SECRET, grant_type: 'client_credentials' }) });
  if (!res.ok) throw new Error(`Yotpo token request returned ${res.status}: ${await res.text()}`);
  token = (await res.json()).access_token;
  return token;
}

// Retries Yotpo's rate limits (429) and server errors (5xx), honoring Retry-After or ratelimit-reset.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { 'X-Yotpo-Token': await accessToken(), Accept: 'application/json' } });
    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(`Yotpo returned ${res.status}: ${await res.text()}`);
    const wait = Number(res.headers.get('retry-after')) || Number(res.headers.get('ratelimit-reset')) || 2 ** attempt;
    await sleep(wait * 1000);
  }
}

const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
const decode = (s) => String(s ?? '').replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) =>
  /^#x/i.test(e) ? String.fromCodePoint(parseInt(e.slice(2), 16))
    : e[0] === '#' ? String.fromCodePoint(parseInt(e.slice(1), 10)) : NAMED[e.toLowerCase()] ?? m);

// Sends every review created or updated at or after `since` (Unix ms). Returns the new `since`.
export async function syncYotpo({ since, historical = false }) {
  const started = Date.now();
  const from = new Date(since - DAY).toISOString().slice(0, 10); // since_updated_at takes a date
  for (let page = 1; ; page++) {
    const q = new URLSearchParams({ since_updated_at: from, count: '100', page: String(page) });
    const { reviews = [] } = await get(`${API}/v1/apps/${process.env.YOTPO_STORE_ID}/reviews?${q}`);
    for (const r of reviews) {
      if (toMillis(r.updated_at) < since - LAG) continue; // already sent by an earlier run
      const text = [decode(r.title).trim(), decode(r.content).trim()].filter(Boolean).join('\n');
      if (!text) continue;
      const id = `yotpo-${r.id}`;
      const created = toMillis(r.created_at);
      await sendToDimensionLabs({ userId: id, sessionId: id, conversationId: id, text, incoming: true, dimensionlabs_timestamp: created,
        platformJson: { score: r.score, sku: r.sku, siteReview: r.sku === 'yotpo_site_reviews', sentiment: r.sentiment,
          votesUp: r.votes_up, votesDown: r.votes_down, reviewerType: r.reviewer_type, incentivized: r.is_incentivized,
          incentiveType: r.incentive_type, userReference: r.user_reference } },
        { historical: historical || created < Date.now() - HOUR });
    }
    if (reviews.length < 100) break;
  }
  return started;
}
```

A review that is edited comes back with a newer `updated_at` and is sent again under the same `sessionId`. Anything unchanged is a duplicate, and is removed automatically. The code looks back three hours past the cursor to allow for Yotpo's delay.

## 5. Run it

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

- **History first:** `node run.mjs 2020-01-01` sends every review updated since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new reviews to `/track`, and they are enriched automatically every day. Because of Yotpo's delay, some new reviews are already more than an hour old when the job sees them: those go to `/trackhistorical`, and need a backfill. Run one over recent dates each week.
- **Re-runs are safe:** duplicates are removed automatically.
- **Rate limits:** 5,000 requests a minute is far more than this job uses. The code waits and retries on `429`.

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.

## Yotpo reference

- [Store ID and API Secret](https://apidocs.yotpo.com/docs/finding-your-app-key-and-api-secret)
- [Authenticate and make your first call](https://apidocs.yotpo.com/docs/authenticate-and-make-your-first-call)
- [Retrieve all reviews](https://apidocs.yotpo.com/reference/retrieve-all-reviews)
- [Guidelines and conventions](https://apidocs.yotpo.com/docs/guidelines-and-conventions)
- [Testing your integration](https://apidocs.yotpo.com/docs/testing-your-integration)

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