npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dupr-js-client

v3.0.1

Published

A comprehensive TypeScript/JavaScript client for the DUPR (Dynamic Universal Pickleball Rating) API.

Readme

dupr-js-client

CI npm version license: MIT Node.js 18+ zero dependencies

A TypeScript/JavaScript client for the DUPR (Dynamic Universal Pickleball Rating) Partner API.
Modelled with inspiration from the Python library dupr-api-client.

Source: OpenAPI 3.1.0 spec at https://uat.mydupr.com/api/v3/api-docs/DUPR%20Partner%20APIs


Features

  • Full coverage of the DUPR Partner API v1.0
  • Auto-refreshing client-credentials token cache — transparent, no action required
  • Authorization Code Flow support for user-facing "Connect DUPR" integrations
  • Automatic retry with exponential backoff on 5xx and network errors; honours Retry-After on 429
  • Typed request/response bodies for every endpoint
  • Structured error hierarchy (AuthenticationError, ValidationError, NotFoundError, RateLimitError, ServerError)
  • Dual ESM + CJS build — works in Node.js, Bun, and bundlers
  • Zero runtime dependencies — uses the native fetch API (Node.js 18+)
  • customFetch injection for testing, polyfilling, or proxying
  • onRequest / onResponse hooks for logging and metrics

Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Authentication
  5. Retry Behaviour
  6. Error Handling
  7. Observability Hooks
  8. API Reference
  9. Type Reference
  10. Testing
  11. Express Integration Example
  12. Regenerating Types from the OpenAPI Spec
  13. Development
  14. Contributing
  15. License

1. Installation

npm install dupr-js-client

Requirements: Node.js 18+ (native fetch and AbortSignal.timeout), TypeScript 5.x (optional — types are bundled).

The package ships both ESM (dist/index.js) and CJS (dist/index.cjs) with full .d.ts declarations.


2. Quick Start

import { DuprClient } from "dupr-js-client";

const client = new DuprClient({
  auth: {
    type: "clientCredentials",
    clientKey: process.env.DUPR_CLIENT_KEY!,
    clientSecret: process.env.DUPR_CLIENT_SECRET!,
  },
  // baseUrl defaults to "https://uat.mydupr.com/api"
  // version  defaults to "v1.0"
});

// Look up a player profile
const { result } = await client.users.getUser("ABC123");
console.log(result?.fullName, result?.doublesRating);

// Search players by name with filters
const found = await client.users.search({
  query: "Jane Smith",
  offset: 0,
  limit: 10,
  filters: { rating: { type: "DOUBLES", min: 4.0, max: 5.5 } },
});

// Submit a doubles match result
const match = await client.matches.create({
  identifier: "my-app-match-001",   // your universally unique ID — never reuse
  matchDate: "2024-06-15",          // yyyy-MM-dd
  matchFormat: "DOUBLES",
  source: "PARTNER",
  teams: [
    { players: [{ duprId: "AAA111" }, { duprId: "BBB222" }], scores: [11, 8] },
    { players: [{ duprId: "CCC333" }, { duprId: "DDD444" }], scores: [8, 11] },
  ],
});
console.log("Match code:", match.result?.matchCode);

3. Configuration

const client = new DuprClient(options);

DuprClientOptions

| Option | Type | Default | Description | |---|---|---|---| | auth | AuthMode | { type: "none" } | Authentication mode. See §4. | | baseUrl | string | "https://uat.mydupr.com/api" | API base URL. Use "https://mydupr.com/api" for production. | | version | string | "v1.0" | API version prefix inserted in all paths. | | timeoutMs | number | 30_000 | Per-request timeout in milliseconds. | | userAgent | string | "dupr-js-client/0.1.0" | User-Agent header sent on every request. | | customFetch | typeof fetch | globalThis.fetch | Inject a custom fetch — useful for mocking in tests or polyfilling. | | retry | RetryOptions \| false | see below | Retry policy for transient errors. Pass false to disable. | | onRequest | (info: RequestInfo) => void | — | Called before every request. Useful for logging. | | onResponse | (info: ResponseInfo) => void | — | Called after every response. Useful for metrics. |

RetryOptions

| Option | Type | Default | Description | |---|---|---|---| | maxRetries | number | 3 | Maximum retry attempts after the initial failure. | | baseDelayMs | number | 1_000 | Base backoff delay in ms. Doubles each attempt with ±20% jitter. | | maxDelayMs | number | 30_000 | Maximum backoff cap in ms. |

Environment variables pattern

// duprClient.ts
import { DuprClient } from "dupr-js-client";

export const dupr = new DuprClient({
  baseUrl: process.env.DUPR_API_BASE_URL ?? "https://uat.mydupr.com/api",
  auth: {
    type: "clientCredentials",
    clientKey: process.env.DUPR_CLIENT_KEY!,
    clientSecret: process.env.DUPR_CLIENT_SECRET!,
  },
});
# .env
DUPR_API_BASE_URL=https://uat.mydupr.com/api
DUPR_CLIENT_KEY=your_client_key
DUPR_CLIENT_SECRET=your_client_secret

4. Authentication

4.1 Client Credentials (server-to-server)

The standard mode for backend services. The SDK acquires and silently refreshes tokens.

Internally: POSTs to POST /auth/{version}/token with an x-authorization: base64(clientKey:clientSecret) header. The response token is cached until 60 seconds before expiry, then auto-refreshed. No action required from your code.

const client = new DuprClient({
  auth: {
    type: "clientCredentials",
    clientKey: "your_client_key",
    clientSecret: "your_client_secret",
  },
});

// Optionally pre-warm the token cache on startup:
const { token, expiresIn } = await client.auth.getToken();

4.2 Static Bearer Token

Use when your backend handles authentication and injects a pre-obtained JWT. No token refresh is attempted.

// Fixed token
const client = new DuprClient({
  auth: { type: "staticBearer", bearerToken: "eyJ..." },
});

// Runtime injection — swap the token without rebuilding the client
client.setBearerToken("new-eyJ...");
client.clearBearerToken();   // revert to configured auth mode

4.3 Authorization Code Flow ("Connect DUPR" button)

Use this flow to let your end-users authorise your app to act on their behalf — for example, showing a player's personal DUPR rating inside your app.

Note: This requires a DUPR OAuth application with a user-facing authorization endpoint, separate from the Partner API client credentials. Contact DUPR support for the authorization URL and OAuth application credentials.

Flow overview

User clicks "Connect DUPR"
        │
        ▼
1. Build authorization URL  →  client.auth.getAuthorizationUrl({ redirectUri, state })
        │
        ▼
2. Redirect user to that URL (DUPR login page)
        │
        ▼
3. User approves  →  DUPR redirects to your redirectUri?code=AUTH_CODE&state=STATE
        │
        ▼
4. Exchange code  →  client.auth.exchangeCode({ code, redirectUri })
        │              returns { token, refresh_token, expiresIn }
        ▼
5. Store tokens. Call client.setBearerToken(token) for API calls.
        │
        ▼
6. On expiry  →  client.auth.refreshToken(refresh_token)
sequenceDiagram
    participant App as YourApp
    participant User
    participant DUPRAuth as DUPR Authorization Server
    participant DUPRAPI as DUPR API

    User->>App: Click "Connect with DUPR"
    App->>User: Redirect to DUPRAuth (with client_id, redirect_uri, scopes)
    User->>DUPRAuth: Login & Authorize
    DUPRAuth-->>App: Redirect to callback with code
    App->>DUPRAuth: POST /auth/v1.0/token (grant_type=authorization_code, code, redirect_uri, client_id, client_secret)
    DUPRAuth-->>App: { access_token, refresh_token, expires_in }
    App->>App: Save tokens (in DB or session)
    App->>DUPRAPI: GET /player/v1.0/me (Authorization: Bearer access_token)
    DUPRAPI-->>App: { user DUPR profile data ... }

Implementation

Step 1 — initiate the flow:

import crypto from "node:crypto";

// Inject authorizationUrl (confirm the exact URL with DUPR support)
(client.config as any).authorizationUrl = "https://auth.mydupr.com/oauth/authorize";

app.get("/auth/dupr/connect", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  req.session.duprOAuthState = state;

  const url = client.auth.getAuthorizationUrl({
    redirectUri: "https://yourapp.com/auth/dupr/callback",
    scopes: ["user.read"],
    state,
  });

  res.redirect(url);
});

Step 2 — handle the callback:

app.get("/auth/dupr/callback", async (req, res) => {
  const { code, state } = req.query as Record<string, string>;

  if (state !== req.session.duprOAuthState) {
    return res.status(400).send("Invalid state");
  }

  const tokens = await client.auth.exchangeCode({
    code,
    redirectUri: "https://yourapp.com/auth/dupr/callback",
  });

  await db.users.update(req.user.id, {
    duprAccessToken: tokens.token,
    duprRefreshToken: tokens.refresh_token,
    duprTokenExpiresAt: Date.now() + (tokens.expiresIn ?? 3600) * 1000,
  });

  res.redirect("/profile");
});

Step 3 — use the user token:

client.setBearerToken(user.duprAccessToken);
const profile = await client.users.getUser(user.duprId);

Step 4 — refresh on expiry:

async function getValidToken(user: UserRecord): Promise<string> {
  if (Date.now() < user.duprTokenExpiresAt - 60_000) return user.duprAccessToken;

  const refreshed = await client.auth.refreshToken(user.duprRefreshToken);
  await db.users.update(user.id, {
    duprAccessToken: refreshed.token!,
    duprRefreshToken: refreshed.refresh_token ?? user.duprRefreshToken,
    duprTokenExpiresAt: Date.now() + (refreshed.expiresIn ?? 3600) * 1000,
  });
  return refreshed.token!;
}

5. Retry Behaviour

The SDK automatically retries requests that fail with transient errors.

| Condition | Retried? | Notes | |---|---|---| | ServerError (5xx) | Yes | | | RateLimitError (429) | Yes | Honours Retry-After response header if present | | Network failure (TypeError) | Yes | Connection refused, DNS failure, etc. | | ValidationError (400) | No | Fix the request | | NotFoundError (404) | No | Resource does not exist | | AuthenticationError (401/403) | No | Fix credentials |

Backoff formula: delay = min(baseDelayMs × 2ⁿ, maxDelayMs) × jitter(0.8–1.2)

// Custom retry policy
const client = new DuprClient({
  auth: { ... },
  retry: { maxRetries: 5, baseDelayMs: 500, maxDelayMs: 60_000 },
});

// Disable retry (e.g. for write operations where idempotency is not guaranteed)
const client = new DuprClient({
  auth: { ... },
  retry: false,
});

6. Error Handling

All errors extend DuprApiError.

DuprApiError
├── AuthenticationError   (401, 403)
├── ValidationError       (400)
├── NotFoundError         (404)
├── RateLimitError        (429)
└── ServerError           (5xx)

DuprApiError properties

| Property | Type | Description | |---|---|---| | message | string | Human-readable description from the DUPR response, or a generated fallback. | | statusCode | number | HTTP status code. | | details | unknown | Raw parsed response body. | | duprRequestId | string \| undefined | Value of x-request-id response header — include in support tickets. |

import {
  DuprApiError,
  AuthenticationError,
  ValidationError,
  NotFoundError,
  RateLimitError,
  ServerError,
} from "dupr-js-client";

try {
  await client.matches.create(payload);
} catch (err) {
  if (err instanceof ValidationError) {
    console.error("Bad request:", err.details);
  } else if (err instanceof RateLimitError) {
    // SDK already retried — you've genuinely hit the cap
    console.warn("Rate limited.");
  } else if (err instanceof AuthenticationError) {
    console.error("Auth failed:", err.message);
  } else if (err instanceof NotFoundError) {
    console.warn("Resource not found.");
  } else if (err instanceof ServerError) {
    console.error("DUPR server error. Request ID:", err.duprRequestId);
  } else if (err instanceof DuprApiError) {
    console.error(`HTTP ${err.statusCode}:`, err.message);
  } else {
    throw err;
  }
}

7. Observability Hooks

onRequest and onResponse let you integrate with any logging or metrics system without wrapping individual calls.

import pino from "pino";

const log = pino();

const client = new DuprClient({
  auth: { ... },

  onRequest({ method, url }) {
    log.debug({ method, url }, "→ DUPR");
  },

  onResponse({ status, url, durationMs }) {
    log.debug({ status, url, durationMs }, "← DUPR");
    metrics.histogram("dupr.latency", durationMs, { status: String(status) });
    if (status >= 400) metrics.counter("dupr.errors", 1, { status: String(status) });
  },
});

onResponse fires once per attempt, including retried attempts. To record only final outcomes, track state in your own closure.


8. API Reference

All methods return Promise<ApiWrapper<T>>.

interface ApiWrapper<T = unknown> {
  status: "SUCCESS" | "FAILURE";
  message?: string;
  result?: T;       // the actual payload
}

8.1 client.auth

| Method | Description | |---|---| | getToken() | Explicitly fetch a client-credentials token. Normally auto-managed. | | getAuthorizationUrl(params) | Build the OAuth redirect URL for user-facing login. | | exchangeCode({ code, redirectUri }) | Exchange an auth code for { token, refresh_token, expiresIn }. | | refreshToken(refreshToken) | Get a new access token using a refresh token. |

See §4 for full examples.


8.2 client.users

| Method | Endpoint | Description | |---|---|---| | getUser(duprId) | GET /user/{v}/{id} | Basic player profile | | getExtendedUser(duprId) | GET /user/{v}/{id}/details | Profile + email (requires USER_EMAIL::VIEW permission) | | getClubMemberships(duprId) | GET /user/{v}/{id}/clubs | Club memberships | | search(req) | POST /user/{v}/search | Full-text search with optional filters | | getBatch(req) | POST /user/{v}/batch | Fetch multiple players by DUPR ID in one request | | invite(req) | POST /user/{v}/invite | Pre-generate a DUPR ID and send an invite email | | grantSubscription(req) | POST /user/{v}/subscription/grants | Grant a product subscription to a user | | getProvisionalRating(req) | POST /user/{v}/provisional_rating | Get provisional ratings | | createProvisionalRating(req) | POST /user/{v}/provisional_rating/create | Set provisional ratings | | updateProvisionalRating(req) | POST /user/{v}/provisional_rating/update | Update provisional ratings | | deleteProvisionalRating(req) | DELETE /user/{v}/provisional_rating/delete | Delete provisional ratings |

search example with filters:

const results = await client.users.search({
  query: "Maria Garcia",
  offset: 0,
  limit: 20,
  filters: {
    gender: "FEMALE",
    rating: { type: "DOUBLES", min: 4.0, max: 5.5, reliable: true },
    location: { lat: 37.77, lng: -122.41, radiusInMeters: 50_000 },
    age: { min: 25, max: 45 },
  },
});

8.3 client.players

| Method | Endpoint | Description | |---|---|---| | getDuprIdByEmail({ email }) | POST /{v}/player/duprid-by-email | Resolve a DUPR ID from an email address |

const { result } = await client.players.getDuprIdByEmail({ email: "[email protected]" });
// result: { duprId: "XYZ789" }

8.4 client.playerRating

| Method | Endpoint | Description | |---|---|---| | getHistory(req) | POST /history | Rating history for a player | | getSubscriptions() | GET /{v}/subscribe/rating-changes | List currently subscribed DUPR IDs | | subscribe(req) | POST /{v}/subscribe/rating-changes | Subscribe to rating-change events | | unsubscribe(req) | DELETE /{v}/subscribe/rating-changes | Unsubscribe from rating-change events |

// Fetch rating history
const { result } = await client.playerRating.getHistory({
  duprId: "ABC123",
  offset: 0,
  limit: 50,
});
// result: [{ date, singlesRating, doublesRating }, ...]

// Subscribe to webhook notifications for a list of players
await client.playerRating.subscribe({ duprIds: ["AAA111", "BBB222"] });

8.5 client.matches

Important: The identifier field must be universally unique across your entire application. It must never be reused, even if a match is deleted.

| Method | Endpoint | Description | |---|---|---| | get(matchId) | GET /match/{v}/{id} | View a match by DUPR match code | | create(match) | POST /match/{v}/create | Submit a new match result | | createBulk(matches[]) | POST /match/{v}/batch | Submit multiple matches at once | | update(req) | POST /match/{v}/update | Update an existing match | | delete(req) | DELETE /match/{v}/delete | Delete a match | | annotate(req) | POST /match/{v}/annotate | Attach vendor metadata to a match | | deleteAnnotation(matchId) | DELETE /match/{v}/annotate/{id} | Remove a match annotation | | searchHistory(req) | POST /match/history/search | Search a player's match history |

create full example:

const { result } = await client.matches.create({
  identifier: "session-42-match-7",   // your unique ID
  matchDate: "2024-06-15",
  matchFormat: "DOUBLES",
  source: "PARTNER",
  teams: [
    {
      players: [{ duprId: "AAA111" }, { duprId: "BBB222" }],
      scores: [11, 7, 11],   // scores per game (won games 1 and 3)
    },
    {
      players: [{ duprId: "CCC333" }, { duprId: "DDD444" }],
      scores: [8, 11, 9],
    },
  ],
  clubId: 123,    // optional
  eventId: 456,   // optional
});
console.log(result?.matchCode);   // DUPR's canonical match code

ExternalMatchTeam fields:

| Field | Type | Notes | |---|---|---| | players | ExternalMatchPlayer[] | 1 player for SINGLES, 2 for DOUBLES | | scores | number[] | Per-game scores. Both teams' arrays must be the same length. |


8.6 client.clubs

| Method | Endpoint | Description | |---|---|---| | membersRating({ clubId }) | POST /club/{v}/members | DUPR ratings for all club members | | searchMatches({ clubId, offset?, limit? }) | POST /club/{v}/match/search | Matches associated with a club |


8.7 client.events

| Method | Endpoint | Description | |---|---|---| | create(req) | POST /events/{v}/create | Create an event | | get({ eventIds }) | POST /events/{v}/get | Get one or more events by ID | | update(req) | POST /events/{v}/update | Update an event | | delete({ eventIds }) | POST /events/{v}/delete | Delete events |

const { result } = await client.events.create({
  name: "Summer Slam 2024",
  description: "Annual club championship",
  startDate: "2024-07-01",
  endDate: "2024-07-03",
  location: "San Francisco, CA",
  clubId: 123,
});
// result: { eventId, name }

8.8 client.webhooks

| Method | Endpoint | Description | |---|---|---| | register({ webhookUrl, topics }) | POST /{v}/webhook | Register your HTTPS webhook endpoint | | getTopics() | GET /{v}/topic | List available webhook topics | | listSchemas() | GET /{v}/webhook/schema | List available webhook schemas | | getSchema(topic) | GET /{v}/webhook/schema/{topic} | Get JSON schema for a topic | | subscribeUsers({ duprIds, topic }) | POST /user/{v}/subscribe/webhook-event | Subscribe players to webhook notifications | | unsubscribeUsers({ duprIds, topic }) | DELETE /user/{v}/subscribe/webhook-event | Unsubscribe players |

// Register your endpoint to receive RATING change events
await client.webhooks.register({
  webhookUrl: "https://yourapp.com/webhooks/dupr",   // must be HTTPS
  topics: ["RATING"],
});

// Subscribe specific players
await client.webhooks.subscribeUsers({
  duprIds: ["ABC123", "DEF456"],
  topic: "RATING",
});

9. Type Reference

All types are exported from the package root.

Enums / union types

| Type | Values | |---|---| | MatchFormat | "SINGLES" \| "DOUBLES" | | MatchSource | "PARTNER" \| "CLUB" | | Gender | "MALE" \| "FEMALE" | | RatingType | "SINGLES" \| "DOUBLES" | | WebhookTopic | "RATING" | | ApiStatus | "SUCCESS" \| "FAILURE" |

Core response shapes

interface ApiWrapper<T = unknown> { status: ApiStatus; message?: string; result?: T; }

interface UserInfo {
  duprId: string; fullName: string;
  singlesRating?: number; doublesRating?: number;
  singlesProvisional?: boolean; doublesProvisional?: boolean;
}

interface ExtendedUserInfo extends UserInfo { email?: string; }

interface MatchResponse {
  matchCode?: string; hashedMatchCode?: string;
  identifier?: string; matchDate?: string;
  matchFormat?: MatchFormat; teams?: ExternalMatchTeam[];
}

interface TokenResponse { token?: string; accessToken?: string; expiresIn?: number; }

interface AuthCodeTokenResponse extends TokenResponse {
  refresh_token?: string; token_type?: string; scope?: string;
}

Request type → method mapping

| Type | Used by | |---|---| | ExternalMatchRequest | matches.create() | | ExternalUpdateMatchRequest | matches.update() | | ExternalDeleteMatchRequest | matches.delete() | | ExternalMatchSearchRequest | matches.searchHistory() | | ExternalSearchRequest | users.search() | | ExternalSearchFilter | users.search()filters field | | ExternalFilterLocation | ExternalSearchFilter.location | | ExternalRatingFilter | ExternalSearchFilter.rating | | ExternalAgeRangeFilter | ExternalSearchFilter.age | | ExternalBatchUserDetailRequest | users.getBatch() | | ExternalInviteRequest | users.invite() | | CreateProvisionalRatingRequest | users.createProvisionalRating() | | UpdateProvisionalRatingRequest | users.updateProvisionalRating() | | PlayerRatingSubscribeRequest | playerRating.subscribe/unsubscribe() | | GrantExternalSubscriptionRequest | users.grantSubscription() | | ExternalClubMemberRequest | clubs.membersRating() | | ExternalClubMatchSearchRequest | clubs.searchMatches() | | CreateEventRequestV1 | events.create() | | UpdateEventRequestV1 | events.update() | | GetEventRequestV1 | events.get() | | DeleteEventRequestV1 | events.delete() | | ClientHookRequest | webhooks.register() | | UserWebhookRequest | webhooks.subscribeUsers/unsubscribeUsers() |


10. Testing

Inject a mock fetch via customFetch — no real network calls, no test server needed.

import { describe, it, expect, vi } from "vitest";
import { DuprClient, NotFoundError } from "dupr-js-client";

function makeClient(fetchMock: ReturnType<typeof vi.fn>) {
  return new DuprClient({
    auth: { type: "staticBearer", bearerToken: "test-token" },
    customFetch: fetchMock as typeof fetch,
    retry: false,   // deterministic — no backoff sleeps in tests
  });
}

it("returns the player on 200", async () => {
  const fetch = vi.fn().mockResolvedValue(
    new Response(
      JSON.stringify({ status: "SUCCESS", result: { duprId: "ABC123", fullName: "Jane Smith" } }),
      { status: 200, headers: { "Content-Type": "application/json" } },
    ),
  );
  const { result } = await makeClient(fetch).users.getUser("ABC123");
  expect(result?.fullName).toBe("Jane Smith");
});

it("throws NotFoundError on 404", async () => {
  const fetch = vi.fn().mockResolvedValue(
    new Response(JSON.stringify({ message: "Not found" }), { status: 404 }),
  );
  await expect(makeClient(fetch).users.getUser("NOPE")).rejects.toBeInstanceOf(NotFoundError);
});

Testing retry with fake timers:

import { vi } from "vitest";

beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

it("retries once then succeeds", async () => {
  const fetch = vi.fn()
    .mockResolvedValueOnce(new Response("{}", { status: 503 }))
    .mockResolvedValueOnce(
      new Response(JSON.stringify({ status: "SUCCESS" }), { status: 200 }),
    );

  const client = new DuprClient({
    auth: { type: "staticBearer", bearerToken: "tok" },
    customFetch: fetch as typeof fetch,
    retry: { maxRetries: 1, baseDelayMs: 100 },
  });

  const promise = client.users.getUser("ABC");
  // Attach assertion BEFORE advancing timers to prevent unhandled-rejection warnings
  const assertion = expect(promise).resolves.toMatchObject({ status: "SUCCESS" });
  await vi.runAllTimersAsync();
  await assertion;
  expect(fetch).toHaveBeenCalledTimes(2);
});

11. Express Integration Example

// server/src/duprClient.ts
import { DuprClient } from "dupr-js-client";

export const dupr = new DuprClient({
  baseUrl: process.env.DUPR_API_BASE_URL ?? "https://uat.mydupr.com/api",
  auth: {
    type: "clientCredentials",
    clientKey: process.env.DUPR_CLIENT_KEY!,
    clientSecret: process.env.DUPR_CLIENT_SECRET!,
  },
  onRequest:  ({ method, url }) => console.log(`[DUPR] → ${method} ${url}`),
  onResponse: ({ status, durationMs }) => console.log(`[DUPR] ← ${status} in ${durationMs}ms`),
});
// server/src/routes/dupr.ts
import { Router } from "express";
import { dupr } from "../duprClient.js";
import { NotFoundError, ValidationError } from "dupr-js-client";

const router = Router();

// GET /api/dupr/players/:duprId
router.get("/players/:duprId", async (req, res, next) => {
  try {
    res.json(await dupr.users.getUser(req.params.duprId));
  } catch (err) {
    if (err instanceof NotFoundError) return res.status(404).json({ error: "Player not found" });
    next(err);
  }
});

// POST /api/dupr/matches
router.post("/matches", async (req, res, next) => {
  try {
    res.status(201).json(await dupr.matches.create(req.body));
  } catch (err) {
    if (err instanceof ValidationError) return res.status(400).json({ error: err.message, details: err.details });
    next(err);
  }
});

// GET /api/dupr/players/:duprId/history
router.get("/players/:duprId/history", async (req, res, next) => {
  try {
    res.json(await dupr.playerRating.getHistory({
      duprId: req.params.duprId,
      offset: Number(req.query.offset ?? 0),
      limit: Number(req.query.limit ?? 20),
    }));
  } catch (err) {
    next(err);
  }
});

export default router;

12. Regenerating Types from the OpenAPI Spec

The types in src/types.ts are hand-rolled from the DUPR Partner APIs OpenAPI 3.1.0 spec. To regenerate from the live spec:

npm run types:generate

This runs:

npx openapi-typescript "https://uat.mydupr.com/api/v3/api-docs/DUPR%20Partner%20APIs" \
  -o src/types.generated.ts

Then update src/types.ts to import from the generated file:

import type { components } from "./types.generated.js";

export type ExternalMatchRequest = components["schemas"]["ExternalMatchRequest"];
export type UserInfo             = components["schemas"]["UserDetail"];
// etc.

Review the diff carefully before committing — field names in the live spec may differ from what this version uses. Run npm test after updating.


13. Development

git clone https://github.com/sunnytambi/dupr-js-client.git
cd dupr-js-client
npm install

npm test           # vitest — 57 tests
npm run build      # tsup → dist/ (ESM + CJS + .d.ts)
npm run typecheck  # tsc --noEmit
npm run lint       # eslint
npm run format     # prettier

CI runs on Node.js 18, 20, and 22 on every push and pull request.


14. Contributing

See CONTRIBUTING.md for branch conventions, commit message format, test guidelines, and the release process.


15. License

MIT © Sunny Tambi