@duabalabs/reviews-sdk
Framework-agnostic client for the centralized 3y3anaa reviews platform — the
shared review/rating store for the Duaba ecosystem. Reviews target a subject
namespaced by (sourceApp, subjectType, externalId); reviewers are external
identities (no Parse account needed). Your app authenticates with a per-app key.
Call this server-side. The app key is secret and must never reach a browser bundle.
Install
npm i @duabalabs/reviews-sdk
# or
pnpm add @duabalabs/reviews-sdkShips CJS + ESM with bundled .d.ts types. No runtime dependencies — it uses
the global fetch.
This page describes 0.4.0. Version 0.2.0 has only
submitReview through
getReviewsByAuthor; everything from
searchSubjects on needs 0.4.0
(npm i @duabalabs/reviews-sdk@^0.4.0), or call it
over HTTP with the same key.
createReviewsClient(config)
import { createReviewsClient } from '@duabalabs/reviews-sdk';
const reviews = createReviewsClient({
appKey: process.env.THREEYANAA_APP_KEY!, // rk_<app>_… — sent as Authorization: Bearer
// baseUrl defaults to https://api.3y3anaa.com — override for self-host/staging
});Config
| Field | Type | Required | Notes |
|---|---|---|---|
appKey | string | ✅ | Your per-app key (rk_<app>_…), sent as Authorization: Bearer <appKey>. Secret. It’s the only credential the SDK needs. |
baseUrl | string | — | Base URL of the reviews API. Defaults to https://api.3y3anaa.com (trailing slash trimmed). Don’t include /v1 — the client adds it. |
fetch | typeof fetch | — | Custom fetch impl (Node < 18 / tracing wrappers). Defaults to global fetch. |
If no fetch is available and none is supplied, or appKey is missing, the
constructor throws a ReviewsApiError.
Methods
The client returns an object with the following methods. Each POSTs its input as
JSON to {baseUrl}/v1/reviews/<function> and returns the result.
submitReview(input)
Create or edit a review. One review exists per (app, subject, author) — calling
again with the same subject + author edits the existing one.
const { review, aggregate } = await reviews.submitReview({
subjectType: 'product',
externalId: 'prod_123',
subjectDisplayName: 'Kente Tote Bag', // recorded on the subject
subjectImageUrl: 'https://cdn.example/…',
subjectUrl: 'https://sellub.com/p/prod_123',
author: { externalUserId: 'user_42', displayName: 'Ama', role: 'buyer' },
rating: 5, // 1–5
title: 'Lovely bag',
body: 'Beautiful craftsmanship, fast delivery.',
verified: true, // e.g. verified purchase
});
// → { review: Review, aggregate: ReviewAggregate }SubmitReviewInput: subjectType, externalId, author, rating (required);
title?, body?, verified?, subjectDisplayName?, subjectImageUrl?, subjectUrl?, subjectMeta? (optional). Maps to submitAppReview.
Server-side, a review whose computed qualityScore is < 35 is set to
under_review rather than published, and doesn’t count in the aggregate until a
moderator approves it. Inspect review.status after submitting.
getReviews(input)
Paginated published reviews for a subject, scoped to your app, with the aggregate.
const { items, total, hasMore, aggregate } = await reviews.getReviews({
subjectType: 'product',
externalId: 'prod_123',
limit: 20, // ≤ 100, default 20
skip: 0,
sort: 'newest', // 'newest' | 'helpful' | 'highest' | 'lowest'
});Maps to getAppReviews.
getAggregate(input)
The aggregate for a single subject.
const agg = await reviews.getAggregate({ subjectType: 'product', externalId: 'prod_123' });
// → { subjectType, externalId, avgRating, reviewCount, ratingBreakdown }Maps to getAppReviewAggregate.
getAggregates(subjects)
Batch aggregates for product grids / cards (≤ 200 per call).
const aggs = await reviews.getAggregates([
{ subjectType: 'product', externalId: 'prod_123' },
{ subjectType: 'product', externalId: 'prod_456' },
]);
// → Array<{ subjectType, externalId, avgRating, reviewCount, ratingBreakdown }>Maps to getAppReviewAggregates (the SDK unwraps { aggregates }).
markHelpful(input)
One helpful vote per external voter, on a review your app submitted.
const { helpfulCount, alreadyVoted } = await reviews.markHelpful({
reviewId: 'abc123',
voterExternalUserId: 'user_99',
});Maps to markAppReviewHelpful.
respondToReview(input)
The subject owner’s public response to a review your app submitted.
const { review } = await reviews.respondToReview({
reviewId: 'abc123',
responderExternalUserId: 'seller_7',
responderDisplayName: 'Accra Crafts', // optional
text: 'Thanks! We’ve refunded the delivery fee.',
});Maps to respondToAppReview.
getReviewsByAuthor(input)
An author’s own reviews (“my reviews”), scoped to your app, newest first.
const { items, total, hasMore } = await reviews.getReviewsByAuthor({
authorExternalUserId: 'user_42',
limit: 20,
skip: 0,
});Maps to getAppReviewsByAuthor.
searchSubjects(input)
Find a real 3y3anaa business or artisan to review. The returned
(subjectType, externalId) pair is what you pass to submitReview, so your reviews
join that entity’s shared reputation instead of forming an island only your app can
see. Reviewing your own catalogue (products, shops) needs none of this — your ids
are namespaced by app and can’t collide.
const { items, total, hasMore } = await reviews.searchSubjects({
query: 'Accra Tiles', // at least 2 characters
subjectType: 'business', // or 'artisan' — optional
city: 'Accra', // optional; region too
limit: 10, // ≤ 50, default 10
});
// items: ReviewSubjectMatch[] — subjectType, externalId, displayName, city,
// trustScore, isClaimed, isVerified, profileUrl, …Maps to searchReviewSubjects.
resolveSubject(input)
Resolve one entity from identifiers you already hold. Tries the strongest signal
first: googlePlaceId, then phone (any Ghanaian spelling — 0244…, +233244…,
233244…), then the exact name (with city to disambiguate).
const { subject, matchedOn, confidence } = await reviews.resolveSubject({
phone: '0244123456',
});
// matchedOn: 'googlePlaceId' | 'phone' | 'name' | 'name+city' | null
// confidence: 'high' (place id, phone) | 'medium' (name) | null
// subject is null (with a `hint`) when nothing matchedMaps to resolveReviewSubject.
getNetworkReviews(input)
What the whole network says about a subject — every integrated app’s published
reviews pooled, not just yours. Takes the same input as getReviews.
const net = await reviews.getNetworkReviews({ subjectType: 'business', externalId });
net.aggregate; // always complete, on every plan
net.contributors; // [{ sourceApp, reviewCount, verified }]
net.itemsTruncated; // true on the free tier — `items` is a sample of 3
net.usage; // { period, calls, quota, remaining }Free keys get the complete aggregate plus a sample of up to 3 reviews (always the
first page); a paid plan gets the full list. Each call counts against your monthly
quota. Maps to getNetworkReviews.
getTrustScore(input)
The 3y3anaa Trust Score and its breakdown for a 3y3anaa entity (resolve it first). Paid, and metered.
const { trustScore, breakdown, calculatedAt } = await reviews.getTrustScore({ externalId });Maps to getSubjectTrustScore.
getEntitlement()
What this key can do and how much quota is left. Free and unmetered.
const { plan, capabilities, usage } = await reviews.getEntitlement();Maps to getMyAppEntitlement.
Webhooks
| Method | Maps to |
|---|---|
registerWebhook({ url, events? }) → endpoint + secret (shown once) | registerAppWebhook |
listWebhooks() | listAppWebhooks |
deleteWebhook({ webhookId }) | deleteAppWebhook |
enableWebhook({ webhookId }) — re-enable an endpoint disabled after repeated failures | enableAppWebhook |
testWebhook({ webhookId }) — send a test delivery | testAppWebhook |
See Webhooks below for events and signature checks.
Free and paid
Your own data is free. The network is paid.
- Free, unlimited, unmetered:
submitReview(and edits),respondToReview,markHelpful,getReviews,getAggregate,getAggregates,getReviewsByAuthor,searchSubjects,resolveSubject,getEntitlement, and webhooks forreview.*events. - Free up to a monthly quota (1,000 calls):
getNetworkReviews— the complete cross-app aggregate plus a sample of reviews. - Paid: the full cross-app review text,
getTrustScore, volume beyond the free quota, andnetwork.*webhooks.
Plans are bought from the 3y3anaa portal (Settings ▸ API keys). Running out of quota returns HTTP 429; a feature your plan doesn’t include returns 402. Neither ever affects contributing.
Verification is separate, and free. It decides whether your reviews carry weight in a business’s Trust Score: an unverified app’s reviews display but count for zero, so an anonymous key can’t move a real business’s score. Apply from Settings ▸ API keys; approval counts everything you’ve already sent.
Webhooks
const hook = await reviews.registerWebhook({
url: 'https://myshop.com/hooks/3y3anaa', // https, public address
events: ['review.moderated', 'review.responded'], // omit for every event
});
// hook.secret is shown ONCE — store it.| Event | Tier |
|---|---|
review.created, review.updated | Free |
review.responded | Free |
review.moderated — our moderation decision on your review | Free |
network.review.created — another app reviewed a subject you’ve reviewed | Paid |
Each delivery is a POST with the JSON body { event, sentAt, data } and the headers
X-3y3anaa-Event, X-3y3anaa-Timestamp and X-3y3anaa-Signature (HMAC-SHA256 of
<timestamp>.<raw body> with your secret). Verify it with verifyWebhookSignature,
passing the raw body:
import { verifyWebhookSignature } from '@duabalabs/reviews-sdk';
app.post('/hooks/3y3anaa', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhookSignature({
secret: process.env.THREEYANAA_WEBHOOK_SECRET!,
body: req.body,
signature: req.header('X-3y3anaa-Signature')!,
timestamp: req.header('X-3y3anaa-Timestamp')!,
});
if (!ok) return res.sendStatus(401);
res.sendStatus(200); // ack fast; do the work after
});It rejects deliveries older than 5 minutes (toleranceMs to change) and needs Node’s
crypto. An app can have up to 5 active endpoints. A failed delivery is retried
with backoff (~1m, 5m, 25m, 2h); ten consecutive failures disable the endpoint until
you call enableWebhook.
Types
type SubjectType =
| 'artisan' | 'business' | 'client'
| 'product' | 'shop' | 'supplier' | 'courier'
| 'property'
| (string & {}); // open union — any string accepted, these are canonical
type ReviewerRole =
| 'artisan' | 'business' | 'client'
| 'buyer' | 'seller' | 'courier'
| (string & {});
interface ReviewAuthor {
externalUserId: string;
displayName?: string;
role?: ReviewerRole;
}
interface RatingBreakdown { 1: number; 2: number; 3: number; 4: number; 5: number }
interface ReviewAggregate {
avgRating: number;
reviewCount: number;
ratingBreakdown: RatingBreakdown;
}
interface Review {
id: string;
subjectType: SubjectType;
externalId: string;
rating: number;
title: string | null;
body: string | null;
verified: boolean;
status: 'published' | 'under_review' | 'rejected';
author: { externalUserId: string; displayName: string | null; role: ReviewerRole | null };
helpfulCount: number;
response: { text: string; byDisplayName: string; at: string } | null;
createdAt: string;
}The SDK also exports ReviewSubjectMatch, ResolveSubjectResult,
NetworkReviewsResult, TrustScoreResult, EntitlementResult, UsageSnapshot,
WebhookEvent and WebhookEndpoint for the newer methods.
Error handling
Methods throw a ReviewsApiError on network failure or when the API returns an
error. The error carries a numeric code — the HTTP status.
import { ReviewsApiError } from '@duabalabs/reviews-sdk';
try {
await reviews.submitReview({ /* … */ });
} catch (e) {
if (e instanceof ReviewsApiError) {
console.error('reviews error', e.code, e.message);
}
// degrade gracefully — don't block your checkout/page on a reviews failure
}| Status | When |
|---|---|
400 | Validation — rating not 1–5; missing subjectType, externalId or author.externalUserId; a search query under 2 characters; a webhook URL that isn’t public https. |
401 | No key sent. |
402 | The call needs a paid plan. |
403 | Invalid or revoked key; a subjectType outside your key’s scopes; the webhook endpoint limit. |
404 | Unknown function; a review or webhook that isn’t yours; no 3y3anaa entity with that id. |
429 | Rate limit exceeded (120 requests/minute per app by default), or the monthly network quota is used up. |
Calling the API over HTTP
Every method is one HTTP call, so any language — or an app still on an older SDK — can use the API directly:
curl -X POST https://api.3y3anaa.com/v1/reviews/resolveReviewSubject \
-H "Authorization: Bearer $THREEYANAA_APP_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"0244123456"}'The path is /v1/reviews/<function> (the “Maps to” name under each method), the body
is the method’s input, and the response is its result. Errors come back as
{ "error": "…" } with the status codes above.
Environment
| Env var | Maps to config / header | Secret? |
|---|---|---|
THREEYANAA_APP_KEY | appKey → Authorization: Bearer | yes |
THREEYANAA_API_URL | baseUrl (optional) | no |
Worked example — Sellub
Sellub integrates the reviews platform server-side from its storefront. The
pattern (mirrored in sellub-storefront/src/lib/reviews/client.ts, which calls the
same endpoints directly):
Product review — a signed-in buyer writes it; Sellub marks it verified when
the buyer has a settled order containing the product:
// server action / route handler — never client-side
await reviews.submitReview({
subjectType: 'product',
externalId: product.id, // Sellub's own product id
subjectDisplayName: product.name,
subjectUrl: `/product/${product.slug}`,
author: {
externalUserId: customer.id, // Sellub's user id
displayName: customerName,
role: 'buyer',
},
rating,
title,
body,
verified: hasSettledOrderForProduct, // gated on a real purchase
});Shop review — the same call with subjectType: 'shop' and the shop’s id as
externalId.
Read on the product page (server component, fail-soft):
const { items, aggregate } = await reviews.getReviews({
subjectType: 'product',
externalId: product.id,
sort: 'helpful',
});
// render aggregate.avgRating / aggregate.reviewCount + itemsProduct grid — one batched call for all visible cards:
const aggs = await reviews.getAggregates(
products.map((p) => ({ subjectType: 'product', externalId: p.id })),
);Sellub’s credential is provisioned with
scopes: ['product','shop','supplier','courier'], so submitting a courier or
supplier review later needs no new key. Reviews written against a 3y3anaa
business’s own id (from resolveSubject) also appear on that business’s public
3y3anaa profile, alongside every other app’s.
Related
- SDK overview — package summary and who uses it.