@edpire/sdk 0.6.11 โ†’ 0.7.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
@@ -1,445 +1,452 @@
1
- # @edpire/sdk
2
-
3
- Embeddable assessment SDK for Edpire. Deliver, render, and grade assessments from your own application.
4
-
5
- > ๐Ÿ“š **Full documentation** lives in the Edpire docs site under [**SDK Reference**](https://docs.edpire.com/developer/sdk/overview) โ€” complete option/prop/type tables for the Embedded Player, React components, Custom Flow, server client, and branding. This README is a condensed quick-start.
6
-
7
- ---
8
-
9
- ## Which approach fits you?
10
-
11
- | Approach | Learner stays on your page? | You build the UI? | Entry point |
12
- |---|---|---|---|
13
- | **[Hosted Redirect](#hosted-redirect)** | No | No | Share URL only |
14
- | **[Embedded Player](#embedded-player)** | Yes | No | `EdpireAssessment.mount()` |
15
- | **[Custom Flow](#custom-flow)** | Yes | Yes | `renderQuestion` + `flattenAssessment` |
16
-
17
- > **Server Client** (`EdpireClient`) is a backend utility used alongside any of the above โ€” not a separate approach. [Jump to Server Client โ†’](#server-client)
18
-
19
- ---
20
-
21
- ## Installation
22
-
23
- Published to the public npm registry โ€” no auth token required.
24
-
25
- ```bash
26
- npm install @edpire/sdk
27
- # or
28
- pnpm add @edpire/sdk
29
- ```
30
-
31
- > **License:** the package is free to install but carries a proprietary, integrate-with-Edpire-only license (see `LICENSE`). It is safe to make public because the bundle is only the question renderer โ€” answer keys, grading, and API keys never ship in it; they stay on the server.
32
-
33
- ---
34
-
35
- ## Hosted Redirect
36
-
37
- The simplest integration โ€” no SDK required on the frontend. Redirect learners to Edpire's hosted assessment UI. After completion, Edpire redirects back to your `return_url` with results.
38
-
39
- ```
40
- https://{your-slug}.edpire.com/take/{shareCode}?learner_ref={userId}&return_url={yourUrl}
41
- ```
42
-
43
- | Parameter | Required | Description |
44
- |-----------|----------|-------------|
45
- | `shareCode` | Yes | Assessment share code from the dashboard |
46
- | `learner_ref` | Recommended | Your stable internal user ID |
47
- | `return_url` | Recommended | Where to send the learner after completion |
48
-
49
- Edpire appends `?submission_id=...&score=...&max_score=...` to the return URL.
50
-
51
- **When to use:** Fast adoption, no custom UI needed, comfortable with a browser redirect.
52
-
53
- ---
54
-
55
- ## Embedded Player
56
-
57
- Mount Edpire's full assessment player inline inside your page. The SDK fetches the assessment, handles all interactions, grades the submission, and shows per-question feedback โ€” all automatically. The learner never leaves your app.
58
-
59
- **Server (Node.js / API route):**
60
-
61
- ```typescript
62
- import { EdpireClient } from "@edpire/sdk/client"
63
-
64
- const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
65
-
66
- // Mint a short-lived single-use token for this learner + assessment
67
- const { token } = await client.mintEmbedToken(assessmentId, userId)
68
- // Pass `token` to the browser (e.g. via server-rendered props or an API response)
69
- ```
70
-
71
- **Browser:**
72
-
73
- ```typescript
74
- import { EdpireAssessment } from "@edpire/sdk"
75
-
76
- const embed = EdpireAssessment.mount({
77
- token, // from your server
78
- container: "#assessment-root", // CSS selector or HTMLElement
79
- onComplete: (result) => {
80
- console.log(`Score: ${result.score}/${result.max_score}`)
81
- console.log(`Passed: ${result.passed}`)
82
- },
83
- onError: (err) => console.error(err.message),
84
- })
85
-
86
- // Later, when done:
87
- embed.unmount()
88
- ```
89
-
90
- **CDN (no bundler):**
91
-
92
- ```html
93
- <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.6.9/dist/umd/index.global.js"></script>
94
- <script>
95
- EdpireSDK.EdpireAssessment.mount({ token, container: "#root", onComplete: handleDone })
96
- </script>
97
- ```
98
-
99
- > The UMD build is served straight from npm by [jsDelivr](https://www.jsdelivr.com/) (and [unpkg](https://unpkg.com/)) โ€” no CDN to run yourself. Pin a version for production; `@latest` works for prototyping.
100
-
101
- **When to use:** You want the full assessment experience inside your page with a single function call. Zero UI code required.
102
-
103
- ---
104
-
105
- ## Custom Flow
106
-
107
- Build your own question-by-question UX โ€” Duolingo-style flows, flashcard drills, practice modes, custom navigation. The SDK handles question rendering and answer collection; you own everything else.
108
-
109
- **How it works:**
110
- 1. Your server fetches the assessment (keeping your API key server-side)
111
- 2. `flattenAssessment()` converts it into a flat array of question steps
112
- 3. `renderQuestion()` (or `<EdpireQuestion>` in React) renders each step into a container you control
113
- 4. Your server grades each answer via the `/check` endpoint
114
- 5. After all questions, your server submits the full attempt
115
-
116
- **Server โ€” fetch the assessment:**
117
-
118
- ```typescript
119
- // GET /api/edpire/assessment/[id]/route.ts
120
- import { EdpireClient } from "@edpire/sdk/client"
121
-
122
- const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
123
- const assessment = await client.getAssessment(assessmentId)
124
- return NextResponse.json({ data: assessment })
125
- ```
126
-
127
- **Browser โ€” flatten and render:**
128
-
129
- ```tsx
130
- // React
131
- import { flattenAssessment } from "@edpire/sdk"
132
- import { EdpireQuestion } from "@edpire/sdk/react"
133
- import type { RuntimeAnswer } from "@edpire/sdk"
134
-
135
- const steps = flattenAssessment(assessment)
136
- // steps[i] = { exerciseId, questionId, content, points, sequenceNumber, index }
137
-
138
- const [currentIndex, setCurrentIndex] = useState(0)
139
- const [answers, setAnswers] = useState<RuntimeAnswer[]>([])
140
- const [feedback, setFeedback] = useState(null)
141
- const step = steps[currentIndex]
142
-
143
- <EdpireQuestion
144
- content={step.content}
145
- onAnswersChange={setAnswers}
146
- feedback={feedback} // null = no feedback shown; pass result of /check to show it
147
- dir="ltr" // or "rtl" for Arabic
148
- />
149
- ```
150
-
151
- ```typescript
152
- // Vanilla JS
153
- import { flattenAssessment, renderQuestion } from "@edpire/sdk"
154
-
155
- const steps = flattenAssessment(assessment)
156
- const step = steps[currentIndex]
157
-
158
- const question = renderQuestion({
159
- container: document.getElementById("question-root"),
160
- content: step.content,
161
- onAnswersChange: (answers) => { /* store answers */ },
162
- })
163
-
164
- // Later, update feedback:
165
- question.setFeedback(feedbackFromCheckEndpoint)
166
-
167
- // Move to next question:
168
- question.setContent(steps[nextIndex].content)
169
- ```
170
-
171
- **Server โ€” grade each question via your proxy:**
172
-
173
- ```typescript
174
- // POST /api/edpire/check/[id]/route.ts (recommended โ€” type-safe, handles URL construction)
175
- import { EdpireClient } from "@edpire/sdk/client"
176
-
177
- const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
178
-
179
- export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
180
- const { id } = await params
181
- const body = await req.json()
182
- // Always set include_correct_answers server-side โ€” never trust the client to send it
183
- const result = await client.checkQuestion(id, { ...body, include_correct_answers: true })
184
- return Response.json(result) // result: CheckResult = { correct, score, max_score, feedback }
185
- }
186
- ```
187
-
188
- <details>
189
- <summary>Alternative โ€” raw fetch without EdpireClient</summary>
190
-
191
- ```typescript
192
- // Note: assessmentId goes in the URL path, not the request body
193
- const result = await fetch(`https://edpire.com/api/v1/assessments/${assessmentId}/check`, {
194
- method: "POST",
195
- headers: {
196
- Authorization: `Bearer ${process.env.EDPIRE_API_KEY}`,
197
- "Content-Type": "application/json",
198
- },
199
- body: JSON.stringify({
200
- exercise_id: exerciseId,
201
- question_id: questionId,
202
- answers,
203
- learner_ref: userId,
204
- session_id: sessionId,
205
- include_correct_answers: true,
206
- }),
207
- }).then((r) => r.json())
208
- // Response envelope: { data: CheckResult, error: null, meta: null }
209
- ```
210
-
211
- </details>
212
-
213
- **Server โ€” submit the full attempt when done:**
214
-
215
- ```typescript
216
- import { EdpireClient } from "@edpire/sdk/client"
217
- import type { StoredAnswer } from "@edpire/sdk"
218
-
219
- const stored: StoredAnswer[] = []
220
- // push { exerciseId, questionId, answers } for each step as the learner answers
221
-
222
- const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
223
- const result = await client.submit(assessmentId, {
224
- learner_ref: userId,
225
- answers: stored, // StoredAnswer[] โ€” the client handles nesting automatically
226
- })
227
- console.log(`Final score: ${result.score}/${result.max_score}`)
228
- ```
229
-
230
- <details>
231
- <summary>Rate limiting on /check</summary>
232
-
233
- Each `(session_id, question_id)` pair is limited to 3 checks per hour (configurable per org).
234
-
235
- **Always pass a `session_id`** โ€” generate a UUID once at the start of each attempt:
236
- ```typescript
237
- const sessionId = crypto.randomUUID() // generate once per attempt, not per question
238
- ```
239
- If omitted, rate limiting falls back to `learner_ref`, which accumulates across all of a learner's attempts and can cause unexpected 429s on repeat attempts.
240
-
241
- </details>
242
-
243
- **Required API key scopes:** `read:assessments` (for fetching and checking) + `write:submissions` (for the final submit).
244
-
245
- **When to use:** You want full control over pacing, navigation, animations, or scoring UX. You're building a Duolingo-style flow, a timed drill, a practice mode, or any experience that goes beyond a linear "answer all โ†’ submit" form.
246
-
247
- ---
248
-
249
- ## Server Client
250
-
251
- `EdpireClient` is a backend-only utility for calling the Edpire REST API from your server. It is not a rendering pattern โ€” it is used alongside Embedded Player and Custom Flow.
252
-
253
- ```typescript
254
- import { EdpireClient } from "@edpire/sdk/client"
255
-
256
- const client = new EdpireClient({
257
- apiKey: process.env.EDPIRE_API_KEY!,
258
- baseUrl: "https://edpire.com", // optional, defaults to edpire.com
259
- })
260
- ```
261
-
262
- **Common operations:**
263
-
264
- ```typescript
265
- // List published assessments
266
- const { items } = await client.getAssessments({ status: "published" })
267
-
268
- // Fetch a single assessment with full content
269
- const assessment = await client.getAssessment(assessmentId)
270
-
271
- // Mint an embed token (for Embedded Player)
272
- const { token } = await client.mintEmbedToken(assessmentId, learnerRef)
273
-
274
- // Submit a full attempt (for Custom Flow)
275
- const result = await client.submit(assessmentId, { learner_ref: userId, answers: stored })
276
-
277
- // Grade a single question without recording (for Custom Flow /check)
278
- const check = await client.checkQuestion(assessmentId, {
279
- exercise_id, question_id, answers, learner_ref, session_id,
280
- include_correct_answers: true,
281
- })
282
-
283
- // Register a webhook
284
- const webhook = await client.registerWebhook("https://yourapp.com/webhooks/edpire", [
285
- "submission.graded",
286
- ])
287
- // Store webhook.secret securely โ€” shown only once
288
- ```
289
-
290
- ---
291
-
292
- ## Mobile & WebView integration
293
-
294
- Flutter, React Native, and native iOS/Android apps cannot import this npm package directly.
295
- The integration uses two parts:
296
-
297
- 1. **Your backend** mints an embed token via `client.mintEmbedToken()` (the API key stays server-side).
298
- 2. **A WebView** in your mobile app loads a minimal HTML page that calls the CDN UMD build with the token.
299
-
300
- > The UMD build is served from npm via [jsDelivr](https://www.jsdelivr.com/) โ€” no CDN to host yourself.
301
-
302
- **Universal WebView HTML template:**
303
-
304
- ```html
305
- <!DOCTYPE html>
306
- <html>
307
- <head>
308
- <meta name="viewport" content="width=device-width, initial-scale=1" />
309
- <style>* { margin: 0; padding: 0; box-sizing: border-box; } #root { min-height: 100vh; }</style>
310
- </head>
311
- <body>
312
- <div id="root"></div>
313
- <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.6.9/dist/umd/index.global.js"></script>
314
- <script>
315
- EdpireSDK.EdpireAssessment.mount({
316
- token: "{{EMBED_TOKEN}}", // replace server-side before loading
317
- container: "#root",
318
- onComplete: function(r) {
319
- if (window.ReactNativeWebView)
320
- window.ReactNativeWebView.postMessage(JSON.stringify({ type: "complete", result: r }))
321
- if (window.flutter_inappwebview)
322
- window.flutter_inappwebview.callHandler("onComplete", r)
323
- },
324
- onError: function(e) {
325
- if (window.ReactNativeWebView)
326
- window.ReactNativeWebView.postMessage(JSON.stringify({ type: "error", error: e }))
327
- if (window.flutter_inappwebview)
328
- window.flutter_inappwebview.callHandler("onError", e)
329
- }
330
- })
331
- </script>
332
- </body>
333
- </html>
334
- ```
335
-
336
- > **Ionic / Capacitor** apps run in a WebView โ€” you can use the full npm SDK directly. No HTML template needed.
337
-
338
- For React Native, Flutter, and native iOS/Android code examples, see the [Mobile Integration guide](https://docs.edpire.com/developer/mobile).
339
-
340
- ---
341
-
342
- ## CSS
343
-
344
- **Embedded Player** (`EdpireAssessment.mount()`) โ€” all CSS is injected automatically. No imports needed.
345
-
346
- **Custom Flow** (`EdpireQuestion` / `renderQuestion`) โ€” styles are bundled into the SDK's JavaScript and injected when the component first renders. In most setups this is also automatic. If styles appear missing (e.g. in a sandboxed iframe or a strict CSP environment that blocks inline styles), import the utility stylesheet explicitly:
347
-
348
- ```typescript
349
- import "@edpire/sdk/styles/runtime-utilities.css"
350
- ```
351
-
352
- ---
353
-
354
- ## TypeScript types
355
-
356
- All commonly needed types are available from the main entry:
357
-
358
- ```typescript
359
- import type {
360
- // Answer types
361
- RuntimeAnswer,
362
- ChoiceSetAnswer,
363
- TypedBlankAnswer,
364
- BlankChoiceAnswer,
365
- MatchingSetAnswer,
366
- MatchingConnection,
367
- OpenResponseAnswer,
368
-
369
- // Custom Flow helpers
370
- StoredAnswer, // { exerciseId, questionId, answers: RuntimeAnswer[] }
371
- FlatStep, // one step from flattenAssessment()
372
-
373
- // Embedded Player
374
- MountOptions,
375
- EmbedInstance,
376
- EmbedResult,
377
- EmbedError,
378
- } from "@edpire/sdk"
379
- ```
380
-
381
- Types specific to the Server Client come from `@edpire/sdk/client`:
382
-
383
- ```typescript
384
- import type { Assessment, GradeResult, CheckResult, EdpireClientOptions } from "@edpire/sdk/client"
385
- ```
386
-
387
- ---
388
-
389
- ## Webhooks
390
-
391
- ```typescript
392
- const webhook = await client.registerWebhook("https://yourapp.com/api/webhooks/edpire", [
393
- "submission.graded",
394
- "assessment.published",
395
- ])
396
- // Store webhook.secret securely โ€” it is only shown once.
397
- ```
398
-
399
- **Verify incoming signatures:**
400
-
401
- ```typescript
402
- import { createHmac, timingSafeEqual } from "crypto"
403
-
404
- export async function POST(req: Request) {
405
- const rawBody = await req.text()
406
- const signature = req.headers.get("x-edpire-signature") ?? ""
407
-
408
- const expected = "sha256=" + createHmac("sha256", process.env.EDPIRE_WEBHOOK_SECRET!)
409
- .update(rawBody)
410
- .digest("hex")
411
-
412
- // Length check first: timingSafeEqual throws RangeError on unequal lengths,
413
- // so a truncated or missing header would crash the route instead of 401ing.
414
- const sigBuf = Buffer.from(signature)
415
- const expBuf = Buffer.from(expected)
416
- if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
417
- return new Response("Unauthorized", { status: 401 })
418
- }
419
-
420
- const event = JSON.parse(rawBody)
421
- // event.event, event.submission_id, event.score, event.passed, ...
422
- }
423
- ```
424
-
425
- **Available events:** `submission.graded`, `submission.grading.completed`, `assessment.published`, `assessment.updated`, `assessment.content_updated`, `assessment.archived`, and the `collection.*` events. Full list and payloads: [Webhooks](https://docs.edpire.com/developer/webhooks).
426
-
427
- ---
428
-
429
- ## Error handling
430
-
431
- `EdpireClient` throws `EdpireError` on non-2xx responses:
432
-
433
- ```typescript
434
- import { EdpireError } from "@edpire/sdk/client"
435
-
436
- try {
437
- await client.submit(assessmentId, options)
438
- } catch (err) {
439
- if (err instanceof EdpireError) {
440
- console.error(err.status, err.message)
441
- }
442
- }
443
- ```
444
-
445
- Common status codes: `400` bad request ยท `401` invalid API key ยท `404` not found ยท `409` max attempts reached ยท `422` grading failed ยท `429` rate limit exceeded
1
+ # @edpire/sdk
2
+
3
+ Embeddable assessment SDK for Edpire. Deliver, render, and grade assessments from your own application.
4
+
5
+ > ๐Ÿ“š **Full documentation** lives in the Edpire docs site under [**SDK Reference**](https://docs.edpire.com/developer/sdk/overview) โ€” complete option/prop/type tables for the Embedded Player, React components, Custom Flow, server client, and branding. This README is a condensed quick-start.
6
+
7
+ ---
8
+
9
+ ## Which approach fits you?
10
+
11
+ | Approach | Learner stays on your page? | You build the UI? | Entry point |
12
+ |---|---|---|---|
13
+ | **[Hosted Redirect](#hosted-redirect)** | No | No | Share URL only |
14
+ | **[Embedded Player](#embedded-player)** | Yes | No | `EdpireAssessment.mount()` |
15
+ | **[Custom Flow](#custom-flow)** | Yes | Yes | `renderQuestion` + `flattenAssessment` |
16
+
17
+ > **Server Client** (`EdpireClient`) is a backend utility used alongside any of the above โ€” not a separate approach. [Jump to Server Client โ†’](#server-client)
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ Published to the public npm registry โ€” no auth token required.
24
+
25
+ ```bash
26
+ npm install @edpire/sdk
27
+ # or
28
+ pnpm add @edpire/sdk
29
+ ```
30
+
31
+ > **License:** the package is free to install but carries a proprietary, integrate-with-Edpire-only license (see `LICENSE`). It is safe to make public because the bundle is only the question renderer โ€” answer keys, grading, and API keys never ship in it; they stay on the server.
32
+
33
+ ---
34
+
35
+ ## Hosted Redirect
36
+
37
+ The simplest integration โ€” no SDK required on the frontend. Redirect learners to Edpire's hosted assessment UI. After completion, Edpire redirects back to your `return_url` with results.
38
+
39
+ ```
40
+ https://{your-slug}.edpire.com/take/{shareCode}?learner_ref={userId}&return_url={yourUrl}
41
+ ```
42
+
43
+ | Parameter | Required | Description |
44
+ |-----------|----------|-------------|
45
+ | `shareCode` | Yes | Assessment share code from the dashboard |
46
+ | `learner_ref` | Recommended | Your stable internal user ID |
47
+ | `return_url` | Recommended | Where to send the learner after completion |
48
+
49
+ Edpire appends `?submission_id=...&score=...&max_score=...` to the return URL.
50
+
51
+ **When to use:** Fast adoption, no custom UI needed, comfortable with a browser redirect.
52
+
53
+ ---
54
+
55
+ ## Embedded Player
56
+
57
+ Mount Edpire's full assessment player inline inside your page. The SDK fetches the assessment, handles all interactions, grades the submission, and shows per-question feedback โ€” all automatically. The learner never leaves your app.
58
+
59
+ **Server (Node.js / API route):**
60
+
61
+ ```typescript
62
+ import { EdpireClient } from "@edpire/sdk/client"
63
+
64
+ const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
65
+
66
+ // Mint a short-lived single-use token for this learner + assessment
67
+ const { token } = await client.mintEmbedToken(assessmentId, userId)
68
+ // Pass `token` to the browser (e.g. via server-rendered props or an API response)
69
+ ```
70
+
71
+ **Browser:**
72
+
73
+ ```typescript
74
+ import { EdpireAssessment } from "@edpire/sdk"
75
+
76
+ const embed = EdpireAssessment.mount({
77
+ token, // from your server
78
+ container: "#assessment-root", // CSS selector or HTMLElement
79
+ onComplete: (result) => {
80
+ console.log(`Score: ${result.score}/${result.max_score}`)
81
+ console.log(`Passed: ${result.passed}`)
82
+ },
83
+ onError: (err) => console.error(err.message),
84
+ })
85
+
86
+ // Later, when done:
87
+ embed.unmount()
88
+ ```
89
+
90
+ **CDN (no bundler):**
91
+
92
+ ```html
93
+ <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.7.0/dist/umd/index.global.js"></script>
94
+ <script>
95
+ EdpireSDK.EdpireAssessment.mount({ token, container: "#root", onComplete: handleDone })
96
+ </script>
97
+ ```
98
+
99
+ > The UMD build is served straight from npm by [jsDelivr](https://www.jsdelivr.com/) (and [unpkg](https://unpkg.com/)) โ€” no CDN to run yourself. Pin a version for production; `@latest` works for prototyping.
100
+
101
+ **Sizing:** the player fills the container you give it, but inside that the questions sit in a centred 768px reading column. Keep it when the player owns the page; override it when the player is embedded in your own layout and your container already sets the width:
102
+
103
+ ```typescript
104
+ EdpireAssessment.mount({ token, container: "#root", layout: { maxWidth: "none" } })
105
+ ```
106
+
107
+ **When to use:** You want the full assessment experience inside your page with a single function call. Zero UI code required.
108
+
109
+ ---
110
+
111
+ ## Custom Flow
112
+
113
+ Build your own question-by-question UX โ€” Duolingo-style flows, flashcard drills, practice modes, custom navigation. The SDK handles question rendering and answer collection; you own everything else.
114
+
115
+ **How it works:**
116
+ 1. Your server fetches the assessment (keeping your API key server-side)
117
+ 2. `flattenAssessment()` converts it into a flat array of question steps
118
+ 3. `renderQuestion()` (or `<EdpireQuestion>` in React) renders each step into a container you control
119
+ 4. Your server grades each answer via the `/check` endpoint
120
+ 5. After all questions, your server submits the full attempt
121
+
122
+ **Server โ€” fetch the assessment:**
123
+
124
+ ```typescript
125
+ // GET /api/edpire/assessment/[id]/route.ts
126
+ import { EdpireClient } from "@edpire/sdk/client"
127
+
128
+ const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
129
+ const assessment = await client.getAssessment(assessmentId)
130
+ return NextResponse.json({ data: assessment })
131
+ ```
132
+
133
+ **Browser โ€” flatten and render:**
134
+
135
+ ```tsx
136
+ // React
137
+ import { flattenAssessment } from "@edpire/sdk"
138
+ import { EdpireQuestion } from "@edpire/sdk/react"
139
+ import type { RuntimeAnswer } from "@edpire/sdk"
140
+
141
+ const steps = flattenAssessment(assessment)
142
+ // steps[i] = { exerciseId, questionId, content, points, sequenceNumber, index }
143
+
144
+ const [currentIndex, setCurrentIndex] = useState(0)
145
+ const [answers, setAnswers] = useState<RuntimeAnswer[]>([])
146
+ const [feedback, setFeedback] = useState(null)
147
+ const step = steps[currentIndex]
148
+
149
+ <EdpireQuestion
150
+ content={step.content}
151
+ onAnswersChange={setAnswers}
152
+ feedback={feedback} // null = no feedback shown; pass result of /check to show it
153
+ dir="ltr" // or "rtl" for Arabic
154
+ />
155
+ ```
156
+
157
+ ```typescript
158
+ // Vanilla JS
159
+ import { flattenAssessment, renderQuestion } from "@edpire/sdk"
160
+
161
+ const steps = flattenAssessment(assessment)
162
+ const step = steps[currentIndex]
163
+
164
+ const question = renderQuestion({
165
+ container: document.getElementById("question-root"),
166
+ content: step.content,
167
+ onAnswersChange: (answers) => { /* store answers */ },
168
+ })
169
+
170
+ // Later, update feedback:
171
+ question.setFeedback(feedbackFromCheckEndpoint)
172
+
173
+ // Move to next question:
174
+ question.setContent(steps[nextIndex].content)
175
+ ```
176
+
177
+ **Server โ€” grade each question via your proxy:**
178
+
179
+ ```typescript
180
+ // POST /api/edpire/check/[id]/route.ts (recommended โ€” type-safe, handles URL construction)
181
+ import { EdpireClient } from "@edpire/sdk/client"
182
+
183
+ const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
184
+
185
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
186
+ const { id } = await params
187
+ const body = await req.json()
188
+ // Always set include_correct_answers server-side โ€” never trust the client to send it
189
+ const result = await client.checkQuestion(id, { ...body, include_correct_answers: true })
190
+ return Response.json(result) // result: CheckResult = { correct, score, max_score, feedback }
191
+ }
192
+ ```
193
+
194
+ <details>
195
+ <summary>Alternative โ€” raw fetch without EdpireClient</summary>
196
+
197
+ ```typescript
198
+ // Note: assessmentId goes in the URL path, not the request body
199
+ const result = await fetch(`https://edpire.com/api/v1/assessments/${assessmentId}/check`, {
200
+ method: "POST",
201
+ headers: {
202
+ Authorization: `Bearer ${process.env.EDPIRE_API_KEY}`,
203
+ "Content-Type": "application/json",
204
+ },
205
+ body: JSON.stringify({
206
+ exercise_id: exerciseId,
207
+ question_id: questionId,
208
+ answers,
209
+ learner_ref: userId,
210
+ session_id: sessionId,
211
+ include_correct_answers: true,
212
+ }),
213
+ }).then((r) => r.json())
214
+ // Response envelope: { data: CheckResult, error: null, meta: null }
215
+ ```
216
+
217
+ </details>
218
+
219
+ **Server โ€” submit the full attempt when done:**
220
+
221
+ ```typescript
222
+ import { EdpireClient } from "@edpire/sdk/client"
223
+ import type { StoredAnswer } from "@edpire/sdk"
224
+
225
+ const stored: StoredAnswer[] = []
226
+ // push { exerciseId, questionId, answers } for each step as the learner answers
227
+
228
+ const client = new EdpireClient({ apiKey: process.env.EDPIRE_API_KEY! })
229
+ const result = await client.submit(assessmentId, {
230
+ learner_ref: userId,
231
+ answers: stored, // StoredAnswer[] โ€” the client handles nesting automatically
232
+ })
233
+ console.log(`Final score: ${result.score}/${result.max_score}`)
234
+ ```
235
+
236
+ <details>
237
+ <summary>Rate limiting on /check</summary>
238
+
239
+ Each `(session_id, question_id)` pair is limited to 3 checks per hour (configurable per org).
240
+
241
+ **Always pass a `session_id`** โ€” generate a UUID once at the start of each attempt:
242
+ ```typescript
243
+ const sessionId = crypto.randomUUID() // generate once per attempt, not per question
244
+ ```
245
+ If omitted, rate limiting falls back to `learner_ref`, which accumulates across all of a learner's attempts and can cause unexpected 429s on repeat attempts.
246
+
247
+ </details>
248
+
249
+ **Required API key scopes:** `read:assessments` (for fetching and checking) + `write:submissions` (for the final submit).
250
+
251
+ **When to use:** You want full control over pacing, navigation, animations, or scoring UX. You're building a Duolingo-style flow, a timed drill, a practice mode, or any experience that goes beyond a linear "answer all โ†’ submit" form.
252
+
253
+ ---
254
+
255
+ ## Server Client
256
+
257
+ `EdpireClient` is a backend-only utility for calling the Edpire REST API from your server. It is not a rendering pattern โ€” it is used alongside Embedded Player and Custom Flow.
258
+
259
+ ```typescript
260
+ import { EdpireClient } from "@edpire/sdk/client"
261
+
262
+ const client = new EdpireClient({
263
+ apiKey: process.env.EDPIRE_API_KEY!,
264
+ baseUrl: "https://edpire.com", // optional, defaults to edpire.com
265
+ })
266
+ ```
267
+
268
+ **Common operations:**
269
+
270
+ ```typescript
271
+ // List published assessments
272
+ const { items } = await client.getAssessments({ status: "published" })
273
+
274
+ // Fetch a single assessment with full content
275
+ const assessment = await client.getAssessment(assessmentId)
276
+
277
+ // Mint an embed token (for Embedded Player)
278
+ const { token } = await client.mintEmbedToken(assessmentId, learnerRef)
279
+
280
+ // Submit a full attempt (for Custom Flow)
281
+ const result = await client.submit(assessmentId, { learner_ref: userId, answers: stored })
282
+
283
+ // Grade a single question without recording (for Custom Flow /check)
284
+ const check = await client.checkQuestion(assessmentId, {
285
+ exercise_id, question_id, answers, learner_ref, session_id,
286
+ include_correct_answers: true,
287
+ })
288
+
289
+ // Register a webhook
290
+ const webhook = await client.registerWebhook("https://yourapp.com/webhooks/edpire", [
291
+ "submission.graded",
292
+ ])
293
+ // Store webhook.secret securely โ€” shown only once
294
+ ```
295
+
296
+ ---
297
+
298
+ ## Mobile & WebView integration
299
+
300
+ Flutter, React Native, and native iOS/Android apps cannot import this npm package directly.
301
+ The integration uses two parts:
302
+
303
+ 1. **Your backend** mints an embed token via `client.mintEmbedToken()` (the API key stays server-side).
304
+ 2. **A WebView** in your mobile app loads a minimal HTML page that calls the CDN UMD build with the token.
305
+
306
+ > The UMD build is served from npm via [jsDelivr](https://www.jsdelivr.com/) โ€” no CDN to host yourself.
307
+
308
+ **Universal WebView HTML template:**
309
+
310
+ ```html
311
+ <!DOCTYPE html>
312
+ <html>
313
+ <head>
314
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
315
+ <style>* { margin: 0; padding: 0; box-sizing: border-box; } #root { min-height: 100vh; }</style>
316
+ </head>
317
+ <body>
318
+ <div id="root"></div>
319
+ <script src="https://cdn.jsdelivr.net/npm/@edpire/sdk@0.7.0/dist/umd/index.global.js"></script>
320
+ <script>
321
+ EdpireSDK.EdpireAssessment.mount({
322
+ token: "{{EMBED_TOKEN}}", // replace server-side before loading
323
+ container: "#root",
324
+ onComplete: function(r) {
325
+ if (window.ReactNativeWebView)
326
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: "complete", result: r }))
327
+ if (window.flutter_inappwebview)
328
+ window.flutter_inappwebview.callHandler("onComplete", r)
329
+ },
330
+ onError: function(e) {
331
+ if (window.ReactNativeWebView)
332
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: "error", error: e }))
333
+ if (window.flutter_inappwebview)
334
+ window.flutter_inappwebview.callHandler("onError", e)
335
+ }
336
+ })
337
+ </script>
338
+ </body>
339
+ </html>
340
+ ```
341
+
342
+ > **Ionic / Capacitor** apps run in a WebView โ€” you can use the full npm SDK directly. No HTML template needed.
343
+
344
+ For React Native, Flutter, and native iOS/Android code examples, see the [Mobile Integration guide](https://docs.edpire.com/developer/mobile).
345
+
346
+ ---
347
+
348
+ ## CSS
349
+
350
+ **Embedded Player** (`EdpireAssessment.mount()`) โ€” all CSS is injected automatically. No imports needed.
351
+
352
+ **Custom Flow** (`EdpireQuestion` / `renderQuestion`) โ€” styles are bundled into the SDK's JavaScript and injected when the component first renders. In most setups this is also automatic. If styles appear missing (e.g. in a sandboxed iframe or a strict CSP environment that blocks inline styles), import the utility stylesheet explicitly:
353
+
354
+ ```typescript
355
+ import "@edpire/sdk/styles/runtime-utilities.css"
356
+ ```
357
+
358
+ ---
359
+
360
+ ## TypeScript types
361
+
362
+ All commonly needed types are available from the main entry:
363
+
364
+ ```typescript
365
+ import type {
366
+ // Answer types
367
+ RuntimeAnswer,
368
+ ChoiceSetAnswer,
369
+ TypedBlankAnswer,
370
+ BlankChoiceAnswer,
371
+ MatchingSetAnswer,
372
+ MatchingConnection,
373
+ OpenResponseAnswer,
374
+
375
+ // Custom Flow helpers
376
+ StoredAnswer, // { exerciseId, questionId, answers: RuntimeAnswer[] }
377
+ FlatStep, // one step from flattenAssessment()
378
+
379
+ // Embedded Player
380
+ MountOptions,
381
+ ShellLayout,
382
+ EmbedInstance,
383
+ EmbedResult,
384
+ EmbedError,
385
+ } from "@edpire/sdk"
386
+ ```
387
+
388
+ Types specific to the Server Client come from `@edpire/sdk/client`:
389
+
390
+ ```typescript
391
+ import type { Assessment, GradeResult, CheckResult, EdpireClientOptions } from "@edpire/sdk/client"
392
+ ```
393
+
394
+ ---
395
+
396
+ ## Webhooks
397
+
398
+ ```typescript
399
+ const webhook = await client.registerWebhook("https://yourapp.com/api/webhooks/edpire", [
400
+ "submission.graded",
401
+ "assessment.published",
402
+ ])
403
+ // Store webhook.secret securely โ€” it is only shown once.
404
+ ```
405
+
406
+ **Verify incoming signatures:**
407
+
408
+ ```typescript
409
+ import { createHmac, timingSafeEqual } from "crypto"
410
+
411
+ export async function POST(req: Request) {
412
+ const rawBody = await req.text()
413
+ const signature = req.headers.get("x-edpire-signature") ?? ""
414
+
415
+ const expected = "sha256=" + createHmac("sha256", process.env.EDPIRE_WEBHOOK_SECRET!)
416
+ .update(rawBody)
417
+ .digest("hex")
418
+
419
+ // Length check first: timingSafeEqual throws RangeError on unequal lengths,
420
+ // so a truncated or missing header would crash the route instead of 401ing.
421
+ const sigBuf = Buffer.from(signature)
422
+ const expBuf = Buffer.from(expected)
423
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
424
+ return new Response("Unauthorized", { status: 401 })
425
+ }
426
+
427
+ const event = JSON.parse(rawBody)
428
+ // event.event, event.submission_id, event.score, event.passed, ...
429
+ }
430
+ ```
431
+
432
+ **Available events:** `submission.graded`, `submission.grading.completed`, `assessment.published`, `assessment.updated`, `assessment.content_updated`, `assessment.archived`, and the `collection.*` events. Full list and payloads: [Webhooks](https://docs.edpire.com/developer/webhooks).
433
+
434
+ ---
435
+
436
+ ## Error handling
437
+
438
+ `EdpireClient` throws `EdpireError` on non-2xx responses:
439
+
440
+ ```typescript
441
+ import { EdpireError } from "@edpire/sdk/client"
442
+
443
+ try {
444
+ await client.submit(assessmentId, options)
445
+ } catch (err) {
446
+ if (err instanceof EdpireError) {
447
+ console.error(err.status, err.message)
448
+ }
449
+ }
450
+ ```
451
+
452
+ Common status codes: `400` bad request ยท `401` invalid API key ยท `404` not found ยท `409` max attempts reached ยท `422` grading failed ยท `429` rate limit exceeded