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:
/embed/v1: called with a short-lived member session (aud: kq-embed), by the Kredit Quest embed (the Banno card and the <kredit-quest> drop-in). Read-only content, the member's own progress and points. The partner is always taken from the session on the server, never from the request./partner/v1: called server to server with a partner API key (kq_test_... or kq_live_...). Never from a browser. Signing keys (JWKS), host origins, webhook endpoints, delivery log, member deletion, API keys.
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
locale | query | Locale | The language of every string in the answer. The answer's Content-Language header says which was served. |
limit | query | integer | Page size. |
cursor | query | Cursor | The next_cursor of the previous page. Opaque; valid for 24 hours. |
If-None-Match | header | string | An ETag from an earlier answer. When it still matches, the answer is 304 with no body. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
journeyId required | path | Id | A journey id. |
locale | query | Locale | The language of every string in the answer. The answer's Content-Language header says which was served. |
If-None-Match | header | string | An ETag from an earlier answer. When it still matches, the answer is 304 with no body. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
lessonId required | path | Id | A lesson id. The same id in every locale. |
locale | query | Locale | The language of every string in the answer. The answer's Content-Language header says which was served. |
If-None-Match | header | string | An ETag from an earlier answer. When it still matches, the answer is 304 with no body. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
If-None-Match | header | string | An ETag from an earlier answer. When it still matches, the answer is 304 with no body. |
| Status | Meaning |
|---|
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. |
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
limit | query | integer | Page size. |
cursor | query | Cursor | The next_cursor of the previous page. Opaque; valid for 24 hours. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
Idempotency-Key | header | IdempotencyKey | 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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
apiKeyId required | path | Id | An API key's id (not the key). |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
apiKeyId required | path | Id | An API key's id (not the key). |
Idempotency-Key | header | IdempotencyKey | 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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
partnerUserId required | path | PartnerUserId | Your opaque id for the user, the sub of your partner tokens. |
Idempotency-Key | header | IdempotencyKey | 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. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
limit | query | integer | Page size. |
cursor | query | Cursor | The next_cursor of the previous page. Opaque; valid for 24 hours. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
Idempotency-Key | header | IdempotencyKey | 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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
Body: WebhookEndpointUpdate
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
Idempotency-Key | header | IdempotencyKey | 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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
Idempotency-Key | header | IdempotencyKey | 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
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
limit | query | integer | Page size. |
cursor | query | Cursor | The next_cursor of the previous page. Opaque; valid for 24 hours. |
status | query | DeliveryStatus | Only deliveries in this state. |
event_id | query | EventId | Only the deliveries of this event. |
| Status | Meaning |
|---|
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
| Parameter | In | Type | Description |
|---|
endpointId required | path | Id | A webhook endpoint id. |
deliveryId required | path | Id | A delivery's id, from the delivery log. |
Idempotency-Key | header | IdempotencyKey | 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. |
| Status | Meaning |
|---|
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. |
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.
| Field | Type | Description |
|---|
type required | string (uri) | https://developers.kreditacademy.com/problems#<code>, which explains the code, or about:blank.
|
title required | string | A short, fixed summary for this code. For people, not for code. |
status required | integer | |
detail | string | What happened this time. Never contains personal data or why a token was refused. |
instance | string | This occurrence, for support. |
code required | object | 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_id | string | Quote it to support. |
missing_refs | array of ComplianceRef | For compliance_not_shown, the refs of the lesson's compliance blocks that were not in compliance_shown. |
list_route | string | For idempotent_replay, the list route where the created thing can be found, for example /partner/v1/api-keys. |
errors | array 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
SessionRequest
The proof of sign-in, by kind.
One of: PartnerTokenGrant, BannoCodeGrant
PartnerTokenGrant
The fintech drop-in. The token's rules are the partner token guide.
| Field | Type | Description |
|---|
grant_type required | string | |
partner_id required | PartnerId | |
token required | string | The partner-signed JWT, unread by the browser. |
host_origin required | object | 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.
| Field | Type | Description |
|---|
grant_type required | string | |
code required | string | |
code_verifier required | string | RFC 7636, 43 to 128 characters. |
redirect_uri required | string (uri) | |
nonce required | string | |
AgeAnswer
| Field | Type | Description |
|---|
pending_id required | Id | |
age_band required | string: under_13, 13-17, 18+ | The band from the year asked. under_13 ends the sign-in with 403 under_13. |
SessionResponse
One of: MemberSession, AgeRequired
MemberSession
| Field | Type | Description |
|---|
status required | string | |
access_token required | string | Opaque bearer token for /embed/v1. In memory only. |
token_type required | string | |
expires_in required | integer | Seconds the token is good for. 900 today; read it, do not assume it. |
member required | Member | |
partner_id required | PartnerId | |
AgeRequired
Ask the year of birth, then send the band to POST /embed/v1/session/age with pending_id.
| Field | Type | Description |
|---|
status required | string | |
pending_id required | Id | |
expires_in required | integer | Seconds the pending_id is good for. |
Member
Everything the embed learns about the member. No name, email or birth date.
| Field | Type | Description |
|---|
id required | object | Our opaque member id, stable for this partner user id within this partner. |
age_band required | AgeBand | |
Page
| Field | Type | Description |
|---|
next_cursor required | Cursor or null | Pass as cursor for the next page; null on the last page. |
JourneyPage
JourneySummary
| Field | Type | Description |
|---|
id required | Id | |
title required | string | |
description required | string | |
lesson_count required | integer | |
estimated_minutes | integer | |
locale required | Locale | |
Journey
LessonSummary
| Field | Type | Description |
|---|
id required | Id | |
title required | string | |
estimated_minutes | integer | |
points required | integer | Points the lesson awards on first completion, before the member's daily cap and the partner's pool cap. |
Lesson
| Field | Type | Description |
|---|
id required | Id | |
journey_id required | Id | |
title required | string | |
locale required | Locale | |
version required | string | The content version served. Changes whenever any block does. |
estimated_minutes | integer | |
points required | integer | As on LessonSummary. |
blocks required | array 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. |
Block
One of: TextBlock, QuestionBlock, FlashcardBlock, ComplianceBlock
TextBlock
Plain text. No HTML or Markdown; render it as text.
| Field | Type | Description |
|---|
type required | string | |
id required | Id | |
style required | string: paragraph, heading | |
text required | string | |
QuestionBlock
A question without its answer. Answer it with POST /embed/v1/answers.
| Field | Type | Description |
|---|
type required | string | |
id required | Id | |
prompt required | string | |
select required | string: single, multiple | single: send one choice id. multiple: send every choice the member picked.
|
choices required | array of Choice | |
Choice
| Field | Type | Description |
|---|
id required | Id | |
text required | string | |
FlashcardBlock
A card to flip. Nothing to answer.
| Field | Type | Description |
|---|
type required | string | |
id required | Id | |
front required | string | |
back required | string | |
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.
| Field | Type | Description |
|---|
type required | string | |
id required | Id | |
text required | string | |
locked required | boolean | |
display required | string | |
ref required | string | 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
| Field | Type | Description |
|---|
lesson_id required | Id | |
question_id required | Id | |
choice_ids required | array of Id | One id for a single question; every picked id for multiple. |
compliance_shown | array 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
| Field | Type | Description |
|---|
correct required | boolean | Whether this answer was right. A wrong answer saves nothing but the attempt. |
lesson required | object | |
points required | object | |
progress required | Progress | |
Progress
| Field | Type | Description |
|---|
current_journey_id required | Id 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 required | integer | Days in a row, up to today in the member's time, with at least one lesson completed. |
lessons_completed required | integer | |
next_lesson required | NextLesson or null | The first lesson not completed in the member's current journey, or null when there is none. |
journeys required | array of JourneyProgress | One entry per journey the partner has on, in the partner's order. |
NextLesson
| Field | Type | Description |
|---|
journey_id required | Id | |
lesson_id required | Id | |
title required | string | |
JourneyProgress
| Field | Type | Description |
|---|
journey_id required | Id | |
lesson_count required | integer | |
completed_lesson_ids required | array of Id | |
PointsBalance
| Field | Type | Description |
|---|
balance required | integer | The member's points within this partner. |
earned_today required | integer | Earned since midnight in the member's time. |
as_of required | DateTime | |
Theme
| Field | Type | Description |
|---|
colors required | object | Each one optional; left out when the partner has not set it. |
HexColor
Partner
| Field | Type | Description |
|---|
id required | object | The publishable partner id of the calling key's environment. |
name required | string | |
environment required | Environment | |
live_status required | string: 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
| Field | Type | Description |
|---|
name required | string | Your label, for example "prod server 2026-10". |
environment | object | 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. |
scopes | array of ApiKeyScope | Defaults to every scope of the calling key. Never more than the calling key has. |
ApiKeyRotation
| Field | Type | Description |
|---|
overlap_seconds | integer | How long the old key keeps working. 0 ends it at once. |
ApiKey
| Field | Type | Description |
|---|
id required | Id | |
name required | string | |
environment required | Environment | |
last4 required | string | |
created required | DateTime | |
expires_at | object | When the key stops working. One year after creation, or the end of a rotation's overlap. We warn 30 days before. |
scopes | array of ApiKeyScope | |
last_used | DateTime or null | |
ApiKeyWithSecret
ApiKeyPage
JwksUpdate
| Field | Type | Description |
|---|
jwks_uri required | string (uri) | |
JwksRegistration
| Field | Type | Description |
|---|
jwks_uri required | string (uri) | |
key_ids required | array of string | The kids we found at the last fetch. |
fetched_at required | DateTime | |
HostOrigins
| Field | Type | Description |
|---|
origins required | array 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
WebhookEndpointUpdate
WebhookEndpoint
| Field | Type | Description |
|---|
id required | Id | |
url required | string (uri) | |
events required | array of EventType | |
description | string | |
enabled required | boolean | A disabled endpoint receives nothing; events for it are not kept. We disable an endpoint that answers 410 Gone. |
environment required | Environment | |
created required | DateTime | |
secret_rotation required | object or null | While a rotation's overlap lasts, when the previous secret stops signing; otherwise null. |
WebhookEndpointWithSecret
WebhookEndpointPage
SecretRotation
| Field | Type | Description |
|---|
overlap_seconds | integer | |
TestSend
DeliveryStatus
Values: pending, retrying, succeeded, failed
WebhookDelivery
| Field | Type | Description |
|---|
id required | Id | |
event_id required | EventId | |
event_type required | EventType | |
status required | DeliveryStatus | |
attempts required | integer | |
last_response_status | integer or null | Your server's HTTP status at the last attempt; null for no answer (timeout or connection error). |
last_duration_ms | integer | |
next_attempt_at | DateTime or null | |
created required | DateTime | |
test required | boolean | Sent by the test route. |
WebhookDeliveryPage
WebhookEnvelope
Every webhook's body. data depends on type.
MemberRef
The member, as you know them. Nothing else about them is sent.
LessonCompletedEvent
PointsEarnedEvent
MemberLinkedEvent
MemberDeletedEvent