@sumhubs/sdk
v0.8.0
Published
Node.js SDK for the SumHubs Open API — typed hub client plus React components
Readme
@sumhubs/sdk
Node.js SDK for the SumHubs Open API. Initialise it with a hub API key, then read and write that hub's content, opportunities, forums, members and questionnaires. Ships optional React components for the common screens.
Full API reference: /developers · Markdown for agents: /llms.txt
Install
npm install @sumhubs/sdkNode 18+ (uses the built-in fetch). React 18+ only if you use the /react entrypoint.
The one rule: the key is a server-side secret
An API key grants read and write over an entire hub, including members and questionnaire responses. It is not scoped to one visitor, and there is no way to lock it to a domain.
Never let it reach the browser. The client throws if it detects a browser environment rather than letting a key leak into a bundle:
createClient must not run in a browser: a SumHubs API key is a server-side secret.Fetch on the server, pass plain data to your components, and submit through a route handler or server action. That is exactly how the React components in this package are designed to be used.
Quick start
import { createClient } from '@sumhubs/sdk';
const sumhubs = createClient({ apiKey: process.env.SUMHUBS_API_KEY! });
const hub = await sumhubs.hub.current(); // resolved from the key, no id needed
const { items } = await sumhubs.content.list({ limit: 20 });Point it at a local backend with baseUrl:
createClient({ apiKey: process.env.SUMHUBS_API_KEY!, baseUrl: 'http://localhost:8080/api/v1' });Options
| Option | Default | Notes |
| --- | --- | --- |
| apiKey | — | Required. Must start with sh_. |
| baseUrl | https://api.sumhubs.com/api/v1 | For local or self-hosted backends. |
| timeoutMs | 30000 | Per-request timeout. |
| headers | {} | Extra headers on every request. |
| fetch | global fetch | Injectable for tests. |
Resources
sumhubs.hub.current() // the hub this key belongs to
sumhubs.hub.stats() // content / opportunity / response counts
sumhubs.content.list({ page, limit, name }) // paginated
sumhubs.content.create({ title, description, article })
sumhubs.content.update(id, { title })
sumhubs.content.delete(id)
sumhubs.opportunities.list()
sumhubs.opportunities.get(id)
sumhubs.opportunities.create({ title })
sumhubs.opportunities.apply(id, { email }) // see below
sumhubs.opportunities.update(id, {})
sumhubs.opportunities.delete(id)
sumhubs.forums.list()
sumhubs.forums.get(id)
sumhubs.forums.create({ name, article })
sumhubs.members.list({ search, role, sort }) // always scoped to the key's hub
sumhubs.members.get(id)
sumhubs.questionnaires.list()
sumhubs.questionnaires.listWithCounts() // adds questionsCount
sumhubs.questionnaires.get(id) // includes questions + options
sumhubs.questionnaires.create({ title, description, language })
sumhubs.questionnaires.addQuestion({ quizId, title, language, questionOrder, type })
sumhubs.questionnaires.listResults() // personal data
sumhubs.questionnaires.listRespondents(quizId) // personal data
sumhubs.questionnaires.listAnswers() // personal dataAnything not wrapped yet is reachable through the raw client:
await sumhubs.http.get('/some/new/endpoint', { query: { page: 1 } });Pagination
List methods return { items, meta }. To walk everything without tracking page numbers:
for await (const member of sumhubs.members.iterate({ limit: 100 })) {
console.log(member.user?.email);
}Available on content, opportunities and members.
Applying to an opportunity
opportunities.apply() takes an email rather than a user id, so applicants need no SumHubs account
first. The backend matches the email against existing users and creates an invited user when it is
unknown, then adds them to the hub and records the application.
const { user, application } = await sumhubs.opportunities.apply(42, {
email: '[email protected]',
firstName: 'Ada',
lastName: 'Lovelace',
phone: '+976 9900 1122',
detail: 'Available from March.',
});
user.isNew; // true if this call created the person, false if it matched an existing userApplying twice with the same email throws SumHubsValidationError (409).
Errors
Every failure is a SumHubsError subclass, so you can branch on cause rather than status codes:
| Class | When |
| --- | --- |
| SumHubsAuthError | 401 — key unknown, deleted, expired, owner lost their hub role, or the endpoint does not accept keys |
| SumHubsScopeError | 403 — key lacks the read or write scope |
| SumHubsNotFoundError | 404 — no such record in this hub (also what another hub's ids return) |
| SumHubsValidationError | 400/409 — missing or rejected field, duplicate application |
| SumHubsError | anything else, including timeouts (status: 0) |
import { SumHubsScopeError } from '@sumhubs/sdk';
try {
await sumhubs.content.create({ title: 'a', description: 'b', article: '<p>c</p>' });
} catch (error) {
if (error instanceof SumHubsScopeError) {
// read-only key
}
}React components
import { QuestionnaireForm, OpportunityBoard, OpportunityApplyForm, ContentList, ContentArticle }
from '@sumhubs/sdk/react';All five are presentational: they take data as props and hand submissions back through a callback.
None of them call the API, which is what keeps the key server-side. They carry 'use client', so
they drop straight into the Next App Router. Styling is inline with no CSS import — override via the
theme prop or restyle with className.
| Component | Props |
| --- | --- |
| QuestionnaireForm | questionnaire, onSubmit(answers) |
| OpportunityBoard | opportunities, hrefFor? / onSelect? |
| OpportunityApplyForm | opportunity, onApply(input) |
| ContentList | items, hrefFor? / onSelect? |
| ContentArticle | content, sanitize |
QuestionnaireForm renders the right control for each of the platform's seven question types
(choose_one, multiple_choice, true_false, scale_1_5, number, short_answer,
long_answer), enforces isRequired, and returns answers keyed by question id.
ContentArticle requires a sanitize function on purpose. content.article is HTML written by hub
editors, so rendering it raw would let a compromised editor account run script on your page:
import DOMPurify from 'dompurify';
<ContentArticle content={content} sanitize={(html) => DOMPurify.sanitize(html)} />Pass (html) => html only if you fully trust every editor in the hub.
Wiring it up (Next App Router)
// app/opportunities/actions.ts
'use server';
import { createClient } from '@sumhubs/sdk';
import type { ApplyInput } from '@sumhubs/sdk';
const sumhubs = createClient({ apiKey: process.env.SUMHUBS_API_KEY! });
export async function apply(opportunityId: number, input: ApplyInput) {
const { user } = await sumhubs.opportunities.apply(opportunityId, input);
return { isNewUser: user.isNew };
}// app/opportunities/[id]/page.tsx
import { createClient } from '@sumhubs/sdk';
import { OpportunityApplyForm } from '@sumhubs/sdk/react';
import { apply } from '../actions';
const sumhubs = createClient({ apiKey: process.env.SUMHUBS_API_KEY! });
export default async function Page({ params }: { params: { id: string } }) {
const opportunity = await sumhubs.opportunities.get(Number(params.id));
return (
<OpportunityApplyForm
opportunity={opportunity}
onApply={async (input) => { await apply(opportunity.id, input); }}
/>
);
}Runnable versions are in examples/.
Scopes
A key carries read, write, or both. Grant the minimum an integration needs — write does not
imply read. Questionnaire results and submitted answers are reachable with only read, so treat
read keys for those integrations as sensitive and prefer an expiry date for one-off exports.
Development
npm install
npm run typecheck
npm run build