Skip to content

TypeScript SDK

Installation

Terminal window
npm install rheo-sdk

Requires Node.js 18 or later.

Setup

import { RheoClient } from 'rheo-sdk'
const rheo = new RheoClient({
apiKey: process.env.RHEO_API_KEY!,
webhookSecret: process.env.RHEO_WEBHOOK_SECRET!, // optional, required for webhook verification
})
OptionTypeDefaultDescription
apiKeystringrequiredAPI key from the Rheo partner dashboard
webhookSecretstringwhsec_... secret for webhook signature verification
baseUrlstringhttps://market.rheo.seOverride for staging or self-hosted
timeoutnumber (ms)30000Per-request timeout
maxRetriesnumber3Max retries on 429 / 5xx
partnerAccountstringReseller member ID — scopes all item calls to that account

Items

Push a single item

const result = await rheo.items.upsert('ERP_PART_12345', {
title: 'Volvo XC90 bromsok fram vänster — 2018',
description: 'Testad och fungerande. Skick B.',
price: 950,
shippingCost: 149,
weightKg: 3.2,
imageUrls: [
'https://cdn.example.com/parts/12345/1.jpg',
'https://cdn.example.com/parts/12345/2.jpg',
],
domain: {
domain: 'auto_parts',
vehicle: {
manufacturer: 'Volvo',
model: 'XC90',
year: 2018,
vehicleType: 'Bil',
},
part: {
name: 'Bromsok fram vänster',
oemNumber: '31400452',
catalogCodes: { recopart: '7118' },
},
conditionGrade: 'B',
},
autoPublishTradera: true,
useAiEnhancement: true,
})
// result.success === true
// result.itemId — Rheo's internal UUID

upsert creates or updates by externalId. The same call works for both new items and updates — Rheo resolves the existing record by your ID.

Get item status

const item = await rheo.items.get('ERP_PART_12345')
// item.rheoStatus — 'draft' | 'active' | 'sold' | 'not_listed' | 'archived'
// item.traderaAdUrl — direct link to the live Tradera auction
// item.price — current price in SEK

Update price

Updates the price and queues a Tradera bid-start update if the item is live.

await rheo.items.updatePrice('ERP_PART_12345', { price: 850 })

Update status

await rheo.items.updateStatus('ERP_PART_12345', { status: 'not_listed' })
// or:
await rheo.items.updateStatus('ERP_PART_12345', { status: 'sold' })

Delete

Removes the item. If a Rheo order is in progress the item is archived instead of deleted.

await rheo.items.delete('ERP_PART_12345')

Batch operations

Push up to 500 items in a single call. Background processing is identical to single upserts.

const parts = await dms.getPartsForVehicle('YV1CZ714581234567')
const result = await rheo.items.batchUpsert({
items: parts.map((part) => ({
externalId: `VIN-YV1CZ714581234567-${part.partNumber}`,
parentExternalId: 'VIN-YV1CZ714581234567',
title: part.name,
description: part.notes,
price: part.askingPrice,
shippingCost: 149,
imageUrls: part.photos.map((p) => p.url),
domain: {
domain: 'auto_parts',
vehicle: { manufacturer: 'Volvo', vehicleType: 'Bil' },
part: { name: part.name, oemNumber: part.oemNumber },
conditionGrade: part.grade,
},
metadata: {
locationBin: part.binLocation,
dismantledBy: part.technicianId,
},
autoPublishTradera: part.grade !== 'C',
})),
})
console.log(`Accepted: ${result.accepted}, rejected: ${result.rejected}`)
result.errors.forEach((e) => console.error(`${e.externalId}: ${e.reason}`))

Querying items

List items with optional filters. Uses cursor pagination.

// All active parts for a vehicle
const page = await rheo.items.list({
parentExternalId: 'VIN-YV1CZ714581234567',
status: 'active',
limit: 100,
})
// Incremental sync — items changed in the last 24 hours
const updated = await rheo.items.list({
updatedSince: new Date(Date.now() - 86_400_000),
})
// Paginate
let cursor: string | undefined
do {
const page = await rheo.items.list({ limit: 100, cursor })
// process page.items ...
cursor = page.nextCursor
} while (cursor)

Container summary

Aggregate view across all children of a container (e.g. a donor vehicle).

const summary = await rheo.items.summary('VIN-YV1CZ714581234567')
// summary.childCount — total part count
// summary.activeCount — parts currently live on Tradera
// summary.soldCount — parts sold
// summary.draftCount — parts not yet listed
// summary.listedValue — sum of active listing prices (SEK)
// summary.soldValue — gross value of sold parts (SEK)

List children

The parts under a container.

const { children, count } = await rheo.items.children('VIN-YV1CZ714581234567')
children.forEach((c) => console.log(`${c.externalId}${c.status}${c.price} SEK`))

Item history

Lifecycle milestones for an item, newest first.

const { events } = await rheo.items.history('ERP_PART_12345')
events.forEach((e) => console.log(`${e.createdAt}${e.eventType}`, e.data))

Vehicle hierarchy

Model donor vehicles as container items. Parts point to their vehicle via parentExternalId.

// 1. Register the donor vehicle
await rheo.items.upsert('VIN-YV1CZ714581234567', {
type: 'container',
title: 'Volvo XC90 II 2018 — VIN YV1CZ714581234567',
metadata: {
vin: 'YV1CZ714581234567',
make: 'Volvo',
model: 'XC90 II',
year: 2018,
receivedAt: new Date().toISOString(),
},
})
// 2. Push all parts in one batch
await rheo.items.batchUpsert({
items: parts.map((p) => ({
externalId: `VIN-YV1CZ714581234567-${p.partNumber}`,
parentExternalId: 'VIN-YV1CZ714581234567',
type: 'item',
// ... rest of fields
})),
})

Container items are not listed for sale — they aggregate metadata and revenue from their children.


Metadata

Attach arbitrary key-value pairs to any item. Rheo stores and returns them verbatim — they are never processed. Use them for internal data (bin locations, technician IDs, cost prices) that you want available in webhook payloads and query responses.

await rheo.items.upsert('ERP_PART_12345', {
// ...
metadata: {
locationBin: 'A-12-3',
dismantledBy: 'erik',
dismantledAt: '2026-05-27T09:15:00Z',
internalCost: 200,
},
})

Limits: 50 keys per item, 1 KB per value.


Reseller routing

If you manage several Rheo accounts under one reseller API key (e.g. a chain of dismantling yards), pass partnerAccount as a per-call option to route a single request to a specific member. The value is the reseller’s external_id for that member — the reference you set in the business Managed accounts UI. It overrides any client-level partnerAccount:

// One client + key, many yards
await rheo.items.upsert('10-PART-001', data, { partnerAccount: '10' })
await rheo.items.updatePrice('10-PART-001', { price: 800 }, { partnerAccount: '10' })
const yard = await rheo.items.list({ status: 'active' }, { partnerAccount: '10' })

Every item method takes the optional { partnerAccount } argument. Set partnerAccount on RheoClient instead to scope every call to a single account.

See the Reseller model for how membership and billing work.


Webhooks

Express middleware

import express from 'express'
import { RheoClient } from 'rheo-sdk'
const rheo = new RheoClient({
apiKey: process.env.RHEO_API_KEY!,
webhookSecret: process.env.RHEO_WEBHOOK_SECRET!,
})
const app = express()
// Mount before any body-parser — the middleware buffers the raw body itself
app.post('/webhooks/rheo', rheo.webhooks.middleware(), (req, res) => {
const event = rheo.webhooks.fromRequest(req)
switch (event.eventType) {
case 'item.sold': {
const { externalId, salePrice, account } = event.data
// account?.memberExternalId tells you which yard (reseller endpoints) — update your DMS
break
}
case 'listing.created': {
const { externalId, traderaAdUrl } = event.data
// record Tradera link
break
}
case 'listing.failed': {
const { externalId, error } = event.data
// alert operations team
break
}
}
res.sendStatus(200)
})

fromRequest(req, secret?) accepts an optional secret (or array of secrets) to verify with the receiving endpoint’s secret — see per-endpoint secrets.

Manual verification

If you manage your own body parsing:

app.post(
'/webhooks/rheo',
express.raw({ type: 'application/json' }),
(req, res) => {
const event = rheo.webhooks.verify(
req.body, // Buffer — raw bytes, not parsed JSON
req.headers['x-rheo-signature'] as string,
)
// event is typed as RheoEvent
res.sendStatus(200)
},
)

Always pass the raw request body bytes to verify(). Re-serializing a parsed JSON object will produce a different byte sequence and fail the HMAC check.

Per-endpoint secrets

Each webhook endpoint has its own signing secret. Verify with the secret of the endpoint that received the event. Pass it as the third argument to verify() (it overrides the client-level webhookSecret), or pass an array to accept any of several secrets during rotation:

const event = rheo.webhooks.verify(rawBody, signature, endpointSecret)
// During secret rotation — succeeds if either matches:
const rotating = rheo.webhooks.verify(rawBody, signature, [oldSecret, newSecret])

See Webhooks for managing multiple endpoints, event-type filters, and the account field added to every payload for reseller routing.


Error handling

import { RheoApiError, RheoRateLimitError, RheoWebhookSignatureError } from 'rheo-sdk'
try {
await rheo.items.upsert('PART_001', { title: 'Test', price: 100, shippingCost: 50 })
} catch (err) {
if (err instanceof RheoRateLimitError) {
console.log(`Rate limited. Retry after ${err.retryAfter ?? 'unknown'} seconds`)
} else if (err instanceof RheoApiError) {
console.error(`API error ${err.status}: ${err.message}`)
// err.code — machine-readable error code (when available)
// err.requestId — x-request-id header, useful for support
} else {
throw err
}
}

The SDK retries 429 and 5xx responses automatically (up to maxRetries). RheoRateLimitError is only thrown if retries are exhausted.


TypeScript types

The SDK exports all types. Import them for use in your own type definitions.

import type {
RheoEvent,
ItemSoldEvent,
DomainObject,
DomainObjectAutoParts,
RheoItemStatus,
BatchUpsertItem,
} from 'rheo-sdk'
function handleSale(event: ItemSoldEvent) {
// event.data.externalId, event.data.salePrice, event.data.platform
}
function isAutoPart(
domain: DomainObject,
): domain is DomainObjectAutoParts {
return domain.domain === 'auto_parts'
}
// Narrowing gives full typing on the nested attributes:
function oemNumberOf(domain: DomainObject): string | undefined {
return isAutoPart(domain) ? domain.part?.oemNumber : undefined
}

RheoEvent discriminated union

Every event is { eventId, timestamp, eventType, data }. The data object always carries the shared fields below plus an additive account; per-event fields are merged in.

interface EventAccount { rheoUserId: string; memberExternalId?: string }
interface EventData {
externalId: string
rheoItemId: string
platform: string // 'tradera' | 'rheo' | 'rheo_stripe' | 'rheo_swish'
salePrice: number // 0 for non-sale events
currency: string
account?: EventAccount // memberExternalId present on reseller (members) endpoints
}
type RheoEvent =
| { eventType: 'item.created'; timestamp: string; eventId: string; data: EventData & { type?: 'item' | 'container' } }
| { eventType: 'item.images_ready'; timestamp: string; eventId: string; data: EventData & { imagesProcessed?: number } }
| { eventType: 'listing.created'; timestamp: string; eventId: string; data: EventData & { traderaAdId?: string; traderaAdUrl?: string } }
| { eventType: 'listing.ended'; timestamp: string; eventId: string; data: EventData & { traderaAdId?: string } }
| { eventType: 'listing.failed'; timestamp: string; eventId: string; data: EventData & { error?: string } }
| { eventType: 'item.sold'; timestamp: string; eventId: string; data: EventData & { traderaAdId?: string } }

The TypeScript compiler narrows event.data automatically in switch (event.eventType) blocks.


CommonJS / ESM

The package ships both CommonJS (dist/index.js) and ESM (dist/index.mjs) builds. It works in Node.js CJS projects without any configuration.

// CommonJS
const { RheoClient } = require('rheo-sdk')
// ESM / Bun / Deno
import { RheoClient } from 'rheo-sdk'