supdesk 0.2.0
supdesk: ^0.2.0 copied to clipboard
Server-side Dart client for the SupDesk API. Submissions, feedback, changelog, help center, messages, waitlist, beta programs and signed webhooks.
supdesk #
Dart client for the SupDesk API.
Built on dio, so a CancelToken, an interceptor
or a proxy-aware adapter all work the way you already expect.
Warning
Server-side only. Never ship your API key to an end user.
A SupDesk API key authenticates as your entire project. Anything that reaches
a phone or a browser is readable — a compiled Flutter binary can be unpacked,
and DevTools shows every request a web build makes. Use this SDK from a
backend you control (a dart_frog route, a shelf handler, a cloud function, a
cron job) and let your app talk to that.
The constructor throws in Flutter and web builds. See Security.
dependencies:
supdesk: ^0.2.0
Quick start #
import 'dart:io';
import 'package:supdesk/supdesk.dart';
final supdesk = SupDesk(apiKey: Platform.environment['SUPDESK_API_KEY']!);
// Auto-pages: the stream walks every page for you.
final page = await supdesk.submissions.list(status: PostStatus.open);
await for (final submission in page.autoPaging()) {
print(submission.title);
}
await supdesk.submissions.create(
type: SubmissionType.bug,
title: 'Export button does nothing',
email: 'user@example.com',
body: 'Clicking Export on the reports page has no effect.',
);
API keys come from Workspace Settings → API Keys in the SupDesk console and
are scoped to a single project. Reads and writes both work on every plan,
Free included. What is metered is creation: submissions.create and
feedback.create count against your monthly submission quota — 250 on Free —
and raise a LimitReachedException at the cap.
Security #
The API key is a server-side secret. It is project-scoped, and it can create, edit and delete submissions, feedback, changelog entries, help center articles, message threads, waitlist signups and beta programs — on every plan — and read every end-user email address in your project, private posts included. SupDesk has no browser-safe publishable key.
So the client refuses to start anywhere an end user could read it:
// In a Flutter widget, or anything compiled for the web:
SupDesk(apiKey: 'sd_live_…');
// → SupDeskConfigurationException: SupDesk is a server-side SDK and was
// constructed in a Flutter or web build. …
Detection is a compile-time constant (dart.library.ui for Flutter,
dart.library.js_interop for web), so it costs nothing at runtime and cannot be
tricked by a release build. If you hit this error, the key is already in your
app bundle — rotate it in Workspace Settings → API Keys, then move the call
behind your own endpoint:
// A dart_frog route — runs on your server.
Future<Response> onRequest(RequestContext context) async {
final body = await context.request.json() as Map<String, dynamic>;
// Validate and rate-limit here: this endpoint is public, your key is not.
await supdesk.feedback.create(
title: body['title'] as String,
email: body['email'] as String,
);
return Response.json(body: {'ok': true});
}
dangerouslyAllowClientSide: true bypasses the check. It exists for cases where
the key genuinely is not a secret — an internal desktop tool behind SSO, or a
test harness pointed at a stub baseUrl — and is named to make its use
conspicuous in review.
Two related habits worth keeping: give each environment its own key so one can be revoked without downtime elsewhere, and store the webhook signing secret server-side too, since it is what proves a delivery actually came from SupDesk.
Client options #
final supdesk = SupDesk(
apiKey: 'sd_live_…',
baseUrl: 'https://api.supdesk.app/v1', // default
dio: myDio, // default: one the client owns
timeout: const Duration(seconds: 30), // Duration.zero disables
maxRetries: 2, // retries after the first attempt
retryUnsafeMethods: false,
defaultHeaders: {'x-app': 'my-service'},
dangerouslyAllowClientSide: false, // default; see Security
);
Every method also takes options: CallOptions(timeout: …, headers: …, cancelToken: …) as a final named argument.
Call supdesk.close() when you are done. It disposes the Dio the client
created, and leaves an injected one alone — that instance is yours.
Resources #
| Accessor | Methods |
|---|---|
submissions |
list get create |
feedback |
list get create |
changelog |
list get create update delete |
messages |
list get create update delete addMessage |
waitlist |
list get create update delete |
beta.programs |
list get create update delete |
beta.testers |
list get create delete |
articles |
list search get create update delete |
articleCategories |
list get create update delete |
Pagination #
list() returns a Page, which is both the current page and a stream over
everything after it.
final page = await supdesk.articles.list(status: ArticleStatus.published);
page.data; // just this page
page.pagination; // limit, offset, hasMore
page.hasNextPage();
await page.getNextPage();
await for (final article in page.autoPaging()) { /* every page */ }
await page.toList(); // everything, in memory
articles.search() is the exception — it returns a plain ranked list, not a
page.
Status values #
Statuses, types and locales are Dart 3 extension types over String, not
enums. The documented values autocomplete, and a value SupDesk adds later still
parses instead of crashing a client that predates it.
PostStatus.inProgress.value; // 'in_progress'
submission.status == PostStatus.open;
const PostStatus('under_review'); // whatever the server sends
Spam moderation #
Every submission and feedback post created through the API runs the same spam assessment as one filed from the portal, and each post reports the verdict:
final submission = await supdesk.submissions.create(
type: SubmissionType.bug,
title: 'Export button does nothing',
email: 'user@example.com',
);
if (submission.moderationStatus != ModerationStatus.published) {
// Held as `pending` or `spam`: no notifications go out, and it waits in
// Spam & moderation in the console for a team member.
}
Posts also carry isPrivate, which reports whether they are hidden from your
public portal. An API key reads private posts like any other — the portal is the
only surface that filters them out.
Errors #
Every failure is a SupDeskException, so one catch covers the lot while
on-clauses still narrow to the specific case. The base class is sealed, so a
switch over it is exhaustive.
try {
await supdesk.submissions.create(
type: SubmissionType.bug,
title: 'Export button does nothing',
email: 'user@example.com',
);
} on LimitReachedException {
// Monthly submission quota exhausted — creates are metered.
} on RateLimitedException {
// 120 requests per 60 seconds, and the retries have already been spent.
} on SupDeskException catch (error) {
print(error.message);
}
| Class | Status | Code |
|---|---|---|
InvalidRequestException |
400 | invalid_request |
UnauthorizedException |
401 | unauthorized |
NotFoundException |
404 | not_found |
RateLimitedException |
429 | rate_limited |
LimitReachedException |
429 | limit_reached |
InternalServerException |
5xx | internal_error |
Plus SupDeskConnectionException, SupDeskTimeoutException,
RequestTooLargeException (the API caps requests at 1 MB, checked before
sending), SupDeskConfigurationException and
SupDeskSignatureVerificationException.
Cancellation is the one thing that is not wrapped: cancelling a CancelToken
raises dio's own DioException with DioExceptionType.cancel, because that
result is the caller's to observe, not a SupDesk failure.
Retries #
The client retries with exponential backoff and jitter, honouring Retry-After
when a proxy supplies one. Two behaviours are worth knowing about:
limit_reachedis never retried. It shares HTTP 429 withrate_limited, but a monthly quota will not clear inside a backoff window — retrying just burns more of your 120-requests-per-minute budget. The two are told apart bycode, not status.POSTis not replayed on connection errors or 5xx by default. SupDesk has no idempotency key, andsubmissions.create/feedback.createare metered, so a request that failed after the server accepted it would double-charge your quota and file the end user's ticket twice. A 429rate_limitedis still retried on any method, because the server states it did not process the request. Opt in withretryUnsafeMethods: true.
Webhooks #
import 'package:supdesk/supdesk.dart';
final webhooks = Webhooks(webhookSigningSecret);
Future<Response> onRequest(RequestContext context) async {
final event = webhooks.constructEventFromHeaders(
await context.request.body(), // the raw body, not a decoded map
context.request.headers,
);
switch (event.type.value) {
case 'waitlist_signup.joined':
print(event.asWaitlistSignup().email);
case 'post.status_changed':
print(event.data['status']);
}
return Response(statusCode: 204);
}
Pass the raw body. The signature covers the exact bytes SupDesk sent, so a framework that decodes JSON for you breaks verification —
jsonEncodewill not reproduce the original whitespace and key order. Capture the raw body first.
Lower-level helpers: verifyWebhookSignature(...) returns a bool,
constructEvent(...) throws on mismatch, and computeWebhookSignature(...)
builds fixtures. Comparison is constant-time.
Examples #
example/ is a runnable package of its own:
bin/shelf_server.dart |
shelf server: feedback intake, webhook receiver, one error middleware |
routes/ |
the same in dart_frog, with the client behind a provider |
bin/webhook_receiver.dart |
signature verification on bare dart:io, plus a signed curl to test it |
bin/pagination.dart |
every way of walking a list endpoint |
bin/resilience.dart |
custom Dio, interceptors, CancelToken, timeouts, the exception hierarchy |
bin/help_center.dart |
category → draft → publish → search → delete |
bin/support_inbox.dart |
threads, replies, closing a conversation |
bin/waitlist_and_beta.dart |
inviting from the queue, then enrolling the same people as testers |
cd example && dart pub get && dart run bin/pagination.dart
Contributing #
dart pub get
dart test
dart run coverage:test_with_coverage && dart run tool/check_coverage.dart 80
dart analyze --fatal-infos
dart format .
Releasing #
- Bump
versioninpubspec.yamlandpackageVersioninlib/src/version.dart(CI checks they agree — the version rides in theuser-agentof every request), and add aCHANGELOG.mdentry. - Tag
vX.Y.Zand push it..github/workflows/publish.ymlre-runs the whole suite, checks the tag against the pubspec, and publishes over OIDC — there is noPUB_TOKENsecret to leak.
The first release is manual: run dart pub publish locally, then enable
Automated publishing on the package's Admin tab on pub.flutter-io.cn (GitHub Actions,
repository RabinApps/supdesk-dart, tag pattern v{{version}}). That page only
exists once the package does.
License #
MIT