Skip to Content
3y3anaaSDKsreviews-sdk

@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-sdk

Ships 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

FieldTypeRequiredNotes
appKeystring✅Your per-app key (rk_<app>_…), sent as Authorization: Bearer <appKey>. Secret. It’s the only credential the SDK needs.
baseUrlstring—Base URL of the reviews API. Defaults to https://api.3y3anaa.com (trailing slash trimmed). Don’t include /v1 — the client adds it.
fetchtypeof 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 matched

Maps 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

MethodMaps to
registerWebhook({ url, events? }) → endpoint + secret (shown once)registerAppWebhook
listWebhooks()listAppWebhooks
deleteWebhook({ webhookId })deleteAppWebhook
enableWebhook({ webhookId }) — re-enable an endpoint disabled after repeated failuresenableAppWebhook
testWebhook({ webhookId }) — send a test deliverytestAppWebhook

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 for review.* 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, and network.* 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.
EventTier
review.created, review.updatedFree
review.respondedFree
review.moderated — our moderation decision on your reviewFree
network.review.created — another app reviewed a subject you’ve reviewedPaid

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 }
StatusWhen
400Validation — rating not 1–5; missing subjectType, externalId or author.externalUserId; a search query under 2 characters; a webhook URL that isn’t public https.
401No key sent.
402The call needs a paid plan.
403Invalid or revoked key; a subjectType outside your key’s scopes; the webhook endpoint limit.
404Unknown function; a review or webhook that isn’t yours; no 3y3anaa entity with that id.
429Rate 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 varMaps to config / headerSecret?
THREEYANAA_APP_KEYappKey → Authorization: Beareryes
THREEYANAA_API_URLbaseUrl (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 + items

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

Last updated on