Webhooks let you skip polling. When you supply callbackUrl on a
POST /api/v1/courses or
POST /api/v1/courses/:id/export request,
we POST a typed event to your URL when the job completes.
How it works
- Per-request, not per-account. Pass
callbackUrlon each request to control delivery. Every customer brings their own URL on every call. - Single delivery. Each event is delivered once with a 10-second
timeout. Build idempotency into your handler — keying off
jobId(forcourse.created/course.failed) orcourseId+exportedAt(forcourse.exported) is enough. - Typed payloads. Three event types:
course.created,course.failed,course.exported. Each has a stable shape (below). Dates are ISO 8601 strings;Content-Typeisapplication/json.
Receiver requirements
- Public, HTTPS endpoint. Your
callbackUrlmust resolve to a public IP. HTTPS is recommended. - Direct response. The endpoint must terminate at the URL you provide; redirects aren't followed.
- Return 2xx on success. Anything else counts as a delivery failure.
Events
course.created
Fired when a course-generation job reaches completed.
{
"event": "course.created",
"jobId": "8aF2k...Xq",
"course": {
"id": "cou_abc123",
"name": "Workplace Safety Basics",
"headline": "A practical guide for new hires",
"shareUrl": "https://learningstudioai.com/go/cou_abc123",
"createdAt": "2026-04-28T17:14:08.000Z"
}
}
The course object matches the GET job status response.
course.failed
Fired when a job reaches failed instead of completed.
{
"event": "course.failed",
"jobId": "8aF2k...Xq",
"error": {
"message": "Course outline could not be generated for this subject.",
"code": "GENERATION_FAILED"
}
}
error.code is best-effort — present for known failure modes,
omitted for unknown ones.
course.exported
Fired when POST /api/v1/courses/:id/export
completes and a callbackUrl was supplied.
{
"event": "course.exported",
"courseId": "cou_abc123",
"format": "scorm12",
"downloadUrl": "https://storage.googleapis.com/.../course.zip",
"shareUrl": "https://learningstudioai.com/go/cou_abc123",
"exportedAt": "2026-04-28T17:15:42.000Z"
}
Receiving webhooks
A minimal Node receiver:
import express from 'express';
const app = express();
app.use(express.json());
app.post('/hooks/learningstudio', async (req, res) => {
const event = req.body;
switch (event.event) {
case 'course.created':
await ingestNewCourse(event.course);
break;
case 'course.failed':
await alertOps(event.jobId, event.error);
break;
case 'course.exported':
await uploadToLms(event.courseId, event.downloadUrl);
break;
default:
// Forward-compat: unknown events should still 200.
console.log('unknown event', event);
}
res.sendStatus(200);
});
app.listen(3000);
Best practices
- Respond quickly. If your handler does heavy work (e.g. download
the SCORM zip and upload to an LMS), enqueue a background job and
return
200immediately. The 10-second timeout is enforced on our side. - Be idempotent. Build your handler to safely process the same event twice — network blips can cause occasional duplicates.
- Forward-compat. Future events may be added. Fall through unknown
event types with a
200— never reject with a 4xx.
Security
Authenticate the webhook by URL secrecy: include a hard-to-guess token
as a path segment or query parameter on your callbackUrl (e.g.
/hooks/learningstudio/<random-32-char-token>). Reject requests on
any other path.