Kredit Quest Partner API reference

Put Kredit Quest lessons inside your own product: a drop-in web component, native views or this API. Your members sign in once, to you.

Download OpenAPI 1.0.0-draft.3

Overview

Preview. Version 1.0 of the Kredit Quest Partner API is in early access. Routes can still change before launch; from launch, v1 follows the versioning promise below. To get test keys, book a demo at https://www.kreditacademy.com/contact.

Two families of routes:

Plus webhooks we send to the partner's server: lesson.completed, points.earned, member.linked, member.deleted. Webhooks, not browser events, are the source of truth for anything of value.

This contract does not mirror our database. Ids are opaque strings; do not parse them.

Versioning. v1 only grows: new fields, enum values, block types, event types, error codes and routes may be added; nothing is removed or changes meaning. Ignore what you do not know. A breaking change goes to v2, with at least six months' written notice and a Sunset header (RFC 8594) on every v1 response once a date is set.

Errors are RFC 9457 application/problem+json with a stable code. Rate limits are reported on every response in RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset; a 429 carries Retry-After.

Data minimisation. A member is an opaque partner user id plus, at most, an age band (13-17 or 18+). No name, email, phone, address, date of birth, account or balance appears anywhere in this contract. Kredit Quest is 13 and up: an under-13 member cannot exist, and a session for one is refused with under_13.

Session

Trade a partner's proof of sign-in for a Kredit Quest member session.

POST /embed/v1/session

Start a member session

Trade a partner-signed token (the partner token guide) or a Banno authorization code with its PKCE verifier for a member session: an access token for /embed/v1, 15 minutes, audience kq-embed.

No refresh token is ever issued to a browser. To renew, the embed asks the host page for a new partner token (or, for Banno, signs in again) and calls this route again, no earlier than 60 seconds before expires_in runs out. A new session replaces the old one; the old access token keeps working until it expires or is revoked.

Every refusal of the proof is 401 sign_in_failed, whatever the reason, so the page is no oracle for forging tokens; the reason is logged on our side only. A member known to be under 13 is refused with 403 under_13. When the proof carries no age band and the member has never answered, the answer is age_required and the embed asks the year of birth, then calls POST /embed/v1/session/age with the band only.

Auth: No key: the proof of sign-in is the body

Body: SessionRequest

StatusMeaning
200

A session, or the age question.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

The proof of sign-in was refused (sign_in_failed), for whatever reason. The reason is logged on our side only.

403

The member is under 13 (under_13). Show "Not available" and nothing else. Nothing but that answer is kept.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

DELETE /embed/v1/session

End the member session

Revokes the access token used to call it. The embed calls it when the host signs the member out.

Auth: memberSession

StatusMeaning
204

Ended.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /embed/v1/session/age

Answer the age question

Only the band leaves the page, never the year. under_13 is kept against the partner user id as that one answer and nothing else, and the answer is 403 under_13; the question is not asked again.

Auth: No key: the proof of sign-in is the body

Body: AgeAnswer

StatusMeaning
200

A member session.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

The proof of sign-in was refused (sign_in_failed), for whatever reason. The reason is logged on our side only.

403

The member is under 13 (under_13). Show "Not available" and nothing else. Nothing but that answer is kept.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

Content

Read-only lessons, partner-scoped and member-scoped by the server. Cacheable with ETags.

GET /embed/v1/journeys

List the journeys this partner has switched on

Auth: memberSession

ParameterInTypeDescription
localequeryLocale

The language of every string in the answer. The answer's Content-Language header says which was served.

limitqueryinteger

Page size.

cursorqueryCursor

The next_cursor of the previous page. Opaque; valid for 24 hours.

If-None-Matchheaderstring

An ETag from an earlier answer. When it still matches, the answer is 304 with no body.

StatusMeaning
200

A page of journeys, in the partner's order.

304

The If-None-Match ETag still matches. No body; use your cached copy.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /embed/v1/journeys/{journeyId}

One journey and its lessons

Auth: memberSession

ParameterInTypeDescription
journeyId requiredpathId

A journey id.

localequeryLocale

The language of every string in the answer. The answer's Content-Language header says which was served.

If-None-Matchheaderstring

An ETag from an earlier answer. When it still matches, the answer is 304 with no body.

StatusMeaning
200

The journey. Lessons are in order; progress is not included (see GET /embed/v1/progress).

304

The If-None-Match ETag still matches. No body; use your cached copy.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /embed/v1/lessons/{lessonId}

One lesson with its blocks

The lesson's blocks, in order. Questions come without their answers: grading is the server's. A compliance block is locked wording and must be displayed verbatim (see the ComplianceBlock schema).

Auth: memberSession

ParameterInTypeDescription
lessonId requiredpathId

A lesson id. The same id in every locale.

localequeryLocale

The language of every string in the answer. The answer's Content-Language header says which was served.

If-None-Matchheaderstring

An ETag from an earlier answer. When it still matches, the answer is 304 with no body.

StatusMeaning
200

The lesson.

304

The If-None-Match ETag still matches. No body; use your cached copy.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /embed/v1/theme

The partner's theme

The colors the partner set in the partner dashboard's Branding area. Every color is six-digit hex; the embed still checks each text and background pair for WCAG AA. Colors the partner has not set are left out.

Auth: memberSession

ParameterInTypeDescription
If-None-Matchheaderstring

An ETag from an earlier answer. When it still matches, the answer is 304 with no body.

StatusMeaning
200

The theme.

304

The If-None-Match ETag still matches. No body; use your cached copy.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

Progress

The member's own answers and progress.

POST /embed/v1/answers

Answer a question

Graded on the server. A lesson is completed when every question in it has been answered right; its points are awarded once, on first completion, by our server from the partner's pool under its caps. The answer reports what happened; it cannot ask for points.

Idempotency-Key is required. The same key with the same body within 24 hours returns the first answer (with Idempotent-Replayed: true) and changes nothing. The same key with a different body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress.

Compliance wording must have been shown. Send in compliance_shown the ref of every compliance block your screen showed for this lesson. An answer that would complete a lesson that has compliance blocks is refused with 422 compliance_not_shown unless every one of that lesson's refs is present; the problem's missing_refs lists the ones that were not. On that refusal nothing is saved: no progress, no points, no webhook, and the Idempotency-Key is not used up, so the same press can be sent again once the wording is on screen. An answer that does not complete the lesson is not checked. A new approved version of a line has a new ref, so fetch the lesson again and show it.

Auth: memberSession

ParameterInTypeDescription
Idempotency-Key requiredheaderIdempotencyKey

A unique key per logical attempt (a random UUID is fine), reused only to retry that same attempt. Kept 24 hours.

localequeryLocale

The language of every string in the answer. The answer's Content-Language header says which was served.

Body: AnswerRequest

StatusMeaning
200

Graded.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /embed/v1/progress

The member's progress

Auth: memberSession

ParameterInTypeDescription
localequeryLocale

The language of every string in the answer. The answer's Content-Language header says which was served.

StatusMeaning
200

Progress across the journeys this partner has on.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

Points

The member's points within this partner. Read-only; points are only ever awarded by our server.

GET /embed/v1/points

The member's points within this partner

Read-only. There is no route, for the embed or the partner, that grants points: they are awarded by our server alone, on lesson completion, from the partner's pool and under its cap.

Auth: memberSession

StatusMeaning
200

The balance.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

Partner

Server-to-server partner management. Partner API key only, never in a browser.

GET /partner/v1/partner

This partner and its environments

Which environment the calling key belongs to, and whether live access is approved.

Auth: partnerApiKey

StatusMeaning
200

The partner.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/live-access

Ask Kredit Academy for live access

Called with a kq_test_ key. Moves live_status from not_requested to pending_review. A person at Kredit Academy approves or declines it; no API call can. Until it is approved, no kq_live_ key can be created.

Auth: partnerApiKey

StatusMeaning
202

Requested. The partner's live_status is now pending_review.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /partner/v1/api-keys

List API keys

Secrets are never shown again after creation; only the last four characters.

Auth: partnerApiKey

ParameterInTypeDescription
limitqueryinteger

Page size.

cursorqueryCursor

The next_cursor of the previous page. Opaque; valid for 24 hours.

StatusMeaning
200

A page of keys.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/api-keys

Create an API key

Creates a key in the calling key's environment, and only there: asking for the other environment is 403 environment_mismatch (a test key never makes a live key; the first key of each environment, and any key across environments, is issued in the partner dashboard by a person). Use it to rotate: create the new key, deploy it, then revoke the old one. A kq_live_ key requires live_status: approved (else 403 live_not_approved). The secret is in this answer only; a replay with the same Idempotency-Key is 409 idempotent_replay and never shows it again.

Auth: partnerApiKey

ParameterInTypeDescription
Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

Body: ApiKeyCreate

StatusMeaning
201

Created. Store secret in your secrets manager now; it is not shown again.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

DELETE /partner/v1/api-keys/{apiKeyId}

Revoke an API key

At once and for good. A key cannot revoke itself (409 conflict), so a partner is never locked out. A key of the other environment is 403 environment_mismatch.

Auth: partnerApiKey

ParameterInTypeDescription
apiKeyId requiredpathId

An API key's id (not the key).

StatusMeaning
204

Revoked.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/api-keys/{apiKeyId}/rotate

Rotate an API key, with an overlap

Creates a new key with the same name, environment and scopes, and ends the old one after overlap_seconds (default 24 hours, at most 7 days), so you can deploy the new key without a failed call. The old key's expires_at becomes the end of the overlap. overlap_seconds: 0 ends the old key at once (for a leak). A kq_live_ key still needs live_status: approved (else 403 live_not_approved), and a key of the other environment is 403 environment_mismatch. The new secret is in this answer only; a replay with the same Idempotency-Key is 409 idempotent_replay.

Auth: partnerApiKey

ParameterInTypeDescription
apiKeyId requiredpathId

An API key's id (not the key).

Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

Body: ApiKeyRotation

StatusMeaning
201

The new key. Store secret in your secrets manager now; it is not shown again.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /partner/v1/jwks

The registered JWKS address

Auth: partnerApiKey

StatusMeaning
200

The registration.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

PUT /partner/v1/jwks

Register or move the JWKS address

We fetch the address at once and refuse it (422 jwks_unreachable or 422 jwks_invalid) unless it answers over https with a JWKS of ES256 or RS256 (2048 bits or more) public keys, each with a kid, and no private key material. Rotating keys inside the same JWKS needs no call here: see the partner token guide, "Keys".

Auth: partnerApiKey

Body: JwksUpdate

StatusMeaning
200

Registered.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/jwks/refresh

Drop our cached copy of your JWKS

Call it after removing a key that may have leaked. We fetch the JWKS again before the next token is accepted.

Auth: partnerApiKey

StatusMeaning
200

Refetched.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /partner/v1/host-origins

The origins allowed to frame the embed

Auth: partnerApiKey

StatusMeaning
200

The origins.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

PUT /partner/v1/host-origins

Replace the origins allowed to frame the embed

Every https:// origin whose pages show <kredit-quest>. Our server sends frame-ancestors with only these, and refuses a session whose host_origin is not one of them. Live origins must be https; a test environment may also list http://localhost origins.

A native app (the iOS, Android and React Native bridges, or the headless SDK in an app) has no web origin and uses https://native.kq.invalid. It is accepted as a session's host_origin only when you list it here; it is never accepted by default, and it is never written to frame-ancestors (no web page can frame the embed by it).

Auth: partnerApiKey

Body: HostOrigins

StatusMeaning
200

Saved.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

DELETE /partner/v1/members/{partnerUserId}

Delete a member and everything we hold for them

For a user who closed their account with you or asked to be forgotten. Deletes the member's progress, answers and points balance within this partner (points go back to no one: they are burned in our ledger). Sends member.deleted when done. Calling it again for the same user is 202 and does nothing more.

Auth: partnerApiKey

ParameterInTypeDescription
partnerUserId requiredpathPartnerUserId

Your opaque id for the user, the sub of your partner tokens.

Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

StatusMeaning
202

Deletion accepted; member.deleted follows.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

Webhooks

Webhook endpoints, secret rotation, test sends and the delivery log.

GET /partner/v1/webhook-endpoints

List webhook endpoints

Auth: partnerApiKey

ParameterInTypeDescription
limitqueryinteger

Page size.

cursorqueryCursor

The next_cursor of the previous page. Opaque; valid for 24 hours.

StatusMeaning
200

A page of endpoints.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/webhook-endpoints

Add a webhook endpoint

At most 10 endpoints per environment. The signing secret is in this answer only; a replay with the same Idempotency-Key is 409 idempotent_replay and never shows it again.

Auth: partnerApiKey

ParameterInTypeDescription
Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

Body: WebhookEndpointCreate

StatusMeaning
201

Created. Store secret now; it is not shown again.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /partner/v1/webhook-endpoints/{endpointId}

One webhook endpoint

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

StatusMeaning
200

The endpoint.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

PATCH /partner/v1/webhook-endpoints/{endpointId}

Change a webhook endpoint

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

Body: WebhookEndpointUpdate

StatusMeaning
200

Changed.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

DELETE /partner/v1/webhook-endpoints/{endpointId}

Remove a webhook endpoint

Deliveries still waiting for it are dropped.

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

StatusMeaning
204

Removed.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/webhook-endpoints/{endpointId}/rotate-secret

Rotate the signing secret, with an overlap

Issues a new secret at once. For overlap_seconds (default 24 hours, at most 7 days) every delivery is signed with both the new and the previous secret, as two v1= entries in KQ-Signature, so you can deploy the new secret without dropping a delivery. After the overlap only the new secret signs. overlap_seconds: 0 retires the previous secret at once (for a leak). A replay with the same Idempotency-Key is 409 idempotent_replay and never shows the secret again.

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

Body: SecretRotation

StatusMeaning
200

Rotated. Store secret now; it is not shown again.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/webhook-endpoints/{endpointId}/test

Send a test event

Sends one event of the chosen type to this endpoint now, signed as usual, with made-up data and a partner_user_id starting test_. It is logged in the delivery log and is not retried.

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

Body: TestSend

StatusMeaning
200

Sent; the attempt's outcome.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

GET /partner/v1/webhook-endpoints/{endpointId}/deliveries

The delivery log

Newest first, kept 30 days. We log the status code and timing of your answer, never its body.

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

limitqueryinteger

Page size.

cursorqueryCursor

The next_cursor of the previous page. Opaque; valid for 24 hours.

statusqueryDeliveryStatus

Only deliveries in this state.

event_idqueryEventId

Only the deliveries of this event.

StatusMeaning
200

A page of deliveries.

400

The request is malformed (invalid_request, unsupported_locale, idempotency_key_required).

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

POST /partner/v1/webhook-endpoints/{endpointId}/deliveries/{deliveryId}/redeliver

Send a logged delivery's event again

Sends the same event (the same id and body, a fresh KQ-Signature) to this endpoint once, now, and logs it as a new delivery that is not retried. For a delivery in the log, within its 30 days. The endpoint must be enabled.

Auth: partnerApiKey

ParameterInTypeDescription
endpointId requiredpathId

A webhook endpoint id.

deliveryId requiredpathId

A delivery's id, from the delivery log.

Idempotency-KeyheaderIdempotencyKey

Optional; send one per logical create and reuse it only to retry that create. Kept 24 hours per environment. The same key with the same body does not do the thing twice: when the first answer carried a secret (an API key or a webhook signing secret), the replay is 409 idempotent_replay with list_route, and the secret is never shown again (find the created item on the list route; if you lost the secret, rotate or revoke it). Any other create answers its stored answer again with Idempotent-Replayed: true. The same key with another body is 422 idempotency_key_reused; a key still being processed is 409 idempotency_in_progress. A refused request (4xx) is not stored.

StatusMeaning
200

Sent; the new attempt's outcome.

401

No valid credential (unauthorized), or the member session has ended (session_expired; get a new one).

403

Not allowed (forbidden, live_not_approved, browser_not_allowed, partner_inactive, environment_mismatch).

404

No such thing for this member and partner (not_found). A journey the partner switched off is not found.

409

Conflicts with the current state (conflict, idempotency_in_progress, limit_reached), or a replayed create whose answer carried a secret (idempotent_replay; the secret is never shown again, see list_route).

422

Well formed but cannot be done (idempotency_key_reused, jwks_unreachable, jwks_invalid, compliance_not_shown).

429

Rate limited (rate_limited). Wait Retry-After seconds; nothing was done. Limits are per member session for /embed/v1, per partner and client address for POST /embed/v1/session, and per API key for /partner/v1. The numbers are in the RateLimit-* headers and may change; code against the headers, not fixed numbers.

Webhooks we send

EVENT lesson.completed

A member finished a lesson for the first time

Sent once per member and lesson. Replays of a lesson send nothing.

EVENT points.earned

Our server awarded a member points

The source of truth for points. Credit anything of value from this event only, never from a browser event.

EVENT member.linked

A user of yours became a Kredit Quest member

Sent the first time a session is started for a partner user id (after the age check passes).

EVENT member.deleted

A member's data was deleted

After DELETE /partner/v1/members/{partnerUserId}, a member's own request, or our retention rules.

Schemas

Id

An opaque id. Do not parse it or rely on its form; compare it whole. The same rule as the embed's postMessage protocol.

EventId

An event id, evt_ then an opaque part.

Cursor

Opaque.

IdempotencyKey

Locale

English or Spanish. Spanish lesson text is reviewed before it is served.

Values: en, es

PartnerId

A publishable partner id, kqp_test_... or kqp_live_.... Public; it names a partner and proves nothing.

PartnerUserId

The partner's own opaque, stable id for the user (the partner token's sub; for Banno, the Banno member id). Never an email address, name, phone or account number.

AgeBand

The only age data in this contract. There is no under-13 band, because there are no under-13 members.

Values: 13-17, 18+

Timestamp

Seconds since the Unix epoch.

DateTime

RFC 3339, UTC.

Origin

An origin, scheme plus host plus optional port, no path.

Problem

RFC 9457 problem details, with our stable code.

FieldTypeDescription
type requiredstring (uri)

https://developers.kreditacademy.com/problems#<code>, which explains the code, or about:blank.

title requiredstring

A short, fixed summary for this code. For people, not for code.

status requiredinteger

detailstring

What happened this time. Never contains personal data or why a token was refused.

instancestring

This occurrence, for support.

code requiredobject

Stable and machine-readable. Branch on this, never on title or detail. The codes v1 uses are listed in ProblemCode; new ones may be added, so treat an unknown code by its HTTP status.

request_idstring

Quote it to support.

missing_refsarray of ComplianceRef

For compliance_not_shown, the refs of the lesson's compliance blocks that were not in compliance_shown.

list_routestring

For idempotent_replay, the list route where the created thing can be found, for example /partner/v1/api-keys.

errorsarray of object

For invalid_request, each field that failed.

ProblemCode

Every code v1 uses. Additive.

Values: invalid_request, unsupported_locale, unauthorized, session_expired, sign_in_failed, under_13, forbidden, browser_not_allowed, partner_inactive, live_not_approved, not_found, conflict, limit_reached, idempotency_key_required, idempotency_key_reused, idempotency_in_progress, idempotent_replay, environment_mismatch, compliance_not_shown, jwks_unreachable, jwks_invalid, rate_limited, internal

PartnerTokenGrant

The fintech drop-in. The token's rules are the partner token guide.

FieldTypeDescription
grant_type requiredstring

partner_id requiredPartnerId

token requiredstring

The partner-signed JWT, unread by the browser.

host_origin requiredobject

The page that framed the embed, as the loader pinned it. Must be one the partner registered. A native app sends https://native.kq.invalid, accepted only for a partner that registered it.

BannoCodeGrant

The Banno card. The browser never redeems the code: our server does, with Banno, checks the ID token and maps the institution to its partner.

FieldTypeDescription
grant_type requiredstring

code requiredstring

code_verifier requiredstring

RFC 7636, 43 to 128 characters.

redirect_uri requiredstring (uri)

nonce requiredstring

AgeAnswer

FieldTypeDescription
pending_id requiredId

age_band requiredstring: under_13, 13-17, 18+

The band from the year asked. under_13 ends the sign-in with 403 under_13.

MemberSession

FieldTypeDescription
status requiredstring

access_token requiredstring

Opaque bearer token for /embed/v1. In memory only.

token_type requiredstring

expires_in requiredinteger

Seconds the token is good for. 900 today; read it, do not assume it.

member requiredMember

partner_id requiredPartnerId

AgeRequired

Ask the year of birth, then send the band to POST /embed/v1/session/age with pending_id.

FieldTypeDescription
status requiredstring

pending_id requiredId

expires_in requiredinteger

Seconds the pending_id is good for.

Member

Everything the embed learns about the member. No name, email or birth date.

FieldTypeDescription
id requiredobject

Our opaque member id, stable for this partner user id within this partner.

age_band requiredAgeBand

Page

FieldTypeDescription
next_cursor requiredCursor or null

Pass as cursor for the next page; null on the last page.

JourneyPage

JourneySummary

FieldTypeDescription
id requiredId

title requiredstring

description requiredstring

lesson_count requiredinteger

estimated_minutesinteger

locale requiredLocale

Journey

LessonSummary

FieldTypeDescription
id requiredId

title requiredstring

estimated_minutesinteger

points requiredinteger

Points the lesson awards on first completion, before the member's daily cap and the partner's pool cap.

Lesson

FieldTypeDescription
id requiredId

journey_id requiredId

title requiredstring

locale requiredLocale

version requiredstring

The content version served. Changes whenever any block does.

estimated_minutesinteger

points requiredinteger

As on LessonSummary.

blocks requiredarray of Block

In order. A client skips a block whose type it does not know, and never shows it as raw data. Wording that must be shown will always come as a compliance block, never as a new type.

TextBlock

Plain text. No HTML or Markdown; render it as text.

FieldTypeDescription
type requiredstring

id requiredId

style requiredstring: paragraph, heading

text requiredstring

QuestionBlock

A question without its answer. Answer it with POST /embed/v1/answers.

FieldTypeDescription
type requiredstring

id requiredId

prompt requiredstring

select requiredstring: single, multiple

single: send one choice id. multiple: send every choice the member picked.

choices requiredarray of Choice

Choice

FieldTypeDescription
id requiredId

text requiredstring

FlashcardBlock

A card to flip. Nothing to answer.

FieldTypeDescription
type requiredstring

id requiredId

front requiredstring

back requiredstring

ComplianceBlock

Locked wording, approved by Kredit Academy and, where it applies, the sponsor bank. Display text verbatim: in full, in this order, as its own block, in the served locale, with no truncation, rewording, translation, summarising or hiding behind a tap. The theme's font and colors may apply; its size may not be below the lesson's body text. Partners cannot change it, and a client that cannot show it in full must not show the lesson.

FieldTypeDescription
type requiredstring

id requiredId

text requiredstring

locked requiredboolean

display requiredstring

ref requiredstring

The approved line and its version, for audit, for example disc-credit-score@3. Send it back in compliance_shown on POST /embed/v1/answers once the block is on screen.

ComplianceRef

A compliance block's ref.

AnswerRequest

FieldTypeDescription
lesson_id requiredId

question_id requiredId

choice_ids requiredarray of Id

One id for a single question; every picked id for multiple.

compliance_shownarray of ComplianceRef

The ref of every compliance block of this lesson that your screen showed, verbatim and in full. Required in practice for the answer that completes a lesson with compliance blocks (else 422 compliance_not_shown); send it on every answer. Refs of other lessons are ignored.

AnswerResult

FieldTypeDescription
correct requiredboolean

Whether this answer was right. A wrong answer saves nothing but the attempt.

lesson requiredobject

points requiredobject

progress requiredProgress

Progress

FieldTypeDescription
current_journey_id requiredId or null

The journey the member is on (the last one they worked in, else the partner's first), or null when the partner has none on.

streak_days requiredinteger

Days in a row, up to today in the member's time, with at least one lesson completed.

lessons_completed requiredinteger

next_lesson requiredNextLesson or null

The first lesson not completed in the member's current journey, or null when there is none.

journeys requiredarray of JourneyProgress

One entry per journey the partner has on, in the partner's order.

NextLesson

FieldTypeDescription
journey_id requiredId

lesson_id requiredId

title requiredstring

JourneyProgress

FieldTypeDescription
journey_id requiredId

lesson_count requiredinteger

completed_lesson_ids requiredarray of Id

PointsBalance

FieldTypeDescription
balance requiredinteger

The member's points within this partner.

earned_today requiredinteger

Earned since midnight in the member's time.

as_of requiredDateTime

Theme

FieldTypeDescription
colors requiredobject

Each one optional; left out when the partner has not set it.

HexColor

Partner

FieldTypeDescription
id requiredobject

The publishable partner id of the calling key's environment.

name requiredstring

environment requiredEnvironment

live_status requiredstring: not_requested, pending_review, approved, declined, suspended

Live access is approved by a person at Kredit Academy, never by an API call. not_requested, then pending_review after POST /partner/v1/live-access, then approved or declined; suspended stops live sessions and live keys at once.

Environment

test for kq_test_ keys and kqp_test_ partner ids (sandbox data only), live for kq_live_ and kqp_live_.

Values: test, live

ApiKeyScope

What a key may do. Additive. partner.read reads the partner, keys, JWKS, host origins, endpoints and the delivery log; each *.manage changes its own area; members.delete deletes members.

Values: partner.read, api_keys.manage, signing_keys.manage, host_origins.manage, webhooks.manage, members.delete

ApiKeyCreate

FieldTypeDescription
name requiredstring

Your label, for example "prod server 2026-10".

environmentobject

Defaults to the calling key's, and must equal it: another environment is 403 environment_mismatch. Keys for the other environment are issued in the partner dashboard.

scopesarray of ApiKeyScope

Defaults to every scope of the calling key. Never more than the calling key has.

ApiKeyRotation

FieldTypeDescription
overlap_secondsinteger

How long the old key keeps working. 0 ends it at once.

ApiKey

FieldTypeDescription
id requiredId

name requiredstring

environment requiredEnvironment

last4 requiredstring

created requiredDateTime

expires_atobject

When the key stops working. One year after creation, or the end of a rotation's overlap. We warn 30 days before.

scopesarray of ApiKeyScope

last_usedDateTime or null

ApiKeyWithSecret

ApiKeyPage

JwksUpdate

FieldTypeDescription
jwks_uri requiredstring (uri)

JwksRegistration

FieldTypeDescription
jwks_uri requiredstring (uri)

key_ids requiredarray of string

The kids we found at the last fetch.

fetched_at requiredDateTime

HostOrigins

FieldTypeDescription
origins requiredarray of Origin

EventType

Additive; ignore types you do not know and answer them 2xx.

Values: lesson.completed, points.earned, member.linked, member.deleted

WebhookUrl

https only, on a public address; a live endpoint must use port 443. We resolve the host and refuse private, loopback and link-local addresses, at registration and before every delivery. In the test environment only, http://localhost and http://127.0.0.1 (any port) are also allowed, for development.

WebhookEndpointCreate

FieldTypeDescription
url requiredWebhookUrl

events requiredarray of EventType

descriptionstring

WebhookEndpointUpdate

FieldTypeDescription
urlWebhookUrl

eventsarray of EventType

descriptionstring

enabledboolean

WebhookEndpoint

FieldTypeDescription
id requiredId

url requiredstring (uri)

events requiredarray of EventType

descriptionstring

enabled requiredboolean

A disabled endpoint receives nothing; events for it are not kept. We disable an endpoint that answers 410 Gone.

environment requiredEnvironment

created requiredDateTime

secret_rotation requiredobject or null

While a rotation's overlap lasts, when the previous secret stops signing; otherwise null.

WebhookEndpointWithSecret

WebhookEndpointPage

SecretRotation

FieldTypeDescription
overlap_secondsinteger

TestSend

FieldTypeDescription
type requiredEventType

DeliveryStatus

Values: pending, retrying, succeeded, failed

WebhookDelivery

FieldTypeDescription
id requiredId

event_id requiredEventId

event_type requiredEventType

status requiredDeliveryStatus

attempts requiredinteger

last_response_statusinteger or null

Your server's HTTP status at the last attempt; null for no answer (timeout or connection error).

last_duration_msinteger

next_attempt_atDateTime or null

created requiredDateTime

test requiredboolean

Sent by the test route.

WebhookDeliveryPage

WebhookEnvelope

Every webhook's body. data depends on type.

FieldTypeDescription
id requiredEventId

type requiredEventType

created requiredTimestamp

partner requiredPartnerId

data requiredobject

api_version requiredstring

MemberRef

The member, as you know them. Nothing else about them is sent.

FieldTypeDescription
partner_user_id requiredPartnerUserId

LessonCompletedEvent

PointsEarnedEvent

MemberLinkedEvent

MemberDeletedEvent