Developer documentation for the Content Rewardz app marketplace.
Apps are hosted by you. We load your app in a sandboxed iframe on the surfaces a creator installs it into (an experience in the sidebar, or a dashboard config view). Your backend talks to our scoped API server-to-server with an app API key. Nothing about your source or hosting is required on our side — only the listing, the embed URL, and the scopes you request.
On every load of one of your surfaces we mint a short-lived JWT signed with your app's signing key and pass it to your iframe as ?cr_context=<jwt>. It tells you who is using the app and what they can do. Verify it server-side — the token's audience is your app id, so a token minted for one app can never be replayed against another. It carries the user, install, company, surface, access level and the scopes the owner granted; it never contains a secret.
// Your app receives `?cr_context=<jwt>` when we load it in the iframe.
// Verify it SERVER-SIDE with your app's signing key (never expose the key to the
// browser). This mirrors the platform's `verifyAppContext` helper.
import { jwtVerify } from 'jose'
import crypto from 'crypto'
const ISSUER = 'contentrewardz'
function keyFor(signingKey) {
// HS256 key derived from your app's signing secret (any length accepted).
return new Uint8Array(crypto.createHash('sha256').update(signingKey).digest())
}
export async function verifyContext(token, appId, signingKey) {
const { payload } = await jwtVerify(token, keyFor(signingKey), {
issuer: ISSUER,
audience: appId, // token is bound to YOUR app id — can't be replayed
})
return {
userId: String(payload.sub ?? payload.userId ?? ''),
installId: String(payload.installId ?? ''),
companyId: String(payload.companyId ?? ''),
surface: payload.surface, // 'experience' | 'dashboard'
accessLevel: payload.accessLevel, // 'admin' | 'customer' | 'no_access'
scopes: payload.scopes ?? [],
}
}Prefer not to implement JWT verification yourself? POST the token to /api/v1/apps/verify-token with your API key and we verify and decode it for you.
Every call to the scoped API carries your app API key as a bearer token. Keys are prefixed cra_ and shown only once at issue time — store it as a server secret. We keep only a hash.
# All /api/v1/apps/** calls are server-to-server with your app API key.
curl https://contentrewardz.com/api/v1/apps/me \
-H "Authorization: Bearer cra_your_app_api_key"
# => { "id": "app_…", "name": "My App", "scopes": ["members:read", …] }The scoped API is versioned by date. Pin a version by sending an Api-Version-Date request header holding a dated release (YYYY-MM-DD). We resolve it to the newest published version at or before that date and echo the result back in an Api-Version response header on every /api/v1/apps/** call.
2025-01-01) — existing integrations keep working untouched.400 with code: invalid_api_version / api_version_too_old.# Pin a dated API version. The resolved version comes back in Api-Version.
curl -i https://contentrewardz.com/api/v1/apps/me \
-H "Authorization: Bearer cra_your_app_api_key" \
-H "Api-Version-Date: 2025-01-01"
# HTTP/1.1 200 OK
# Api-Version: 2025-01-01
# { "id": "app_…", "name": "My App", "scopes": [ … ] }
# Omit the header → default (2025-01-01).
# Too-old / malformed date → 400 { "error": "…", "code": "api_version_too_old" }Base URL: https://contentrewardz.com. Each endpoint enforces the scope shown; a missing scope returns 403, an invalid key 401.
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/apps/me | — | Confirm your API key and read your app identity (id, name, granted scopes). |
| POST | /api/v1/apps/verify-token | — | Verify a context token we handed your iframe and get back its claims. |
| GET | /api/v1/apps/access?installId=&userId= | members:read | Check a user’s access level for one of your installs (admin / customer / no_access). |
| POST | /api/v1/apps/notifications | notifications:create | Send an in-app notification to a user of one of your installs. |
curl -X POST https://contentrewardz.com/api/v1/apps/verify-token \
-H "Authorization: Bearer cra_your_app_api_key" \
-H "Content-Type: application/json" \
-d '{ "token": "<the cr_context jwt>" }'
# => { "valid": true, "claims": { "userId": "…", "installId": "…", "accessLevel": "admin", … } }curl "https://contentrewardz.com/api/v1/apps/access?installId=INSTALL&userId=USER" \
-H "Authorization: Bearer cra_your_app_api_key"
# requires scope: members:read
# => { "has_access": true, "access_level": "admin" }curl -X POST https://contentrewardz.com/api/v1/apps/notifications \
-H "Authorization: Bearer cra_your_app_api_key" \
-H "Content-Type: application/json" \
-d '{
"installId": "INSTALL",
"userId": "USER",
"title": "Your report is ready",
"body": "Open the app to view it.",
"link": "/apps/my-app"
}'
# requires scope: notifications:create
# The target user must be an installer of an ACTIVE install of your app.Subscribe to events with the webhooks:events scope and a webhook URL. We POST each event to your URL with an HMAC-SHA256 signature of the raw request body in the X-CR-Signature header, keyed by your app's signing key. Always verify the signature against the raw body before parsing.
// We POST events to your webhookUrl with an HMAC-SHA256 signature of the RAW body,
// keyed by your app's signing key. Verify it before trusting the payload.
import crypto from 'crypto'
function verifyWebhook(rawBody, signatureHeader, signingKey) {
const expected = crypto
.createHmac('sha256', signingKey)
.update(rawBody, 'utf8')
.digest('hex')
// constant-time compare to avoid timing leaks
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader || '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express example:
app.post('/cr/webhook', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.header('X-CR-Signature')
if (!verifyWebhook(req.body.toString('utf8'), sig, process.env.CR_SIGNING_KEY)) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8')) // { type, data, … }
// handle event.type …
res.status(200).end()
})Let people sign in to your app with their Content Rewardz account — the same way “Sign in with Google”-style buttons work. It is standard OAuth 2.1 authorization-code with PKCE. Unlike the app API key (which acts as your app, server-to-server), these tokens act as a specific user, limited to the scopes they consented to.
code_verifier + S256 code_challenge and a state./oauth/authorize; they sign in and click Allow.redirect_uri with a one-time code (10-min TTL)./api/oauth/token for an access + refresh token./api/v1/apps/userinfo with the access token to identify the user.| Param | Value |
|---|---|
| response_type | code (required) |
| client_id | Your app’s client id (issued on approval) |
| redirect_uri | Where we send the code — its origin must match a URL registered for your app |
| scope | Space-separated scopes (subset of what your app is granted). Omit for basic profile only. |
| state | Opaque value echoed back — verify it to prevent CSRF |
| code_challenge | base64url(SHA-256(code_verifier)) |
| code_challenge_method | S256 (required when code_challenge is sent; “plain” is rejected) |
// 1) Create a PKCE verifier + S256 challenge (do this per sign-in, server-side).
import crypto from 'crypto'
const codeVerifier = crypto.randomBytes(32).toString('base64url') // 43–128 chars
const codeChallenge = crypto
.createHash('sha256').update(codeVerifier).digest('base64url') // S256
const state = crypto.randomBytes(16).toString('hex') // CSRF guard
// Persist { state -> codeVerifier } in the user's session for step 3.// 2) Redirect the user's BROWSER to the consent screen.
const url = new URL('https://contentrewardz.com/oauth/authorize')
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', process.env.CR_CLIENT_ID)
url.searchParams.set('redirect_uri', 'https://your-app.com/callback') // must match a registered app URL origin
url.searchParams.set('scope', 'members:read') // space-separated; omit for basic profile only
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
// The user signs in (if needed) and clicks Allow. We redirect back to:
// https://your-app.com/callback?code=<code>&state=<state>
// On Deny: https://your-app.com/callback?error=access_denied&state=<state># 3) Exchange the code for tokens (server-to-server). PUBLIC client → send code_verifier.
curl -X POST https://contentrewardz.com/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=THE_CODE \
-d client_id=$CR_CLIENT_ID \
-d redirect_uri=https://your-app.com/callback \
-d code_verifier=THE_VERIFIER
# Confidential client: add -d client_secret=$CR_CLIENT_SECRET (or HTTP Basic) instead of / with PKCE.
# => {
# "access_token": "crat_…", "refresh_token": "crrt_…",
# "token_type": "Bearer", "expires_in": 3600, "scope": "members:read"
# }# 4) Read the signed-in user with the access token.
curl https://contentrewardz.com/api/v1/apps/userinfo \
-H "Authorization: Bearer crat_the_access_token"
# => { "id": "…", "name": "Ada Lovelace", "username": "ada" }# 5) Access tokens last ~1 hour. Rotate with the refresh token (old one is revoked).
curl -X POST https://contentrewardz.com/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=crrt_the_refresh_token \
-d client_id=$CR_CLIENT_ID
# Confidential client: also send client_secret.
# => new { access_token, refresh_token, expires_in, scope }Public clients (SPAs / mobile / anything that can’t hold a secret) authenticate with PKCE alone — send code_verifier at token exchange and no secret. Confidential clients send client_secret (form field or HTTP Basic). Access tokens are prefixed crat_, refresh tokens crrt_; we store only hashes.
Request the least privilege you need — creators see and consent to each scope at install. No scope moves money.
campaigns:readRead your campaignscampaigns:writeCreate and edit campaigns on your behalfsubmissions:readRead clip submissions and verified-view statssubmissions:writeApprove, reject or annotate submissionsanalytics:readRead aggregate performance datapayouts:readRead payout and earnings status (read-only, never moves money)webhooks:eventsReceive event notificationsmembers:readCheck a member’s access level to this installationnotifications:createSend in-app notifications to your app’s userspayments:chargeCharge your users for in-app value (routed through platform billing)members:readCheck whether a user has access to an installnotifications:createSend in-app notifications to your installerssubmission.createdsubmission.approvedsubmission.rejectedcampaign.activatedcampaign.completedpayout.releasedinstall.createdinstall.revokedNever expose your API key or signing key in client-side code. All /api/v1/apps/** calls and token verification happen on your server.