Revenue Expansion beta

Send product usage events that Revenue Linter can score.

Product events tell Revenue Linter which paying accounts are active, dormant, or pushing against plan limits. They unlock silent-churn and expansion-ready cohorts without writing to your app, Stripe, or CRM.

Endpoint

POST product events with an ingest token.

Use the same ingest token you created for app-state snapshots. Include a stable app user ID and Stripe customer ID when available.

POST /api/v1/events/product
Authorization: Bearer <ingest_token>
Content-Type: application/json

{
  "events": [{
    "externalUserId": "user_123",
    "stripeCustomerId": "cus_123",
    "type": "PREMIUM_FEATURE_USED",
    "occurredAt": "2026-05-29T15:00:00.000Z",
    "properties": {
      "feature": "audit_export",
      "plan": "starter"
    }
  }]
}

Backend examples

Send events from your server, not the browser.

Keep REVLINT_INGEST_TOKEN in server-side environment variables. These examples are intentionally one-way and zero-write.

Next.js

Call Revenue Linter from a server action, route handler, or trusted server-side job.

const endpoint = process.env.REVLINT_PRODUCT_EVENTS_URL ?? "https://revenuelinter.com/api/v1/events/product";

await fetch(endpoint, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.REVLINT_INGEST_TOKEN}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    events: [{
      externalUserId: user.id,
      stripeCustomerId: user.stripeCustomerId,
      type: "API_LIMIT_USED",
      occurredAt: new Date().toISOString(),
      properties: {
        usage: apiUsageThisMonth,
        limit: apiLimit,
        unit: "api_calls"
      }
    }]
  })
});

Laravel

Send events from a controller, queued job, listener, or scheduled command.

use Illuminate\Support\Facades\Http;

Http::withToken(config('services.revlint.ingest_token'))
    ->post(config('services.revlint.product_events_url'), [
        'events' => [[
            'externalUserId' => (string) $user->id,
            'stripeCustomerId' => $user->stripe_customer_id,
            'type' => 'API_LIMIT_USED',
            'occurredAt' => now()->toIso8601String(),
            'properties' => [
                'usage' => $usageThisMonth,
                'limit' => $planLimit,
                'unit' => 'api_calls',
            ],
        ]],
    ]);

Rails

Post events from a service object, ActiveJob worker, or subscription usage job.

require "net/http"
require "json"

uri = URI(ENV.fetch("REVLINT_PRODUCT_EVENTS_URL", "https://revenuelinter.com/api/v1/events/product"))

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('REVLINT_INGEST_TOKEN')}"
request["Content-Type"] = "application/json"
request.body = {
  events: [{
    externalUserId: user.id.to_s,
    stripeCustomerId: user.stripe_customer_id,
    type: "SEAT_LIMIT_USED",
    occurredAt: Time.current.iso8601,
    properties: {
      seatsUsed: workspace.users.count,
      seatLimit: workspace.plan.seat_limit
    }
  }]
}.to_json

Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
  http.request(request)
end

Node

Use from a backend worker, API server, cron job, or billing reconciliation task.

const endpoint = process.env.REVLINT_PRODUCT_EVENTS_URL || "https://revenuelinter.com/api/v1/events/product";

await fetch(endpoint, {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.REVLINT_INGEST_TOKEN,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    events: [{
      externalUserId: String(user.id),
      stripeCustomerId: user.stripeCustomerId,
      type: "PREMIUM_FEATURE_USED",
      occurredAt: new Date().toISOString(),
      properties: {
        feature: "audit_export",
        plan: user.plan
      }
    }]
  })
});

Event types

Start with events that prove activity or pressure.

Revenue Linter accepts any product event type string, but these names make the Revenue Expansion intent clear.

PREMIUM_FEATURE_USEDAPI_LIMIT_USEDSEAT_LIMIT_USEDUSAGE_LIMIT_REACHEDEXPORT_CREATED

Expansion-ready accounts

Send usage and limit values when an account nears a cap.

Revenue Linter marks lower-tier accounts as expansion-ready when recent usage is high or usage reaches at least 80% of a reported limit.

POST /api/v1/events/product
Authorization: Bearer <ingest_token>

{
  "events": [{
    "externalUserId": "user_123",
    "stripeCustomerId": "cus_123",
    "type": "API_LIMIT_USED",
    "occurredAt": "2026-05-29T15:05:00.000Z",
    "properties": {
      "usage": 91000,
      "limit": 100000,
      "metric": "api_requests",
      "window": "monthly"
    }
  }, {
    "externalUserId": "user_456",
    "stripeCustomerId": "cus_456",
    "type": "SEAT_LIMIT_USED",
    "occurredAt": "2026-05-29T15:06:00.000Z",
    "properties": {
      "seatsUsed": 9,
      "seatLimit": 10
    }
  }]
}

Properties Revenue Linter reads

Use one usage key and one limit key.

These fields are enough for Revenue Linter to calculate a usage ratio and label the exported signal.

usagelimitcurrentUsageusageLimitseatsUsedseatLimitquantitymax

Silent churn

Any recent product event can prove activity.

Silent churn is flagged when Stripe billing is active but Revenue Linter has no recent app activity or product usage events. Send login, feature, export, dashboard, or API usage events for accounts that should count as active.

{
  "events": [{
    "externalUserId": "user_789",
    "stripeCustomerId": "cus_789",
    "type": "EXPORT_CREATED",
    "occurredAt": "2026-05-29T15:10:00.000Z",
    "properties": {
      "exportType": "audit_report"
    }
  }]
}

Safety notes

Do not send secrets

The product event API rejects secret-like payloads. Do not include API keys, tokens, passwords, cookies, or raw credentials.

Prefer IDs over personal data

Use externalUserId and stripeCustomerId. Email is optional and hashed before storage.

Keep follow-up team-approved

Revenue Expansion exports advisory cohorts. It does not write to your CRM or trigger marketing campaigns.