ashby-ts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Waits
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # ashby-ts
2
+
3
+ Typed TypeScript client for the [Ashby](https://www.ashbyhq.com) API, generated from [Ashby's published OpenAPI spec](https://developers.ashbyhq.com/openapi/ashby-api.json).
4
+
5
+ All 173 endpoints across 53 resource namespaces, fully typed. Zero runtime dependencies. Works in Node 18+, Bun, and edge runtimes (it only needs `fetch` and `btoa`).
6
+
7
+ ```bash
8
+ npm install ashby-ts
9
+ # or
10
+ bun add ashby-ts
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ts
16
+ import { Ashby } from "ashby-ts";
17
+
18
+ const ashby = new Ashby({ apiKey: process.env.ASHBY_API_KEY! });
19
+
20
+ // every list endpoint is an async iterator; pagination is handled for you
21
+ for await (const job of ashby.job.list({ status: ["Open"] })) {
22
+ console.log(job.title);
23
+ }
24
+
25
+ const { results: candidate } = await ashby.candidate.info({ id: "..." });
26
+ ```
27
+
28
+ The client surface mirrors Ashby's own API exactly: their endpoints are RPC-style `noun.verb` calls (`application.list`, `candidate.create`), so the client is `ashby.application.list()`, `ashby.candidate.create()`, and so on for every endpoint in the spec. If you know their API reference, you know this client. Responses come back as Ashby's own envelopes, typed.
29
+
30
+ ## Pagination
31
+
32
+ Every cursor-paginated endpoint returns a lazy call object. Awaiting it fetches one page. Iterating it fetches everything, following `nextCursor` until the data runs out.
33
+
34
+ ```ts
35
+ // one page
36
+ const page = await ashby.application.list({ limit: 50 });
37
+ page.results; // Application[]
38
+ page.nextCursor; // string | undefined
39
+
40
+ // or the explicit spelling of the same thing
41
+ const samePage = await ashby.application.list({ limit: 50 }).page();
42
+
43
+ // every item, across every page
44
+ for await (const app of ashby.application.list({ jobId })) {
45
+ // breaking early stops fetching; no wasted requests
46
+ }
47
+
48
+ // collect everything into one array
49
+ const all = await ashby.application.list({ jobId }).all();
50
+ ```
51
+
52
+ ## Incremental sync
53
+
54
+ Ashby's list endpoints support `syncToken` incremental sync, and their docs push it hard for good reason: it's the difference between refetching your whole ATS and fetching what changed. The `sync()` helper drains all pages and hands you the next token.
55
+
56
+ ```ts
57
+ // first sync: everything
58
+ let { results, syncToken } = await ashby.candidate.sync();
59
+
60
+ // later syncs: only what changed
61
+ ({ results, syncToken } = await ashby.candidate.sync({ syncToken }));
62
+ ```
63
+
64
+ `sync()` exists on the 26 namespaces whose list endpoint supports it. The types won't let you call it anywhere else.
65
+
66
+ ## Errors
67
+
68
+ Ashby reports most failures as HTTP 200 with `{ success: false, errors: [...] }`. The client unwraps that: a successful call resolves with the success envelope, anything else throws `AshbyError` carrying Ashby's actual error details.
69
+
70
+ ```ts
71
+ import { AshbyError } from "ashby-ts";
72
+
73
+ try {
74
+ await ashby.application.changeStage({ applicationId, interviewStageId });
75
+ } catch (err) {
76
+ if (err instanceof AshbyError) {
77
+ err.status; // HTTP status (200 for logical errors)
78
+ err.endpoint; // "application.changeStage"
79
+ err.errors; // [{ message, parameter? }]
80
+ }
81
+ }
82
+ ```
83
+
84
+ 429s and 5xxs are retried twice with backoff, honoring `Retry-After`. Tune with `maxRetries`.
85
+
86
+ ## Escape hatch
87
+
88
+ If you'd rather call endpoints by path, `request()` is the raw typed transport:
89
+
90
+ ```ts
91
+ const res = await ashby.request("/apiKey.info");
92
+ ```
93
+
94
+ ## How it's built
95
+
96
+ The entire client is generated from Ashby's spec; there is no per-endpoint code to drift out of date.
97
+
98
+ - `openapi-typescript` turns the vendored spec (`spec/ashby-api.json`) into types
99
+ - the client surface is a mapped type over those generated types: namespaces, methods, request bodies, response envelopes, which endpoints paginate, which support sync
100
+ - the runtime is one small Proxy-based fetch wrapper (~200 lines), because Ashby's API is uniform enough that nothing else is needed
101
+
102
+ Regenerate against the latest spec with `bun run generate --fetch`. The API reference in [`docs/api.md`](./docs/api.md) is generated from this package's own types with [@openpkg-ts/sdk](https://github.com/ryanwaits/openpkg-ts).
103
+
104
+ ## Notes
105
+
106
+ - Auth is Ashby's standard scheme: your API key as the Basic auth username, blank password. Keys and permissions are managed in Ashby's admin console.
107
+ - This client talks to your real ATS. Endpoints require the corresponding key permissions (`candidatesRead`, `candidatesWrite`, ...), and write calls do what they say.
108
+ - Not affiliated with Ashby. Ashby's API is versioned by them; this package tracks the published spec and vendored spec updates are explicit, reviewable diffs.
109
+
110
+ MIT
@@ -0,0 +1,42 @@
1
+ import type { AshbyClient, RequestOf, RpcPath, SuccessOf } from "./types.js";
2
+ export interface AshbyOptions {
3
+ /** Ashby API key. Sent as the Basic-auth username with a blank password. */
4
+ apiKey: string;
5
+ /** Override the API origin. Defaults to https://api.ashbyhq.com */
6
+ baseUrl?: string;
7
+ /** Custom fetch implementation (testing, instrumentation). Defaults to globalThis.fetch. */
8
+ fetch?: typeof globalThis.fetch;
9
+ /** Retries on 429/5xx before giving up. Defaults to 2. Retry-After is honored when present. */
10
+ maxRetries?: number;
11
+ }
12
+ export interface AshbyErrorDetail {
13
+ message: string;
14
+ parameter?: string;
15
+ }
16
+ /** Thrown for HTTP failures and for `success: false` envelopes alike. */
17
+ export declare class AshbyError extends Error {
18
+ readonly status: number;
19
+ readonly endpoint: string;
20
+ readonly errors: AshbyErrorDetail[];
21
+ constructor(endpoint: string, status: number, errors: AshbyErrorDetail[]);
22
+ }
23
+ export type Ashby = AshbyClient & {
24
+ /** Raw escape hatch: call any endpoint by path with full typing. */
25
+ request<P extends RpcPath>(path: P, body?: RequestOf<P>): Promise<SuccessOf<P>>;
26
+ };
27
+ interface AshbyConstructor {
28
+ new (options: AshbyOptions): Ashby;
29
+ }
30
+ /**
31
+ * The Ashby API client.
32
+ *
33
+ * ```ts
34
+ * const ashby = new Ashby({ apiKey: process.env.ASHBY_API_KEY! });
35
+ * for await (const job of ashby.job.list({ status: ["Open"] })) {
36
+ * console.log(job.title);
37
+ * }
38
+ * ```
39
+ */
40
+ export declare const Ashby: AshbyConstructor;
41
+ export {};
42
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAc,MAAM,YAAY,CAAC;AAEzF,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4FAA4F;IAC5F,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,+FAA+F;IAC/F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,yEAAyE;AACzE,qBAAa,UAAW,SAAQ,KAAK;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC;gBAExB,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE;CAQzE;AAsKD,MAAM,MAAM,KAAK,GAAG,WAAW,GAAG;IAChC,oEAAoE;IACpE,OAAO,CAAC,CAAC,SAAS,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;CACjF,CAAC;AAEF,UAAU,gBAAgB;IACxB,KAAK,OAAO,EAAE,YAAY,GAAG,KAAK,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,KAAK,EAAE,gBAUY,CAAC"}
package/dist/client.js ADDED
@@ -0,0 +1,182 @@
1
+ /** Thrown for HTTP failures and for `success: false` envelopes alike. */
2
+ export class AshbyError extends Error {
3
+ status;
4
+ endpoint;
5
+ errors;
6
+ constructor(endpoint, status, errors) {
7
+ const detail = errors.map((e) => e.message).join("; ") || `HTTP ${status}`;
8
+ super(`${endpoint}: ${detail}`);
9
+ this.name = "AshbyError";
10
+ this.status = status;
11
+ this.endpoint = endpoint;
12
+ this.errors = errors;
13
+ }
14
+ }
15
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
+ class Core {
17
+ baseUrl;
18
+ auth;
19
+ fetchImpl;
20
+ maxRetries;
21
+ constructor(options) {
22
+ if (!options.apiKey)
23
+ throw new Error("ashby-ts: apiKey is required");
24
+ this.baseUrl = (options.baseUrl ?? "https://api.ashbyhq.com").replace(/\/$/, "");
25
+ this.auth = `Basic ${btoa(`${options.apiKey}:`)}`;
26
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
27
+ this.maxRetries = options.maxRetries ?? 2;
28
+ }
29
+ async request(endpoint, body) {
30
+ for (let attempt = 0;; attempt++) {
31
+ const res = await this.fetchImpl(`${this.baseUrl}/${endpoint}`, {
32
+ method: "POST",
33
+ headers: {
34
+ authorization: this.auth,
35
+ "content-type": "application/json",
36
+ accept: "application/json",
37
+ },
38
+ body: JSON.stringify(body ?? {}),
39
+ });
40
+ if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) {
41
+ const retryAfter = Number(res.headers.get("retry-after"));
42
+ const delay = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
43
+ await sleep(delay);
44
+ continue;
45
+ }
46
+ let json;
47
+ try {
48
+ json = (await res.json());
49
+ }
50
+ catch {
51
+ // fall through to the HTTP error below
52
+ }
53
+ if (!res.ok)
54
+ throw new AshbyError(endpoint, res.status, json?.errors ?? []);
55
+ if (!json || json.success !== true) {
56
+ throw new AshbyError(endpoint, res.status, json?.errors ?? []);
57
+ }
58
+ return json;
59
+ }
60
+ }
61
+ async *paginate(endpoint, body) {
62
+ let cursor = body?.cursor;
63
+ for (;;) {
64
+ const page = await this.request(endpoint, cursor ? { ...body, cursor } : { ...body });
65
+ const results = Array.isArray(page.results) ? page.results : [];
66
+ for (const item of results)
67
+ yield item;
68
+ if (!page.moreDataAvailable || !page.nextCursor)
69
+ return;
70
+ cursor = page.nextCursor;
71
+ }
72
+ }
73
+ async sync(endpoint, body) {
74
+ const results = [];
75
+ let cursor;
76
+ let syncToken;
77
+ for (;;) {
78
+ const page = await this.request(endpoint, cursor ? { ...body, cursor } : { ...body });
79
+ if (Array.isArray(page.results))
80
+ results.push(...page.results);
81
+ if (page.syncToken !== undefined)
82
+ syncToken = page.syncToken;
83
+ if (!page.moreDataAvailable || !page.nextCursor)
84
+ break;
85
+ cursor = page.nextCursor;
86
+ }
87
+ return { results, syncToken };
88
+ }
89
+ }
90
+ /** Lazy thenable for list endpoints: await = one page, for-await = every item. */
91
+ class ListCallImpl {
92
+ core;
93
+ endpoint;
94
+ body;
95
+ executed;
96
+ constructor(core, endpoint, body) {
97
+ this.core = core;
98
+ this.endpoint = endpoint;
99
+ this.body = body;
100
+ }
101
+ run() {
102
+ this.executed ??= this.core.request(this.endpoint, this.body ?? {});
103
+ return this.executed;
104
+ }
105
+ then(onfulfilled, onrejected) {
106
+ return this.run().then(onfulfilled, onrejected);
107
+ }
108
+ catch(onrejected) {
109
+ return this.run().catch(onrejected);
110
+ }
111
+ finally(onfinally) {
112
+ return this.run().finally(onfinally);
113
+ }
114
+ page() {
115
+ return this.run();
116
+ }
117
+ async all() {
118
+ const items = [];
119
+ for await (const item of this.core.paginate(this.endpoint, this.body))
120
+ items.push(item);
121
+ return items;
122
+ }
123
+ [Symbol.asyncIterator]() {
124
+ return this.core.paginate(this.endpoint, this.body)[Symbol.asyncIterator]();
125
+ }
126
+ [Symbol.toStringTag] = "AshbyListCall";
127
+ }
128
+ function buildClient(core) {
129
+ const namespaces = new Map();
130
+ return new Proxy(Object.create(null), {
131
+ get(target, ns) {
132
+ const own = Reflect.get(target, ns);
133
+ if (own !== undefined)
134
+ return own;
135
+ // "then" must stay undefined so the client itself is never thenable
136
+ if (typeof ns !== "string" || ns === "then")
137
+ return undefined;
138
+ let namespace = namespaces.get(ns);
139
+ if (!namespace) {
140
+ const methods = new Map();
141
+ namespace = new Proxy(Object.create(null), {
142
+ get(_t, verb) {
143
+ if (typeof verb !== "string" || verb === "then")
144
+ return undefined;
145
+ let method = methods.get(verb);
146
+ if (!method) {
147
+ method =
148
+ verb === "sync"
149
+ ? (body) => core.sync(`${ns}.list`, body)
150
+ : (body) => new ListCallImpl(core, `${ns}.${verb}`, body);
151
+ methods.set(verb, method);
152
+ }
153
+ return method;
154
+ },
155
+ });
156
+ namespaces.set(ns, namespace);
157
+ }
158
+ return namespace;
159
+ },
160
+ });
161
+ }
162
+ /**
163
+ * The Ashby API client.
164
+ *
165
+ * ```ts
166
+ * const ashby = new Ashby({ apiKey: process.env.ASHBY_API_KEY! });
167
+ * for await (const job of ashby.job.list({ status: ["Open"] })) {
168
+ * console.log(job.title);
169
+ * }
170
+ * ```
171
+ */
172
+ export const Ashby = class {
173
+ constructor(options) {
174
+ const core = new Core(options);
175
+ const client = buildClient(core);
176
+ Object.defineProperty(client, "request", {
177
+ value: (path, body) => core.request(path.replace(/^\//, ""), body),
178
+ enumerable: false,
179
+ });
180
+ return client;
181
+ }
182
+ };