</>

Build an App

Developer documentation for the Content Rewardz app marketplace.

Overview

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.

  1. Apply as a developer and get approved.
  2. Create an app, set its embed URL, and request the least-privilege scopes you need.
  3. Submit for review. An admin approves it and it goes LIVE on the marketplace; you receive your API key and per-app signing key.
  4. Creators install your app; we hand each surface a signed context token.

The context-token handshake

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.

API keys (Bearer auth)

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", …] }

API versioning

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.

  • Omit the header and you get the current stable default (2025-01-01) — existing integrations keep working untouched.
  • A future date pins to the latest published version (you get today’s behaviour until a newer one ships).
  • A malformed date, or one older than the earliest supported version, returns 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" }

Scoped endpoints

Base URL: https://contentrewardz.com. Each endpoint enforces the scope shown; a missing scope returns 403, an invalid key 401.

MethodPathScopeDescription
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:readCheck a user’s access level for one of your installs (admin / customer / no_access).
POST/api/v1/apps/notificationsnotifications:createSend an in-app notification to a user of one of your installs.

Verify a context token

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", … } }

Check access

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" }

Send a notification

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.

Webhooks (HMAC verification)

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()
})

Login with Content Rewardz (OAuth)

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.

  1. Generate a PKCE code_verifier + S256 code_challenge and a state.
  2. Redirect the user to /oauth/authorize; they sign in and click Allow.
  3. We redirect back to your redirect_uri with a one-time code (10-min TTL).
  4. Exchange the code at /api/oauth/token for an access + refresh token.
  5. Call /api/v1/apps/userinfo with the access token to identify the user.

Authorize URL parameters

ParamValue
response_typecode (required)
client_idYour app’s client id (issued on approval)
redirect_uriWhere we send the code — its origin must match a URL registered for your app
scopeSpace-separated scopes (subset of what your app is granted). Omit for basic profile only.
stateOpaque value echoed back — verify it to prevent CSRF
code_challengebase64url(SHA-256(code_verifier))
code_challenge_methodS256 (required when code_challenge is sent; “plain” is rejected)

1. PKCE + state

// 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. Send the user to consent

// 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

# 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. Identify the user

# 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. Refresh the access token

# 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.

Available scopes

Request the least privilege you need — creators see and consent to each scope at install. No scope moves money.

  • campaigns:readRead your campaigns
  • campaigns:writeCreate and edit campaigns on your behalf
  • submissions:readRead clip submissions and verified-view stats
  • submissions:writeApprove, reject or annotate submissions
  • analytics:readRead aggregate performance data
  • payouts:readRead payout and earnings status (read-only, never moves money)
  • webhooks:eventsReceive event notifications
  • members:readCheck a member’s access level to this installation
  • notifications:createSend in-app notifications to your app’s users
  • payments:chargeCharge your users for in-app value (routed through platform billing)
  • members:readCheck whether a user has access to an install
  • notifications:createSend in-app notifications to your installers

Webhook events

submission.createdsubmission.approvedsubmission.rejectedcampaign.activatedcampaign.completedpayout.releasedinstall.createdinstall.revoked

Never expose your API key or signing key in client-side code. All /api/v1/apps/** calls and token verification happen on your server.