API
Build on Qwizbee
Let your app know the moment a quiz starts or finishes. Users connect with one click, you receive signed webhooks, and nobody ever shares a password or account token.
https://qwizbee-api-production.up.railway.app
How the connection works
Three steps, no developer portal and no pre-registration. Your app generates a PKCE verifier, the user approves once, and you store the API key that comes back.
Add a Connect button
Send the user to qwizbee.com/connect with your app name, a https redirect URI, a PKCE code challenge and a random state value.
The user approves
Qwizbee shows a consent page in the user’s own language. After Allow, the browser returns to your redirect URI with a one-time code (valid 2 minutes) and your state.
Exchange the code
Your server trades the code plus the PKCE verifier at POST /api/integrations/token for a qbw_… API key. That key only manages webhooks — nothing else in the account.
The Connect link
Generate the verifier and challenge on your server and keep the verifier in the user’s session:
// on your server
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(sha256(verifier));
// your "Connect to Qwizbee" button points at:
https://www.qwizbee.com/connect
?name=Supergood Mail
&redirect_uri=https://www.supergood-mail.com/callback
&code_challenge=${challenge}
&state=${randomState}
// after the user approves you are called back at:
https://www.supergood-mail.com/callback?code=${code}&state=${randomState}The state value comes back untouched so you can protect against CSRF, and the redirect URI must be https (http://localhost is allowed for testing).
// exchange the code for the scoped API key
const res = await fetch('https://qwizbee-api-production.up.railway.app/api/integrations/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifier }),
});
const { access_token } = await res.json(); // qbw_… — store it safelyThe events
After connecting, register a webhook with the API key. From then on Qwizbee POSTs a signed JSON message to your URL whenever one of the user’s quizzes starts or finishes.
quiz.started
Fires the moment the host presses Start.
POST https://your-app.com/webhooks/qwizbee
X-Qwizbee-Event: quiz.started
X-Qwizbee-Signature: <hmac-sha256>
{
"event": "quiz.started",
"timestamp": "2026-10-08T13:05:22.410Z",
"data": {
"sessionId": "s_ab12cd",
"roomCode": "KGMZ34",
"quizId": "qz_9x8y",
"quizTitle": "Friday afternoon quiz",
"playerCount": 14,
"questionCount": 10
}
}quiz.completed
Fires when the final standings appear. Adds the winner.
{
"event": "quiz.completed",
"timestamp": "2026-10-08T13:12:40.911Z",
"data": {
...same fields as quiz.started,
"winner": { "nickname": "BeeMercia", "score": 8420 }
}
}Payload fields
- event
- quiz.started or quiz.completed
- timestamp
- ISO 8601 moment of the event
- data.sessionId
- internal session id, stable per game
- data.roomCode
- the 6-character room code players join with
- data.quizId / quizTitle
- which quiz it was
- data.playerCount / questionCount
- how many players and questions
- data.winner
- quiz.completed only: nickname and score of number 1, or null
Verifying the signature
Every delivery is signed with the webhook’s own secret (shown once when you register it). Verify the raw request body — not a re-serialized object — before trusting a delivery:
import { createHmac, timingSafeEqual } from 'node:crypto';
app.post('/webhooks/qwizbee', express.raw({ type: 'application/json' }), (req, res) => {
const expected = createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex');
const got = req.headers['x-qwizbee-signature'] || '';
const valid = expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body);
if (event.event === 'quiz.started') { /* … */ }
res.status(200).end();
});API reference
Base URL of the production API. JWT means the user’s login token; API key means a qbw_… key from the Connect flow. Webhook routes accept both.
Connect flow
- GET
/api/connect/authorizeJWTValidate the connect parameters; returns the app name for the consent page.
- POST
/api/connect/approveJWTApprove and get the redirect URL containing the one-time code and state.
- POST
/api/integrations/tokenpublicExchange code + code_verifier for the scoped API key (qbw_…).
- GET
/api/integrationsJWTList the user’s connected apps with last-used timestamps.
- DELETE
/api/integrations/:idJWTBreak a connection. Its webhooks are deleted immediately and the key stops working.
Webhooks
- POST
/api/webhooksJWT or API keyRegister a webhook: { url, events }. The response contains the signing secret — shown only once.
- GET
/api/webhooksJWT or API keyList registered webhooks.
- DELETE
/api/webhooks/:idJWT or API keyRemove a webhook.
- GET
/api/webhooks/:id/deliveriesJWT or API keyLast 50 delivery attempts with status codes, for debugging.
Delivery behaviour
- Failed deliveries are retried after 1, 5 and 25 seconds (4 attempts total).
- Your endpoint must answer within 5 seconds with any 2xx status.
- Client errors (4xx) are not retried — you clearly refused, so we stop.
- Every attempt is logged and visible via the deliveries endpoint.
- Breaking the connection in the user’s Account page stops all deliveries instantly.
Testing your integration
- Register a webhook pointing at a https://webhook.site URL to inspect live deliveries.
- http://localhost and http://127.0.0.1 are accepted as webhook and redirect targets, so you can develop locally.
- The delivery log (GET /api/webhooks/:id/deliveries) shows the exact status codes we received from you.
Questions or missing an event?
contact@kylevanraaij.com Mail and tell us what you are building — player-level events are on the roadmap.