Skip to main content

Installation

composer require crovver/crovver-php
Requirements: PHP 8.0+, Composer.

Initialize the Client

use Crovver\CrovverClient;
use Crovver\CrovverConfig;

$client = new CrovverClient(new CrovverConfig(
    apiKey: getenv('CROVVER_SECRET_KEY'),
));

Configuration Options

OptionTypeDefaultDescription
apiKeystringrequiredYour secret API key (sk_live_...)
timeoutint30Request timeout in seconds
maxRetriesint3Retry attempts for server errors
debugboolfalseLog requests and responses
loggercallablenullCustom logger: fn($msg, $ctx)

Tenant Management

use Crovver\Types\CreateTenantRequest;

// Create a tenant (B2B: workspace or user)
$tenant = $client->createTenant(new CreateTenantRequest(
    externalTenantId: 'workspace_abc123',
    name: 'Acme Corp',
    externalUserId: 'user_123',
));

// Retrieve a tenant
$tenant = $client->getTenant('workspace_abc123');

Plans

$plans = $client->getPlans();

foreach ($plans->plans as $plan) {
    echo $plan->name . ' - $' . ($plan->priceAmount / 100) . '/mo';
}

Subscriptions

$result = $client->getSubscriptions('workspace_abc123');

foreach ($result->subscriptions as $sub) {
    echo $sub->status; // active, trial, past_due, pending_cancel, canceled, expired
}

Feature Entitlements

// Single-product org
$canAccess = $client->canAccess('workspace_abc123', 'advanced_analytics');

// Multi-product: scope to a specific product
$canAccess = $client->canAccess('workspace_abc123', 'advanced_analytics', 'product-a');

// Subscription-existence check — does this tenant have any active sub for product-a?
$hasProduct = $client->canAccess('workspace_abc123', '', 'product-a');

Credits

Credits are the preferred way to track and gate consumption in Crovver. Each credit pool has a key (e.g. "ai_generations") and is refilled by plan entitlements or add-on purchases.

Check balance

$balance = $client->getCreditsBalance('workspace_abc123');

foreach ($balance['pools'] as $pool) {
    echo $pool['poolKey'] . ': ' . $pool['remaining'] . ' remaining';
}

Consume credits

consumeCredits is idempotent — passing the same $idempotencyKey twice returns the original result without double-deducting.
$result = $client->consumeCredits(
    tenantId: 'workspace_abc123',
    poolKey: 'ai_generations',
    amount: 1,
    idempotencyKey: bin2hex(random_bytes(16)), // stable per user action
    metadata: ['prompt' => 'summarise report'], // optional
);

if ($result->result === 'success') {
    echo 'Remaining: ' . $result->remaining;
} else {
    // result === 'insufficient_credits'
    // Prompt the user to purchase an add-on or upgrade
}
consumeCredits parameters:
ParameterTypeRequiredDescription
tenantIdstringYesYour tenant/workspace ID
poolKeystringYesCredit pool identifier defined in your plan
amountintYesUnits to deduct
idempotencyKeystringYesUnique key per action; prevents double-deduction
metadataarray|nullNoArbitrary key/value pairs stored with the event
ConsumeResponse fields:
FieldTypeDescription
resultstring"success" or "insufficient_credits"
remainingintCredits left in the pool after deduction
alreadyProcessedbooltrue when the idempotency key was already seen
poolKeystringEchoed pool key

Add-ons

Add-ons let tenants purchase extra credit packs without changing their base plan.

List available add-ons

$addons = $client->listAvailableAddons('workspace_abc123');

foreach ($addons as $addon) {
    echo $addon['name'] . ': ' . $addon['creditQty'] . ' credits — '
        . $addon['currency'] . ' ' . $addon['amount'];
}

Purchase an add-on

$purchase = $client->purchaseAddon(
    externalTenantId: 'workspace_abc123',
    addonId: 'addon_uuid',
    currency: 'USD',
    idempotencyKey: bin2hex(random_bytes(16)),
    successUrl: 'https://yourapp.com/billing/success',
    cancelUrl: 'https://yourapp.com/billing/cancel',
);

if ($purchase->requiresPayment) {
    // Redirect to Stripe checkout
    header('Location: ' . $purchase->checkoutUrl);
    exit;
} else {
    // Credits added immediately (free add-on or already processed)
    echo $purchase->creditQty . ' credits added';
}
AddonPurchaseResponse fields:
FieldTypeDescription
purchaseIdstringUnique purchase record ID
checkoutUrlstring|nullStripe checkout URL — present when requiresPayment is true
requiresPaymentbooltrue when a checkout session was created
addonNamestringDisplay name of the add-on
creditQtyintCredits that will be added on completion
amountfloatCharge amount in major currency units
currencystringCurrency code (e.g. "USD")
alreadyProcessedbooltrue when the idempotency key was already seen

Get active add-on credits

$active = $client->getActiveAddonCredits('workspace_abc123');

foreach ($active as $pool) {
    echo $pool['poolKey'] . ': ' . $pool['remaining'] . ' remaining';
}

Usage Tracking (deprecated)

recordUsage and checkUsageLimit are deprecated. They write to a simple event log with no idempotency and do not integrate with the credit pool system. Use consumeCredits instead.
// ❌ Deprecated — use consumeCredits() instead
$client->recordUsage('workspace_abc123', 'api_calls', 1);

// ❌ Deprecated — use getCreditsBalance() instead
$limit = $client->checkUsageLimit('workspace_abc123', 'api_calls');
echo $limit->current . ' / ' . $limit->limit;

Checkout

use Crovver\Types\CreateCheckoutSessionRequest;

$session = $client->createCheckoutSession(new CreateCheckoutSessionRequest(
    externalTenantId: 'workspace_abc123',
    planId: 'plan_pro',
    currency: 'USD',                                    // optional; defaults to plan's first price
    successUrl: 'https://yourapp.com/billing/success',
    cancelUrl: 'https://yourapp.com/billing/cancel',
));

// Redirect user to $session->checkoutUrl

Seat Management

Use these methods on seat-based plans to track which users occupy seats. This is separate from recordUsage — seat allocation is for per-user billing, not credit/quota consumption.

Get Seat Count

$count = $client->getSeatCount('workspace_abc123');

echo $count->activeCount;           // Active allocated seats
echo $count->capacityUnits;         // Total seats in subscription
echo $count->utilizationPercentage; // activeCount / capacityUnits × 100
echo $count->billingMode;           // recurring | manual | free
echo $count->exceeded ? 'Over limit' : 'Within limit';

Allocate a Seat

Call this when a user joins a workspace to record their seat allocation.
use Crovver\Types\AllocateSeatRequest;

$result = $client->allocateSeat(new AllocateSeatRequest(
    requestingEntityId: 'workspace_abc123',
    externalUserId: 'user_456',
    email: 'alice@acme.com',
    name: 'Alice',
));

if ($result->success) {
    echo 'Seat allocated. Active seats: ' . $result->capacity->activeCount;
}
If the workspace is at capacity and the plan is seat-based, Crovver returns a proration preview instead of allocating the seat:
$result = $client->allocateSeat(new AllocateSeatRequest(
    requestingEntityId: 'workspace_abc123',
    externalUserId: 'user_789',
));

if ($result->requiresProration) {
    // Show the user the upgrade cost before proceeding
    echo 'Adding this seat costs ' . $result->proration->amount . ' ' . $result->proration->currency;
    echo ' for ' . $result->proration->daysRemaining . ' remaining days in the billing period.';

    // Then call createProrationCheckout to start payment
    $checkout = $client->createProrationCheckout(
        externalTenantId: 'workspace_abc123',
        newCapacity: $result->proration->newSeats,
        successUrl: 'https://yourapp.com/seats?upgraded=1',
        cancelUrl: 'https://yourapp.com/seats',
    );

    // Redirect to $checkout->checkoutUrl
}

AllocateSeatRequest Parameters

ParameterTypeRequiredDescription
requestingEntityIdstringyesYour tenant/workspace ID
externalUserIdstringyesYour user ID. Must be non-empty. The call is idempotent — re-allocating the same ID updates the record rather than creating a duplicate.
emailstringnoUser’s email for audit records
namestringnoUser’s display name for audit records
metadataarraynoArbitrary key/value pairs stored with the allocation

AllocateSeatResponse Fields

FieldTypeDescription
successbooltrue when a seat was recorded
requiresProrationbooltrue when capacity is full and a prorated upgrade is needed
requiresCheckoutbooltrue when proration was already confirmed but payment is still pending (rare)
capacityAllocateSeatCapacity|nullPresent on success: activeCount, capacityUnits, exceeded
prorationAllocateSeatProration|nullPresent when requiresProration is true

GetSeatCountResponse Fields

FieldTypeDescription
activeCountintNumber of active seat allocations
capacityUnitsintTotal seats included in the subscription
utilizationPercentageintactiveCount / capacityUnits × 100
billingModestringrecurring, manual, or free
exceededboolWhether activeCount > capacityUnits

Bulk Allocate Seats

Allocate up to 100 users in one atomic call. Users beyond capacity_units are returned in rejected — no proration is triggered. Already-active users are silently skipped (idempotent).
bulkAllocateSeats is never retried automatically to prevent duplicate allocations on network errors.
use Crovver\Types\BulkAllocateSeatsRequest;
use Crovver\Types\BulkAllocateSeatUser;

$result = $client->bulkAllocateSeats(new BulkAllocateSeatsRequest(
    requestingEntityId: 'workspace_abc123',
    users: [
        new BulkAllocateSeatUser('user_1', 'alice@acme.com', 'Alice'),
        new BulkAllocateSeatUser('user_2', 'bob@acme.com',   'Bob'),
        new BulkAllocateSeatUser('user_3', 'carol@acme.com', 'Carol'),
    ],
    metadata: ['addedBy' => 'admin_789'], // optional
));

echo count($result->allocated) . ' allocated';
echo count($result->skipped)   . ' skipped (already active)';
echo count($result->rejected)  . ' rejected (over capacity)';

echo $result->capacity->activeCount . ' / ' . $result->capacity->capacityUnits . ' seats';

if (count($result->rejected) > 0) {
    // Call createProrationCheckout to upgrade capacity, then retry $result->rejected
    echo $result->message;
}
Returns HTTP 207 when at least one user was rejected; 200 otherwise.

BulkAllocateSeatsRequest Parameters

ParameterTypeRequiredDescription
requestingEntityIdstringyesYour tenant/workspace ID
usersBulkAllocateSeatUser[]yesMax 100 per call. Each externalUserId must be unique within the batch.
metadataarraynoAttached to every allocation created in this batch

BulkAllocateSeatUser Parameters

ParameterTypeRequiredDescription
externalUserIdstringyesYour app’s user ID
emailstringnoUser’s email
namestringnoUser’s display name

BulkAllocateSeatsResponse Fields

FieldTypeDescription
allocatedstring[]User IDs inserted this call
skippedstring[]Already active — not re-inserted
rejectedstring[]Would exceed capacity — not inserted
capacity->activeCountintActive seat count after this operation
capacity->capacityUnitsintTotal seat limit on the subscription
messagestring|nullPresent when users were rejected

List Allocations

Returns a paginated list of users allocated to the tenant’s active subscription, along with a real-time capacity summary.
// Active users, first page (defaults)
$result = $client->getAllocations('workspace_abc123');

foreach ($result->allocations as $user) {
    echo $user->externalUserId . ' — ' . $user->email . ' (' . $user->status . ')';
    echo ' since ' . $user->allocatedAt;
}

// Capacity summary
echo $result->capacity->activeCount . ' / ' . $result->capacity->capacityUnits . ' seats';
echo $result->capacity->utilizationPercentage . '% utilized';

if ($result->capacity->exceeded) {
    // Prompt upgrade
}

// Pagination
echo $result->pagination->total . ' total — page '
    . $result->pagination->page . ' of ' . $result->pagination->totalPages;

// Page through all users including removed
$page2 = $client->getAllocations(
    requestingEntityId: 'workspace_abc123',
    status: 'all',    // 'active' | 'removed' | 'all'
    page: 2,
    limit: 25,        // max 100, default 50
);

getAllocations Parameters

ParameterTypeDefaultDescription
requestingEntityIdstringrequiredYour tenant/workspace ID
statusstring'active''active', 'removed', or 'all'
pageint11-based page number
limitint50Page size, max 100

AllocationUser Fields

FieldTypeDescription
externalUserIdstringYour app’s user ID
emailstring|nullUser’s email
namestring|nullUser’s display name
statusstring'active' or 'removed'
allocatedAtstringISO 8601 timestamp of seat assignment
removedAtstring|nullISO 8601 timestamp of deallocation
metadataarrayMetadata stored at allocation time

Proration Checkout

Call this whenever a tenant needs more seats — proactively from a settings page, or after presenting the preview returned by allocateSeat(). Crovver calculates the prorated charge for the remaining days in the billing period and creates a checkout session using the same payment provider as the tenant’s original subscription.
$checkout = $client->createProrationCheckout(
    externalTenantId: 'workspace_abc123',
    newCapacity: 25,
    successUrl: 'https://yourapp.com/seats?upgraded=1',
    cancelUrl: 'https://yourapp.com/seats',
);

if ($checkout->requiresPayment) {
    // Redirect to the provider's checkout page
    header('Location: ' . $checkout->checkoutUrl);
} else {
    // Seat count was updated without payment (e.g. within free allowance)
    echo $checkout->message;
}

createProrationCheckout Parameters

ParameterTypeRequiredDescription
externalTenantIdstringyesYour tenant/workspace ID
newCapacityintyesThe new total seat count (must exceed current capacityUnits)
planIdstringnoPlan ID if also switching plans at the same time
successUrlstringnoRedirect URL after successful payment
cancelUrlstringnoRedirect URL if the user cancels

ProrationCheckoutResponse Fields

FieldTypeDescription
prorationIdstringUnique ID for this proration calculation
requiresPaymentbooltrue when a Stripe checkout session was created
checkoutUrlstring|nullStripe checkout URL — redirect the user here when requiresPayment is true
prorationAmountfloatAmount charged in the plan’s currency (in major units, e.g. 17.50)
prorationDetailsarrayBreakdown: daysRemaining, totalDaysInPeriod, perSeatPrice, etc.
messagestringHuman-readable summary of the proration
Proration checkout is never retried automatically to prevent duplicate Stripe sessions.

Error Handling

use Crovver\CrovverError;

try {
    $result = $client->getSubscriptions('workspace_abc123');
} catch (CrovverError $e) {
    $e->getMessage();     // Human-readable message
    $e->getStatusCode();  // HTTP status code (null for network errors)
    $e->getErrorCode();   // API error code string
    $e->isRetryable();    // Whether the SDK already retried
}
5xx errors, 429, and 408 responses are retried automatically with exponential backoff. Checkout endpoints are never retried to prevent duplicate charges.