Rovenue SDKBeta
Guides

Server-side API

Call Rovenue from your own backend with the project's secret key — authentication, which routes need which key, addressing a subscriber, errors, rate limits, retries, and verifying project webhooks.

The SDKs talk to Rovenue with your project's public key. Your backend talks to it with the secret key, over the same /v1 API. Use the server-side API when your server is the one that knows the truth:

  • checking a user's entitlements before serving premium content from your API;
  • writing attributes your app never sees (a CRM tier, a web sign-up source);
  • merging two accounts after your own login system decides they are one person;
  • debiting a virtual currency for work your backend performed;
  • answering a GDPR/KVKK export or erasure request.

Every route is in the API Explorer, and every error code is in API Errors. This page covers what those two don't: how the pieces fit together.

Base URL

DeploymentBase URL
Rovenue Cloudhttps://api.rovenue.io
Self-hostedYour API service's public URL

Every route below lives under /v1 on that host. The examples show the path only.

Getting a secret key

In the dashboard, open your project → SDK & API → Project keys → New key, and choose an environment. Rovenue mints a pair: a publishable key (rov_pk_…) and a secret key (rov_sk_…).

Every key names its environment. A Production key starts rov_pk_live_ / rov_sk_live_; a Sandbox key starts rov_pk_test_ / rov_sk_test_. The examples below use a Production secret key, <rov_sk_live_…>. Each key also ends in a checksum, so a truncated or mistyped key is refused before Rovenue looks it up.

The secret key is shown once, in that dialog. Rovenue stores only a hash of it and cannot show it again. Put it straight into your secret manager, never in source control, and never in an app or web bundle.

To rotate a key, use Roll on its row. Rovenue mints a new pair with the same label and environment, shows the new secret once, and keeps the old key working for the window you pick (immediately, 1 hour, 24 hours, 3 days or 7 days). Deploy the new key to your backend before the old one expires. Revoke cuts a key off immediately. Revoking the project's last Production key asks you to confirm first, because every Production app using it stops working.

Authenticating

Send the key as a Bearer token on every request:

GET /v1/subscribers/user_123/access HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: …
X-RateLimit-Limit: …
X-RateLimit-Remaining: …
X-RateLimit-Reset: …

{
  "data": {
    "access": {
      "pro": {
        "isActive": true,
        "expiresDate": "…",
        "store": "…",
        "productIdentifier": "pro_monthly"
      }
    }
  }
}

The key alone selects the project. No route takes a projectId, and none would trust one.

A request without the header is refused before anything else runs:

GET /v1/subscribers/user_123/access HTTP/1.1
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "error": {
    "code": "BEARER_REQUIRED",
    "message": "…"
  }
}

The other two refusals are INVALID_API_KEY_FORMAT (the token is not a well-formed rov_pk_ or rov_sk_ key, or its checksum does not match) and INVALID_API_KEY (unknown or revoked key). Both answer 401.

Which key each route takes

KeyRoutes
Secret onlyGET /v1/subscribers/{appUserId}/access · POST /v1/subscribers/{appUserId}/attributes · POST /v1/subscribers/{appUserId}/restore · POST /v1/subscribers/transfer · GET /v1/virtual-currencies/{appUserId} · GET /v1/virtual-currencies/{appUserId}/transactions · POST /v1/virtual-currencies/{appUserId}/{code}/grants · POST /v1/virtual-currencies/{appUserId}/{code}/transactions · POST /v1/virtual-currencies/{appUserId}/{code}/transactions/{referenceId}/reverse · the four DSAR routes
Public onlyPOST /v1/events · POST /v1/sdk/sessions — SDK telemetry
EitherEverything else: offerings, placements, remote config, experiments, receipts, identify, /v1/me/*

The /v1/me/* routes act on the subscriber named by the X-Rovenue-App-User-Id header. They exist for the SDK; from a server, prefer the /v1/subscribers/{appUserId}/… routes, which name the subscriber in the path.

Sending the public key to a secret-only route is refused:

GET /v1/subscribers/user_123/access HTTP/1.1
Authorization: Bearer <rov_pk_live_…>
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": {
    "code": "FORBIDDEN",
    "message": "Secret API key required"
  }
}

Addressing a subscriber

The {appUserId} path segment accepts either identifier you hold:

  • the app user id your app passed to identify() — the one your backend knows;
  • the device's rovenueId.

Rovenue tries the rovenueId first, then the app user id. Any other id the customer has had works too: an earlier device rovenueId, an app user id a merge retired, the srv_… id of a buyer Rovenue recorded before the app did, or its store identifier (stripe:<customer id>, apple:<originalTransactionId>). When the id belongs to a subscriber that was later merged into another one, the request lands on the surviving subscriber. An id Rovenue has never seen, or an erased subscriber, answers 404 SUBSCRIBER_NOT_FOUND:

GET /v1/subscribers/nobody_here/access HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "error": {
    "code": "SUBSCRIBER_NOT_FOUND",
    "message": "Subscriber nobody_here not found"
  }
}

Server-side routes never create a subscriber. A user exists once your app has called identify() (or bought something). See Identifying users for how the two identifiers relate.

Recipes

Check access

GET /v1/subscribers/{appUserId}/access returns the access map shown under Authenticating. Each key is an access identifier you defined in the dashboard (pro above). Gate on isActive; expiresDate is null for a lifetime grant. A subscriber with no access gets "access": {}.

Set attributes

Attributes are string key/value pairs. A null value deletes the key; keys you leave out are unchanged:

POST /v1/subscribers/user_123/attributes HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
Content-Type: application/json

{
  "attributes": {
    "signup_source": "web",
    "crm_tier": "gold"
  }
}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "subscriber": {
      "id": "…",
      "appUserId": "user_123",
      "attributes": {
        "signup_source": "web",
        "crm_tier": "gold"
      }
    }
  }
}

The response lists all of the subscriber's attributes after the write, not only the keys you sent — the SDK may already have set some (such as platform).

Custom keys are 1–40 characters of A–Z a–z 0–9 _ . -; values are at most 500 characters; a subscriber holds at most 50 custom keys. Keys starting with $ are reserved — see Subscriber identity attributes.

Merge two accounts

When your login system decides two app user ids are one person, move one into the other:

POST /v1/subscribers/transfer HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
Content-Type: application/json

{
  "fromAppUserId": "user_456",
  "toAppUserId": "user_123"
}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "fromSubscriberId": "…",
    "toSubscriberId": "…",
    "creditsTransferred": 0
  }
}

The source is looked up by app user id first. When no subscriber carries that app user id, any other current id of the customer works too: a device's Rovenue id, or a store identifier of a buyer Rovenue recorded before the app did (stripe:<customer id>, a server-minted srv_… id). An app user id a merge already retired is not current: it answers 400 … has already been transferred. The target is looked up by app user id, else by Rovenue id. A buyer Rovenue recorded server-side can only be a source: naming one as the target answers 409 UNCLAIMED_BUYER_TARGET, because an unclaimed buyer never absorbs another customer. It is not retryable: swap the sides and merge the buyer into the customer. The source's virtual-currency balances move to the target and the source is retired as merged, so later calls for user_456 reach user_123's subscriber:

GET /v1/subscribers/user_456/access HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "access": {
      "pro": {
        "isActive": true,
        "productIdentifier": "pro_monthly"
      }
    }
  }
}

A transfer onto itself, from or to an unknown id, or from an already-merged subscriber answers 400. Identifying users explains when to merge on the server and when identify() already did it.

Re-sync access

POST /v1/subscribers/{appUserId}/restore recomputes the subscriber's access from the purchases Rovenue already holds. You may pass store receipts in receipts to verify them first; with no body it only re-syncs:

POST /v1/subscribers/user_123/restore HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
Content-Type: application/json

{}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "access": {
      "pro": {
        "isActive": true
      }
    },
    "restored": [],
    "failed": []
  }
}

A receipt Rovenue refuses never fails the others: it is listed in failed with its error code.

Grant, spend and reverse virtual currency

RouteWhat it does
POST /v1/virtual-currencies/{appUserId}/{code}/grantsCredits units (support tools, goodwill credits).
POST /v1/virtual-currencies/{appUserId}/{code}/transactionsDebits units for work your backend did.
POST /v1/virtual-currencies/{appUserId}/{code}/transactions/{referenceId}/reverseReverses a spend in full when that work failed.
GET /v1/virtual-currencies/{appUserId}Reads balances (balances: { CODE: n }), plus expiring: { CODE: [{ amount, expiresAt }] }: units from expiring subscription grants that are still unspent, soonest expiry first.
GET /v1/virtual-currencies/{appUserId}/transactionsReads the transaction history, newest first.

Grants and spends take a required referenceId that makes retries safe. The request bodies, the 409 cases and the reversal rules are in Virtual Currencies.

Export or erase a user's data

The four DSAR routes are covered in Self-Service DSAR API.

Responses and errors

Every response is JSON in one of two shapes:

{ "data": … }                                         success
{ "error": { "code": "…", "message": "…", "details": … } }   failure — details is optional

Branch on error.code, never on message: codes are stable, messages are for humans. A request body that fails validation answers 400 VALIDATION_ERROR, with the rejected fields in details.fields:

POST /v1/subscribers/transfer HTTP/1.1
Authorization: Bearer <rov_sk_live_…>
Content-Type: application/json

{
  "fromAppUserId": "user_456"
}
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": {
      "fields": [
        { "path": "toAppUserId", "code": "invalid_type" }
      ]
    }
  }
}

Every response carries an X-Request-Id header. Send your own X-Request-Id and Rovenue echoes it back; otherwise it generates one. Log it — it is what to quote when you report a problem.

The full list of codes is in API Errors.

Rate limits

LimitDefault
Per secret key500 requests per minute (one bucket per key, across all your servers)
Per client IP, every route1000 requests per minute
DSAR export and erasure5 requests per minute

Self-hosted deployments set the first two with SDK_KEY_RATE_LIMIT_PER_MIN and GLOBAL_IP_RATE_LIMIT_PER_MIN.

Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Over the limit you get 429 RATE_LIMITED with a Retry-After header in seconds; wait that long before retrying.

Retries and idempotency

Retry on network errors, 429, and 503 (the response's Retry-After says when). Do not retry other 4xx responses unchanged — they will fail the same way.

These writes are safe to retry:

  • Virtual-currency grants and spends deduplicate on the referenceId you send. A retry with the same referenceId applies once. A reversal is idempotent on its own: a second call returns the same reversal.
  • Receipt submissions, grants and spends also accept an Idempotency-Key header (up to 255 characters). Rovenue caches a successful response for 24 hours; a retry with the same key gets that response back with Idempotent-Replay: true, and the same key on a different method, path or body is refused with 422. For grants and spends, referenceId remains the guarantee — the cache is skipped when Redis is unavailable.
Idempotency-Key: 6f1c9a2e-debit-order-8841

Attribute writes, transfers and restores are not deduplicated, but they converge: repeating one leaves the subscriber in the same state (a repeated transfer answers 400, because the source is already merged).

Receiving project webhooks

Rovenue can also call your backend. Set it up in the dashboard under SDK & API → Webhooks → Manage webhooks:

  1. Rotate secret — Rovenue shows a signing secret (whsec_…) once. Store it like the API key.
  2. Enter your Endpoint URL and save.

Each delivery is a JSON POST with three headers:

HeaderValue
x-rovenue-event-idThe delivery's id. Use it to deduplicate retries.
x-rovenue-timestampUnix seconds when the delivery was signed.
x-rovenue-signaturet=<timestamp>,v1=<hex HMAC-SHA256>

The HMAC key is the whole secret string, whsec_ prefix included. The signed message is the timestamp, a dot, and the raw request body exactly as received — verify before you parse the JSON, because re-serialising it changes the bytes:

verify-rovenue-webhook.js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verifyRovenueWebhook(rawBody, signatureHeader, secret, nowMs = Date.now()) {
  const parts = Object.fromEntries(
    String(signatureHeader ?? '')
      .split(',')
      .map((part) => part.split('=', 2)),
  );
  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp) || !parts.v1) return false;
  if (Math.abs(nowMs / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  const received = Buffer.from(parts.v1, 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}

Reject a delivery that fails verification with any 4xx. Answer 2xx quickly once you have stored it; Rovenue retries failed deliveries for 24 hours. The payload and the events it carries are described in Reacting to changes → Project webhook.

Fanning events out to several endpoints, or to tools like Slack and Amplitude, is a separate feature with its own signature scheme: see Outbound webhooks.

API Explorer · API Errors · Identifying users · Virtual Currencies · Self-Service DSAR API · Reacting to changes

On this page