@relume_io/partner-node 2.1.0 → 2.2.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/README.md CHANGED
@@ -3,11 +3,10 @@
3
3
  The server SDK for Relume partners. It answers Relume's signed calls and
4
4
  serves builder sessions, so your backend never writes any cryptography.
5
5
 
6
- It implements the server side of the
7
- [Relume Partner Protocol v1](../../docs/partner-protocol/v1.md). Pair it with
8
- [`@relume_io/partner-embed`](../embed) on your page. The
9
- [partner docs](https://apis.relume.io/nova/docs/partners) have quickstarts for
10
- Express and Next.js, guides and the partner API reference.
6
+ Your page needs
7
+ [`@relume_io/partner-embed`](https://www.npmjs.com/package/@relume_io/partner-embed).
8
+ The [partner docs](https://apis.relume.io/nova/docs/partners) have
9
+ quickstarts, guides and the partner API reference.
11
10
 
12
11
  ## Install
13
12
 
@@ -16,7 +15,8 @@ npm install @relume_io/partner-node
16
15
  ```
17
16
 
18
17
  Node 20 or later, or any runtime with `fetch` and Web Crypto (Cloudflare
19
- Workers, Bun, Deno, Next.js edge). No runtime dependencies.
18
+ Workers, Bun, Deno, Next.js edge). No runtime dependencies. Install the same
19
+ version as `@relume_io/partner-embed`.
20
20
 
21
21
  ## Quickstart (Express)
22
22
 
@@ -55,107 +55,93 @@ app.use('/relume', relume.express()); // before express.json()
55
55
  app.use(express.json());
56
56
  ```
57
57
 
58
- Then, in Relume's partner settings, set your endpoint URL to
58
+ ## Endpoint URL
59
+
60
+ On the Partners page in Relume, set each environment's endpoint URL to
59
61
  `https://<your backend>/relume/calls`.
60
62
 
61
- ## What it mounts
62
-
63
- | Route | Called by | Does |
64
- | --------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
65
- | `GET /relume/session` | `@relume_io/partner-embed` | Refuses another site, resolves the user with `getUserIdFromRequest`, calls `POST /sessions`, returns `{ clientSecret, expiresAt }`. |
66
- | `POST /relume/calls` | Relume | Verifies `Relume-Signature` and the 5-minute window, routes by `type`, runs your callback, validates what it returns. |
67
-
68
- `relume.express()` answers exactly `/session` and `/calls` under its mount
69
- path (`app.use('/relume', …)`), or `/relume/session` and `/relume/calls` when
70
- mounted at the root, and passes everything else on. `relume.fetchHandler`
71
- answers any path ending in `/session` or `/calls`, because your framework has
72
- already routed the request to it.
73
-
74
- CORS preflights (`OPTIONS`) are left to your app: `express()` passes them on
75
- to the next middleware, and `fetchHandler` answers `204` with no CORS headers.
76
-
77
- If your backend is on another origin from your page, pass `sessionUrl` to
78
- `Relume.init` in the embed and allow your page's origin, with credentials and
79
- the `Relume-Embed` header, in that route's CORS response. Use an exact
80
- allowlist: never reflect the request `Origin` and never use `*`.
81
-
82
- Any site can make a signed-in user's browser call the session route, so it
83
- refuses two things before it looks at the user: a request with
84
- `Sec-Fetch-Site: cross-site`, which only another site's page sends, and one
85
- without the `Relume-Embed: 1` header `@relume_io/partner-embed` sends. The
86
- header makes a cross-origin request need a CORS preflight, so a link or form
87
- on another site can't get one. Neither helps against a script on your own
88
- page: any script there can ask the route for a session for the signed-in
89
- user, or answer the builder's session request with a client secret for
90
- another of your users, which opens the builder as them. Load only scripts you
91
- trust where the builder is embedded.
92
-
93
- | Situation | Answer |
94
- | -------------------------------------------- | ------------------------------------------------------------ |
95
- | Bad or missing signature, or stale `t` | `401 invalid_signature`; your callback isn't called |
96
- | Unknown `type`, or no callback registered | `400 unsupported_call_type` |
97
- | Body that isn't valid for its `type` | `400 invalid_request` |
98
- | `environment` isn't your secret key's | `400 environment_mismatch`; your callback isn't called |
99
- | `onGetUser` returns `null` | `404 user_not_found` |
100
- | `onChargeCredits` returns `{ ok: false }` | `402 insufficient_credits`, with `balance` |
101
- | A callback throws, or returns a bad shape | `500 internal_error`, and `onHandlerError` gets the error |
102
- | No `Relume-Embed: 1` header (session route) | `400 embed_header_missing`, and `onHandlerError` says why |
103
- | `Sec-Fetch-Site: cross-site` (session route) | `403 cross_site_request` |
104
- | Nobody signed in (session route) | `401 not_signed_in` |
105
- | `POST /sessions` refused (session route) | Relume's status and `{ error }`, e.g. `403 user_deactivated` |
106
-
107
- Every response is JSON with `Cache-Control: no-store`.
108
-
109
- ## The callbacks
110
-
111
- Each callback gets a second argument, `{ type, environment, requestId }`.
112
- Quote the `requestId` to Relume support. `environment` is always your secret
113
- key's (`'test'` for `sk_test_…`, `'live'` for `sk_live_…`): every call Relume
114
- signs says which environment it is for, and one for the other environment is
115
- refused before a callback runs, so a test call can never move a live wallet.
116
- Still give test and live their own endpoint URLs, each with its own client.
63
+ - Relume's servers call it, so it must be reachable from the internet. On
64
+ `localhost`, use a tunnel such as `cloudflared tunnel --url http://localhost:3000`
65
+ or `ngrok http 3000`.
66
+ - Give test and live their own URLs, each served with that environment's
67
+ secret key. Live URLs must be `https`.
68
+ - The builder can't open for a new user until the endpoint answers: Relume
69
+ calls `user.get` before their first session.
70
+
71
+ ## Routes
72
+
73
+ | Route | Called by | Does |
74
+ | --------------------- | ------------- | ---------------------------------------------------------------------------------------------------------- |
75
+ | `GET /relume/session` | the embed SDK | Finds the user with `getUserIdFromRequest`, calls `POST /sessions`, returns `{ clientSecret, expiresAt }`. |
76
+ | `POST /relume/calls` | Relume | Verifies `Relume-Signature`, runs your callback for the call's `type`, checks what it returns. |
77
+
78
+ `relume.express()` answers `/session` and `/calls` under its mount path
79
+ (`/relume/session` and `/relume/calls` when mounted at the root), and passes
80
+ everything else on. `relume.fetchHandler` answers any path ending in
81
+ `/session` or `/calls`.
82
+
83
+ | Situation | Answer |
84
+ | ----------------------------------------------------------- | ------------------------------------------------------------ |
85
+ | Bad or missing signature, or a timestamp over 5 minutes off | `401 invalid_signature`; your callback isn't called |
86
+ | Unknown `type`, or no callback registered | `400 unsupported_call_type` |
87
+ | A body that isn't valid for its `type` | `400 invalid_request` |
88
+ | `environment` isn't your secret key's | `400 environment_mismatch`; your callback isn't called |
89
+ | `onGetUser` returns `null` | `404 user_not_found` |
90
+ | `onChargeCredits` returns `{ ok: false }` | `402 insufficient_credits`, with `balance` |
91
+ | A callback throws, or returns a bad shape | `500 internal_error`; `onHandlerError` gets the error |
92
+ | Session route: no `Relume-Embed: 1` header | `400 embed_header_missing` |
93
+ | Session route: `Sec-Fetch-Site: cross-site` | `403 cross_site_request` |
94
+ | Session route: nobody signed in | `401 not_signed_in` |
95
+ | Session route: Relume unreachable or timed out | `502 session_failed`, or `504` on a timeout |
96
+ | Session route: `POST /sessions` refused | Relume's status and `{ error }`, e.g. `403 user_deactivated` |
97
+
98
+ CORS preflights are left to your app: `express()` passes them to the next
99
+ middleware, and `fetchHandler` answers `204` with no CORS headers, so put your
100
+ CORS handling in front of it. If your page is on another origin from this
101
+ backend, pass `sessionUrl` to the embed SDK, and allow your page's exact
102
+ origin, with credentials and the `Relume-Embed` header, on the session route.
103
+ Never answer with `*` and never echo the request's `Origin`.
104
+
105
+ ## Callbacks
106
+
107
+ - **`getUserIdFromRequest(request) → userId | null`**: read the user from your
108
+ own session. Never take a user id from the query string or body. Pass your
109
+ framework's request type to `Relume.init<…>` to type `request`.
110
+
111
+ The four `on…` callbacks below answer Relume's calls. Each gets a second
112
+ argument, `{ type, environment, requestId }`. `environment` is always your
113
+ secret key's: `'test'` or `'live'`. Quote `requestId` to Relume support.
114
+
115
+ - **`onGetUser(userId) → { name?, email?, avatarUrl? } | null`**: `name` at
116
+ most 200 characters, `email` a valid address, `avatarUrl` an `https` URL.
117
+ `null` for a user you don't know.
118
+ - **`onGetCredits(userId) → { balance }`**: a whole number of your own
119
+ credits, zero or more.
117
120
 
118
121
  ### `onChargeCredits(charge) → { ok, balance? }`
119
122
 
120
- - **Debit `partnerCredits` only.** `relumeCredits` is what Relume charges you
121
- for the action, for your records. Never debit it from the user's wallet.
123
+ - **Debit `partnerCredits` only.** `relumeCredits` is what Relume charges
124
+ you. Never debit it from the user.
122
125
  - **Idempotent on `chargeId`.** Relume retries a charge with the same
123
126
  `chargeId`. Debit at most once, and answer a repeat with the same result.
124
- - Return `ok: false` when the user can't afford it; the builder asks them to
125
- top up.
126
- - Throwing (or timing out) makes the outcome unknown. Relume then never does
127
- the work and always sends `credits.reverse` for that `chargeId`.
127
+ - Return `ok: false` when the user can't afford it. The builder then asks
128
+ them to top up.
129
+ - If you throw or time out, Relume doesn't do the work, and sends a reversal
130
+ for that `chargeId`.
131
+ - A build of several pages is one charge, with `pageIds` instead of `pageId`
132
+ and both prices covering every page. Debit it like any other.
128
133
 
129
134
  ### `onReverseCharge(reversal) → { balance? } | void`
130
135
 
131
- - **Credit back `partnerCredits` only**, at most once per `reversalId`.
132
- - If `chargeId` names a charge you never debited (one that timed out before
133
- reaching your wallet), credit nothing and return normally, and record that
134
- `chargeId` as declined: an attempt can still reach your wallet after the
135
- reversal (if your wallet stalled and caught up), and `onChargeCredits` must
136
- then return `ok: false` for it without debiting. Recording it as a declined
137
- charge lets your idempotency on `chargeId` do that.
136
+ - **Credit back the reversal's `partnerCredits` only**, at most once per
137
+ `reversalId`. A reversal can cover part of a charge.
138
+ - **If `chargeId` names a charge you never debited**, credit nothing, return
139
+ normally, and record that `chargeId` as declined. If the charge reaches you
140
+ later, `onChargeCredits` must return `ok: false` without debiting.
138
141
  - `chargeId` is absent for a staff refund. `reason` is `work_failed`,
139
142
  `charge_unknown` or `staff_refund`; accept new values.
140
143
  - Accept reversals for users you have since deactivated.
141
144
 
142
- ### `onGetUser(userId) → { name?, email?, avatarUrl? } | null`
143
-
144
- `name` at most 200 characters, `email` a valid address, `avatarUrl` an
145
- `https` URL. Return `null` for an unknown user.
146
-
147
- ### `onGetCredits(userId) → { balance }`
148
-
149
- A whole number of your own credits, zero or more.
150
-
151
- ### `getUserIdFromRequest(request) → userId | null`
152
-
153
- Read the user from your own session. Never take a user id from the query
154
- string or body. Gets the Express/Node request for `express()`, or the
155
- `Request` for `fetchHandler`. Pass that type to `Relume.init<…>` to type
156
- `req`; it must have `headers` (`SessionRequest`), which every framework's
157
- request does, and is `{ headers: unknown }` otherwise.
158
-
159
145
  ## Other frameworks
160
146
 
161
147
  `relume.fetchHandler` is a `(Request) => Promise<Response>`.
@@ -175,30 +161,36 @@ export const POST = relume.fetchHandler;
175
161
  app.all('/relume/*', (c) => relume.fetchHandler(c.req.raw));
176
162
  ```
177
163
 
178
- **Cloudflare Workers:**
164
+ **Cloudflare Workers:** a Worker reads its secrets from `env` on each request,
165
+ so create the client there (`Relume.init` is cheap and holds no connection):
179
166
 
180
167
  ```ts
181
- export default { fetch: (request: Request) => relume.fetchHandler(request) };
168
+ export default {
169
+ fetch(request: Request, env: Env) {
170
+ const relume = Relume.init<Request>({ secretKey: env.RELUME_SECRET_KEY });
171
+ // …register getUserIdFromRequest and the four callbacks…
172
+ return relume.fetchHandler(request);
173
+ },
174
+ };
182
175
  ```
183
176
 
184
177
  **Anything else:** call `relume.handleCall({ rawBody, signatureHeader, requestId })`
185
178
  and `relume.handleSession(request)`. Each returns `{ status, headers, body }`.
186
- `handleSession` reads the request's headers, so pass it the fetch `Request`,
187
- the Node request, or an object with `headers`.
188
-
189
- ### Raw bodies
179
+ Pass `handleSession` the fetch `Request`, the Node request, or an object with
180
+ `headers`.
190
181
 
191
- The signature covers the raw bytes, so a re-serialised body never verifies.
192
- Mount `relume.express()` before `express.json()`, or give it
193
- `express.raw({ type: 'application/json' })`. If a parser already consumed the
194
- body, the call is answered `500` and `onHandlerError` explains why.
182
+ **Raw bodies:** the signature covers the raw bytes, so a parsed body never
183
+ verifies. Mount `relume.express()` before `express.json()`, or give it
184
+ `express.raw({ type: 'application/json' })`. Add nothing that parses the
185
+ body in front of `fetchHandler`. If a parser got there first, the call is
186
+ answered `500` and `onHandlerError` says why.
195
187
 
196
188
  ## Options
197
189
 
198
190
  ```ts
199
191
  Relume.init({
200
192
  secretKey: 'sk_test_…', // required; sk_test_… or sk_live_…
201
- apiBaseUrl: 'https://…', // defaults to production; http only for localhost
193
+ apiBaseUrl: 'https://…', // default: production's; http only for localhost
202
194
  apiTimeoutMs: 15_000, // per partner API request attempt
203
195
  maxRetries: 2, // retries for 429s, 5xx answers and failed connections
204
196
  fetch, // custom fetch for partner API requests
@@ -206,16 +198,13 @@ Relume.init({
206
198
  });
207
199
  ```
208
200
 
209
- `Relume.init` returns a new client every time, with no global state, so test
210
- and live clients can run side by side. It throws for an `apiBaseUrl` that
211
- isn't `https`, except `http://localhost`, `127.0.0.1` and `[::1]`: every request
212
- carries your secret key.
201
+ `Relume.init` throws for a missing key, or one that isn't `sk_test_…` or
202
+ `sk_live_…`. It returns a new client every time, so test and live clients can
203
+ run side by side.
213
204
 
214
205
  ## Calling the partner API
215
206
 
216
- Every partner API endpoint has a typed method. Each throws a `RelumeError`
217
- when the API refuses, with `status`, `code` (safe to switch on) and
218
- `requestId` (quote it to Relume support).
207
+ Every partner API endpoint has a typed method.
219
208
 
220
209
  ```ts
221
210
  const { clientSecret } = await relume.sessions.create({ userId: '42' });
@@ -226,12 +215,26 @@ await relume.users.reactivate('42');
226
215
 
227
216
  const { siteId } = await relume.sites.create('42', { name: 'Bloom Florist' });
228
217
  const site = await relume.sites.get(siteId);
229
- const exported = await relume.exports.get(siteId, exportId); // not available yet: not_implemented
218
+ const exported = await relume.exports.get(siteId, exportId); // not available yet: 501 not_implemented
230
219
 
231
220
  const entry = await relume.ledger.get('ch_…'); // with partnerResponse
232
- const prices = await relume.prices.list();
221
+
222
+ const { data, creditUnit } = await relume.prices.get();
223
+ // data: [{ action, partnerCredits (null until Relume sets it), relumeCredits }]
224
+ // creditUnit: { singular: 'coin', plural: 'coins', iconUrl? }
233
225
  ```
234
226
 
227
+ - `prices.get()` returns your prices and `creditUnit`: what the builder calls
228
+ your credits, and the icon it shows beside amounts. `prices.list()` returns
229
+ the prices alone.
230
+ - A user who has never opened the builder has no Relume account, so
231
+ `sites.list` and `sites.create` answer `user_not_found`. Before creating a
232
+ site for them, call `sessions.create({ userId })`: it creates the account.
233
+ - `sites.create` isn't idempotent, so it retries only a `429`.
234
+
235
+ Each method throws a `RelumeError` when the API refuses, with `status`,
236
+ `code` (safe to switch on) and `requestId` (quote it to Relume support):
237
+
235
238
  ```ts
236
239
  try {
237
240
  await relume.users.reactivate('42');
@@ -244,18 +247,12 @@ try {
244
247
 
245
248
  ### Lists
246
249
 
247
- `sites.list`, `ledger.list` and `usage.get` follow the cursor for you, a page
248
- at a time as you iterate:
249
-
250
- ```ts
251
- for await (const site of relume.sites.list('42')) {
252
- console.log(site.name);
253
- }
254
- ```
250
+ `sites.list`, `ledger.list` and `usage.get` follow the cursor for you: iterate
251
+ items with `for await`, pages with `.pages()`, or collect them with
252
+ `.toArray()`.
255
253
 
256
254
  To reconcile your wallet, poll the ledger from the cursor you stored last
257
- time. Entries come in change order and appear again when their status
258
- changes, so upsert on `id`:
255
+ time. An entry appears again when its status changes, so upsert on `id`:
259
256
 
260
257
  ```ts
261
258
  const entries = relume.ledger.list({ after: await loadCursor() });
@@ -265,113 +262,54 @@ for await (const page of entries.pages()) {
265
262
  }
266
263
  ```
267
264
 
268
- `entries.nextCursor` is also there after a plain `for await`: the cursor of
269
- the last page you finished, so breaking out early never skips an entry.
270
- Site lists can't be polled this way (new sites sort to the front): list them
271
- from the beginning instead.
272
-
273
- ```ts
274
- const rows = await relume.usage
275
- .get({ from: '2026-10-01', to: '2026-11-01', groupBy: ['user'] })
276
- .toArray(); // [{ userId, partnerCredits: { net, … }, relumeCredits: { net, … } }]
277
- ```
265
+ After a plain `for await`, `entries.nextCursor` is the cursor of the last
266
+ page you finished, so breaking out early never skips an entry. Site lists
267
+ can't be polled this way, because new sites sort to the front: list them from
268
+ the beginning each time.
278
269
 
279
270
  ### Retries
280
271
 
281
- A `429` is retried after its `Retry-After` (unless that's over a minute).
282
- A `5xx` (other than `501` and `505`) or a failed connection is retried with
283
- exponential backoff, except on
284
- `sites.create`, which isn't idempotent: a retry could create a second site.
285
- A `Retry-After` on a `5xx` is honoured up to 8 seconds.
286
- `maxRetries` (default 2) sets how many retries follow the first attempt.
287
- The session route makes one attempt, without retries, because a browser is
288
- waiting on it.
289
-
290
- The request and response types (`User`, `Site`, `LedgerEntry`, `UsageRow`, …)
291
- are exported, and the Relume API is type-checked against the same
292
- definitions, so they match what the API sends.
272
+ A `429` is retried after its `Retry-After`, unless that's over a minute. A
273
+ `5xx` (except `501` and `505`) or a failed connection is retried with
274
+ exponential backoff. `maxRetries` (default 2) sets how many retries follow the
275
+ first attempt. The session route makes one attempt, because a browser is
276
+ waiting.
293
277
 
294
278
  ## Checking your endpoint
295
279
 
296
- Before going live, run the conformance checker against your test endpoint.
297
- It works on any endpoint, whether or not it uses this SDK:
280
+ Before going live, run the conformance checker against your test endpoint. It
281
+ works on any endpoint, whether or not it uses this SDK:
298
282
 
299
283
  ```sh
300
284
  RELUME_SECRET_KEY=sk_test_… npx @relume_io/partner-node check \
301
285
  --url https://partner.example/relume/calls --user 42
302
286
  ```
303
287
 
304
- It calls your endpoint the way Relume does, and checks that it:
305
-
306
- - accepts a correctly signed call, including a rotation header with two
307
- `v1=` values;
308
- - rejects another key's signature, a body changed after signing, a timestamp
309
- more than 5 minutes off either way, and a call with no signature, with
310
- `401`;
311
- - refuses a correctly signed call whose `environment` isn't the key's, with
312
- `400`;
313
- - answers `user.get` and `credits.get` with bodies Relume accepts, and
314
- `user.get` with `404` for a user id it has never seen;
315
- - debits a charge, and only once when the same `chargeId` comes again, with
316
- the same answer as the first time;
317
- - answers `402` to a charge the user can't afford;
318
- - changes nothing when a charge it never debited is reversed, and debits
319
- nothing if that charge arrives afterwards (record a reversed `chargeId` as
320
- declined, see `onReverseCharge` above);
321
- - credits a reversal, and only once per `reversalId`;
322
- - doesn't let another site read the session route: it asks for a session from
323
- `https://evil.example`, preflight and `GET`, and fails if the CORS response
324
- grants that origin;
325
- - refuses a session request without the `Relume-Embed` header with `400`, and
326
- one with `Sec-Fetch-Site: cross-site` with `403`, before looking for a
327
- signed-in user.
328
-
329
- It finds the session route next to a `--url` ending in `/calls`; pass
330
- `--session-url` if yours is elsewhere. If a login answers that route before
331
- the SDK does, give the check a test user's cookie so it gets past it: in
332
- `RELUME_SESSION_HEADERS` (one `Name: value` a line), or with
333
- `--session-header "Cookie: …"` (repeatable). Prefer the variable: a cookie on
334
- the command line leaks like a key does.
335
-
336
- Give it the key in `RELUME_SECRET_KEY`, not `--secret-key`: a key on the
337
- command line lands in your shell history, CI logs and the process list.
338
- `--user` must be a user your wallet knows, holding at least 2 credits. The
339
- check charges them 2 and reverses everything it charged, and it prints a
340
- `NOTE` if the balance doesn't end where it started. Each check prints `PASS`,
341
- `FAIL` with the reason, or `SKIP` when an earlier failure means it can't run.
342
- It exits `0` only when every check passes, so you can run it in CI. A live key
343
- is refused unless you pass `--live`, because the check moves real credits.
344
-
345
- Inside this repo, `npm run check -- --url … --user …` runs it from source.
288
+ It calls your endpoint the way Relume does, and checks signatures,
289
+ environments, idempotency on `chargeId` and `reversalId`, the
290
+ unknown-charge rule and the session route's refusals. The
291
+ [partner docs](https://apis.relume.io/nova/docs/partners#tag/checking-your-endpoint)
292
+ list every check.
293
+
294
+ - `--user` must be a user your wallet knows, holding at least 2 credits. The
295
+ check charges them 2 and reverses everything it charged.
296
+ - Pass the key in `RELUME_SECRET_KEY`, not `--secret-key`: a key on the
297
+ command line lands in shell history and CI logs.
298
+ - It finds the session route next to a `--url` ending in `/calls`; otherwise
299
+ pass `--session-url`. If a login guards that route, give the check a test
300
+ user's cookie in `RELUME_SESSION_HEADERS` (one `Name: value` a line) or with
301
+ `--session-header "Cookie: …"` (repeatable). Prefer the variable: a cookie
302
+ on the command line leaks like a key does.
303
+ - It exits `0` only when every check passes, so you can run it in CI. A live
304
+ key is refused unless you pass `--live`, because the check moves real
305
+ credits.
346
306
 
347
307
  ## Signing helpers
348
308
 
349
309
  For your own tests, or another runtime: `verifySignature`, `signCall`,
350
310
  `parseSignatureHeader` and `computeSignature`. They pass the protocol's
351
- [test vectors](../../docs/partner-protocol/test-vectors.json).
352
-
353
- ## Examples
354
-
355
- [`examples/`](./examples) has the same partner on Express and on Hono, with
356
- an in-memory wallet that shows the idempotency rules:
357
-
358
- ```sh
359
- RELUME_SECRET_KEY=sk_test_… npm run example:express # :4600
360
- RELUME_SECRET_KEY=sk_test_… npm run example:fetch # :4601
361
- ```
362
-
363
- ## Developing
364
-
365
- ```sh
366
- npm ci
367
- npm run typecheck
368
- npm test # unit, vectors and both sample servers
369
- npm run test:dist # builds, then loads the ESM and CJS output
370
- ```
371
-
372
- Releases go out from CI on a `partner-sdk/v*` tag, together with
373
- `@relume_io/partner-embed`: see [Releasing](../README.md#releasing).
311
+ [test vectors](https://apis.relume.io/nova/docs/partners/test-vectors.json).
374
312
 
375
313
  ## License
376
314
 
377
- MIT. See [LICENSE](./LICENSE).
315
+ MIT