A complete PHP integration in four files

Let creators on your site connect their BentBox account, upload videos to it, and follow each one until it's live. Plain PHP with cURL and OpenSSL, no framework, no packages. Copy it into your site, set four environment variables, and link to it.

How it works

Everything that touches BentBox runs on your server. The creator's browser never sees a token, your client secret or a video ID.

  1. The creator clicks “Connect BentBox” bentbox_connect.php It stores a random state in an encrypted cookie and redirects to BentBox's consent screen.
  2. They sign in and approve on BentBox BentBox sends them back to your callback with a short-lived code and the same state.
  3. Your server exchanges the code bentbox_callback.php It checks state, swaps the code for tokens at POST /oauth/token using your client secret, and stores them in an encrypted, HttpOnly cookie.
  4. The creator uploads a video bentbox_upload.php They choose a file and say who's in it. Your server calls POST /v1/content/video and POST /v1/content/upload-url; the browser sends the file straight to the presigned URL.
  5. They follow it until it's live bentbox_upload.php “Your uploads” checks GET /v1/content/video/{video_id} through your server and links to BentBox when the creator needs to publish or finish a setting.

Setup

You need PHP 7.4 or later with the curl and openssl extensions, served over HTTPS, and BentBox OAuth credentials. Email api@bentbox.co for credentials, and include the URL where bentbox_callback.php will live.

  1. Copy the four PHP files into a folder on your siteFor example /bentbox/.
  2. Register the callback URL with BentBoxThe full URL of bentbox_callback.php, exactly: same scheme, host and path, no trailing slash.
  3. Set the environment variablesIn your web server or PHP-FPM pool config, not in the files.
  4. Link to bentbox_connect.phpFrom wherever creators manage their account on your site.
VariableValue
BENTBOX_CLIENT_IDYour client ID, mp_live_…
BENTBOX_CLIENT_SECRETYour client secret, mp_secret_…
BENTBOX_REDIRECT_URIThe callback URL you registered, exactly
BENTBOX_COOKIE_KEYA random 32-byte key, base64 (see below). The same on every server.
BENTBOX_COOKIE_DOMAINOptional. .example.com to share the connection across www and the bare domain.
Setup
# Generate the cookie key
php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"

# PHP-FPM pool file (e.g. /etc/php/8.3/fpm/pool.d/www.conf), then reload PHP-FPM
env[BENTBOX_CLIENT_ID]     = mp_live_…
env[BENTBOX_CLIENT_SECRET] = mp_secret_…
env[BENTBOX_REDIRECT_URI]  = https://example.com/bentbox/bentbox_callback.php
env[BENTBOX_COOKIE_KEY]    = …

The files

The code below is exactly what the downloads contain.

SharedSettings, the encrypted-cookie token store, and every call to the BentBox API.

Download
<?php
/**
 * bentbox_config.php
 * Settings, token store and HTTP helpers for the BentBox integration.
 * Include this at the top of every BentBox page.
 *
 * SECURITY MODEL
 *   - client_secret and all tokens live ONLY on the server.
 *   - The browser never receives access_token, refresh_token or connection_id.
 *   - Token exchange and every BentBox API call happen server-side (cURL).
 *   - No PHP sessions: state lives in encrypted cookies, so this works on
 *     several servers behind a load balancer as long as they share BB_COOKIE_KEY.
 *
 * SETTINGS come from environment variables (set them in your web server or
 * PHP-FPM pool config). Never hard-code secrets in a web-served file.
 *
 *   BENTBOX_CLIENT_ID      your OAuth client ID (mp_live_…)
 *   BENTBOX_CLIENT_SECRET  your OAuth client secret (mp_secret_…)
 *   BENTBOX_REDIRECT_URI   full URL of bentbox_callback.php, exactly as registered with BentBox
 *   BENTBOX_COOKIE_KEY     base64 32-byte key:  php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"
 *   BENTBOX_COOKIE_DOMAIN  optional, e.g. ".example.com" to share the cookie across www + apex
 */

function bb_env(string $name, ?string $default = null): string {
    $v = getenv($name);
    if ($v === false || $v === '') {
        if ($default !== null) return $default;
        http_response_code(500);
        exit("BentBox integration is not configured: set the $name environment variable.");
    }
    return $v;
}

// --- Your settings -----------------------------------------------------------
define('BB_CLIENT_ID',     bb_env('BENTBOX_CLIENT_ID'));
define('BB_CLIENT_SECRET', bb_env('BENTBOX_CLIENT_SECRET'));
define('BB_REDIRECT_URI',  bb_env('BENTBOX_REDIRECT_URI'));
define('BB_COOKIE_KEY',    bb_env('BENTBOX_COOKIE_KEY'));
define('BB_COOKIE_DOMAIN', bb_env('BENTBOX_COOKIE_DOMAIN', ''));   // '' = current host only

const BB_SCOPES = 'upload_video';

// false = the browser uploads the file straight to the presigned storage URL
//         (best for large files; the short-lived URL is used by JS, never shown).
// true  = the file goes browser -> your server -> storage, so nothing storage-
//         related reaches the browser (heavier; limited by PHP upload limits).
const BB_PROXY_UPLOAD = false;

// Encrypted cookies
const BB_CONN_COOKIE  = 'bb_conn';            // the connection (tokens)
const BB_CONN_TTL     = 30 * 24 * 3600;       // 30 days, long enough to keep the refresh_token
const BB_STATE_COOKIE = 'bb_oauth_state';     // CSRF state during the OAuth round trip
const BB_STATE_TTL    = 15 * 60;              // 15 minutes to sign in and approve

// --- BentBox endpoints -------------------------------------------------------
const BB_API_BASE      = 'https://api.bentbox.co';
const BB_AUTHORIZE_URL = BB_API_BASE . '/oauth/authorize';
const BB_TOKEN_URL     = BB_API_BASE . '/oauth/token';
const BB_CREATE_VIDEO  = BB_API_BASE . '/v1/content/video';
const BB_UPLOAD_URL    = BB_API_BASE . '/v1/content/upload-url';
const BB_VIDEO_STATUS  = BB_API_BASE . '/v1/content/video/';   // + {video_id}

/** Cookie options shared by every cookie this integration sets. */
function bb_cookie_opts(int $expires): array {
    $o = ['expires' => $expires, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax'];
    if (BB_COOKIE_DOMAIN !== '') $o['domain'] = BB_COOKIE_DOMAIN;
    return $o;
}

/**
 * ---- Token store (encrypted cookie, no server-side state) -------------------
 * The connection (access_token, refresh_token, connection_id, user_id, scopes)
 * is stored in ONE cookie, AES-256-GCM encrypted and authenticated with
 * BB_COOKIE_KEY. The browser can't read or forge it (HttpOnly + GCM auth tag),
 * and any of your servers can decrypt it — no sessions, no DB, no shared storage.
 *
 * An in-request cache mirrors writes so bb_get_connection() reflects a
 * bb_store_connection() call made earlier in the SAME request (e.g. after a
 * token refresh), since $_COOKIE isn't updated mid-request.
 */
function &bb_conn_cache(): array {
    static $c = ['loaded' => false, 'data' => null];
    return $c;
}

function bb_store_connection(array $tokens): void {
    $existing = bb_get_connection() ?? [];
    $data = [
        'access_token'  => $tokens['access_token']  ?? $existing['access_token']  ?? null,
        'refresh_token' => $tokens['refresh_token'] ?? $existing['refresh_token'] ?? null,
        'connection_id' => $tokens['connection_id'] ?? $existing['connection_id'] ?? null,
        'user_id'       => $tokens['user_id']       ?? $existing['user_id']       ?? null,
        'scopes'        => $tokens['scopes']        ?? $existing['scopes']        ?? [],
        'expires_at'    => isset($tokens['expires_in'])
                            ? time() + (int)$tokens['expires_in']
                            : ($existing['expires_at'] ?? 0),
    ];

    setcookie(BB_CONN_COOKIE, bb_cookie_encode($data), bb_cookie_opts(time() + BB_CONN_TTL));

    $cache = &bb_conn_cache();
    $cache['loaded'] = true;
    $cache['data']   = $data;
}

function bb_get_connection(): ?array {
    $cache = &bb_conn_cache();
    if (!$cache['loaded']) {
        $cache['data']   = bb_cookie_decode($_COOKIE[BB_CONN_COOKIE] ?? '');
        $cache['loaded'] = true;
    }
    return $cache['data'];
}

function bb_is_connected(): bool {
    $c = bb_get_connection();
    return $c && !empty($c['access_token']) && !empty($c['connection_id']);
}

function bb_clear_connection(): void {
    setcookie(BB_CONN_COOKIE, '', bb_cookie_opts(time() - 3600));
    $cache = &bb_conn_cache();
    $cache['loaded'] = true;
    $cache['data']   = null;
}

/**
 * ---- Cookie encryption (AES-256-GCM, url-safe base64) -----------------------
 */
function bb_cookie_key(): string {
    $key = base64_decode(BB_COOKIE_KEY, true);
    if ($key === false || strlen($key) !== 32) {
        throw new RuntimeException(
            'BENTBOX_COOKIE_KEY must be a base64-encoded 32-byte key. Generate one with: ' .
            'php -r "echo base64_encode(random_bytes(32)).PHP_EOL;"'
        );
    }
    return $key;
}

function bb_b64url(string $bin): string {
    return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
}

function bb_b64url_decode(string $s) {
    return base64_decode(strtr($s, '-_', '+/'));
}

function bb_cookie_encode(array $data): string {
    $iv  = random_bytes(12);
    $tag = '';
    $ct  = openssl_encrypt(json_encode($data), 'aes-256-gcm', bb_cookie_key(),
                           OPENSSL_RAW_DATA, $iv, $tag);
    if ($ct === false) throw new RuntimeException('Cookie encryption failed');
    return bb_b64url($iv . $tag . $ct);   // iv(12) | tag(16) | ciphertext
}

function bb_cookie_decode(string $raw): ?array {
    if ($raw === '') return null;
    $bin = bb_b64url_decode($raw);
    if ($bin === false || strlen($bin) < 28) return null;   // 12 + 16 minimum
    $iv  = substr($bin, 0, 12);
    $tag = substr($bin, 12, 16);
    $ct  = substr($bin, 28);
    $pt  = openssl_decrypt($ct, 'aes-256-gcm', bb_cookie_key(), OPENSSL_RAW_DATA, $iv, $tag);
    if ($pt === false) return null;   // tampered, wrong key, or corrupt
    $data = json_decode($pt, true);
    return is_array($data) ? $data : null;
}

/**
 * Server-side JSON POST. Sends the token both as a Bearer header and (by the
 * caller) in the body, since the content endpoints read it from the body.
 */
function bb_http_post_json(string $url, array $body, ?string $bearer = null): array {
    $headers = ['Content-Type: application/json', 'Accept: application/json'];
    if ($bearer) $headers[] = 'Authorization: Bearer ' . $bearer;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($body),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    $raw  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = curl_error($ch);
    curl_close($ch);

    return ['http' => $code, 'json' => json_decode($raw, true), 'raw' => $raw, 'error' => $err];
}

/** Server-side JSON GET with a Bearer token. */
function bb_http_get_json(string $url, ?string $bearer = null): array {
    $headers = ['Accept: application/json'];
    if ($bearer) $headers[] = 'Authorization: Bearer ' . $bearer;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    $raw  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err  = curl_error($ch);
    curl_close($ch);

    return ['http' => $code, 'json' => json_decode($raw, true), 'raw' => $raw, 'error' => $err];
}

/**
 * ---- OAuth round trip -------------------------------------------------------
 * bb_authorize_url() makes a random state, keeps it in a short-lived encrypted
 * cookie and returns the BentBox consent URL. bb_check_state() verifies the
 * state that comes back on the callback, once.
 */
function bb_authorize_url(): string {
    $state = bin2hex(random_bytes(16));
    setcookie(BB_STATE_COOKIE, bb_cookie_encode(['s' => $state, 't' => time()]),
              bb_cookie_opts(time() + BB_STATE_TTL));
    return BB_AUTHORIZE_URL . '?' . http_build_query([
        'client_id'     => BB_CLIENT_ID,
        'redirect_uri'  => BB_REDIRECT_URI,
        'scope'         => BB_SCOPES,
        'state'         => $state,
        'response_type' => 'code',
    ]);
}

function bb_check_state(string $returned): bool {
    $saved = bb_cookie_decode($_COOKIE[BB_STATE_COOKIE] ?? '');
    setcookie(BB_STATE_COOKIE, '', bb_cookie_opts(time() - 3600));   // single use
    return $saved
        && isset($saved['s'], $saved['t'])
        && time() - (int)$saved['t'] <= BB_STATE_TTL
        && hash_equals((string)$saved['s'], $returned);
}

/** Exchange the authorization code for tokens and store them. */
function bb_exchange_code(string $code): array {
    $r = bb_http_post_json(BB_TOKEN_URL, [
        'grant_type'    => 'authorization_code',
        'code'          => $code,
        'client_id'     => BB_CLIENT_ID,
        'client_secret' => BB_CLIENT_SECRET,
        'redirect_uri'  => BB_REDIRECT_URI,
    ]);
    if (empty($r['json']['access_token']) || empty($r['json']['connection_id'])) {
        return ['ok' => false, 'error' => bb_error_text($r, 'Could not connect your BentBox account')];
    }
    bb_store_connection($r['json']);
    return ['ok' => true];
}

/**
 * Refresh the access token using the stored refresh token. Updates the store
 * on success (standard OAuth refresh_token grant).
 */
function bb_refresh(array &$conn): bool {
    if (empty($conn['refresh_token'])) return false;
    $r = bb_http_post_json(BB_TOKEN_URL, [
        'grant_type'    => 'refresh_token',
        'refresh_token' => $conn['refresh_token'],
        'client_id'     => BB_CLIENT_ID,
        'client_secret' => BB_CLIENT_SECRET,
    ]);
    if (!empty($r['json']['access_token'])) {
        bb_store_connection(array_merge($conn, $r['json']));
        $conn = bb_get_connection();
        return true;
    }
    return false;
}

/**
 * Publish, part 1: create the video, then get a presigned upload URL.
 * (Part 2, sending the file, happens in bentbox_upload.php.)
 * Retries once after a token refresh on 403 (expired token).
 */
function bb_create_and_sign(array $meta, bool $allowRefresh = true): array {
    $conn  = bb_get_connection();
    $token = $conn['access_token'];

    $body = [
        'access_token'  => $token,
        'connection_id' => $conn['connection_id'],
        'title'         => $meta['title'],
        'filename'      => $meta['filename'],
        'filesize'      => $meta['filesize'],
        'duration'      => $meta['duration'],
        'description'   => $meta['description'],
        'tags'          => $meta['tags'],
        'price'         => $meta['price'],
        'is_premium'    => $meta['is_premium'],
    ];

    // Consent: BentBox requires sole_performer = true OR a ProntoID
    // release_form_id. Only send what the creator actually chose.
    if (array_key_exists('sole_performer', $meta)) {
        $body['sole_performer'] = (bool)$meta['sole_performer'];
    }
    if (!empty($meta['release_form_id'])) {
        $body['release_form_id'] = $meta['release_form_id'];
    }
    // Optional: false keeps the video as a draft the creator publishes on BentBox.
    if (array_key_exists('publish_on_ready', $meta)) {
        $body['publish_on_ready'] = (bool)$meta['publish_on_ready'];
    }

    $create = bb_http_post_json(BB_CREATE_VIDEO, $body, $token);

    if ($create['http'] === 403 && $allowRefresh && bb_refresh($conn)) {
        return bb_create_and_sign($meta, false); // retry once with fresh token
    }
    if (empty($create['json']['success']) || empty($create['json']['video_id'])) {
        return ['ok' => false, 'stage' => 'create_metadata', 'http' => $create['http'],
                'error' => bb_error_text($create, 'Failed to create metadata')];
    }

    $videoId = $create['json']['video_id'];

    $sign = bb_http_post_json(BB_UPLOAD_URL, [
        'access_token'  => $token,
        'connection_id' => $conn['connection_id'],
        'video_id'      => $videoId,
        'title'         => $meta['title'],
        'price'         => $meta['price'],
        'duration'      => $meta['duration'],
        'description'   => $meta['description'],
        'tags'          => $meta['tags'],
        'adult_content' => $meta['adult_content'],
    ], $token);

    if (empty($sign['json']['success']) || empty($sign['json']['upload_url'])) {
        return ['ok' => false, 'stage' => 'get_upload_url', 'http' => $sign['http'],
                'video_id' => $videoId,
                'error' => bb_error_text($sign, 'Failed to get upload URL')];
    }

    return ['ok' => true, 'video_id' => $videoId,
            'upload_url' => $sign['json']['upload_url'],
            'content_type' => $sign['json']['content_type'] ?? 'video/mp4'];
}

/**
 * Track: read a video's status (GET /v1/content/video/{video_id}).
 * Returns ['ok' => true, 'status' => …, …] or ['ok' => false, 'error' => …].
 * Retries once after a token refresh when the token is rejected.
 */
function bb_video_status(string $videoId, bool $allowRefresh = true): array {
    $conn = bb_get_connection();
    $url  = BB_VIDEO_STATUS . rawurlencode($videoId)
          . '?connection_id=' . rawurlencode($conn['connection_id']);

    $r = bb_http_get_json($url, $conn['access_token']);

    $code = $r['json']['error'] ?? '';
    if ($allowRefresh && in_array($r['http'], [401, 403], true)
        && in_array($code, ['invalid_token', 'unauthorized'], true) && bb_refresh($conn)) {
        return bb_video_status($videoId, false);
    }
    if (empty($r['json']['success'])) {
        return ['ok' => false, 'http' => $r['http'], 'code' => $code,
                'error' => bb_error_text($r, 'Could not read the video status')];
    }

    // Pass through only what the page needs.
    $keep = ['status', 'status_description', 'is_live', 'title',
             'video_page_url', 'video_settings_url', 'checked_at'];
    return ['ok' => true] + array_intersect_key($r['json'], array_flip($keep));
}

/**
 * Opaque, encrypted reference to a video for the browser, so the page can ask
 * for a status without ever holding the raw video_id. Bound to the current
 * connection: a ref from another connection is rejected.
 */
function bb_video_ref(string $videoId): string {
    $conn = bb_get_connection();
    return bb_cookie_encode(['v' => $videoId, 'c' => $conn['connection_id'] ?? '']);
}

function bb_video_from_ref(string $ref): ?string {
    $d = bb_cookie_decode($ref);
    $conn = bb_get_connection();
    if (!$d || empty($d['v']) || ($d['c'] ?? null) !== ($conn['connection_id'] ?? '')) return null;
    return (string)$d['v'];
}

/**
 * Best human-readable error from a BentBox response. The API puts it in
 * error_description; errors passed straight through from a Lambda often only
 * have a readable sentence in "error".
 */
function bb_error_text(array $resp, string $fallback): string {
    $j = $resp['json'] ?? [];
    if (!empty($j['error_description'])) return (string)$j['error_description'];
    if (!empty($j['error']) && strpos((string)$j['error'], ' ') !== false) return (string)$j['error'];
    if (!empty($j['error'])) return $fallback . ' (' . $j['error'] . ')';
    if (!empty($resp['error'])) return $fallback . ': ' . $resp['error'];
    return $fallback . ' (HTTP ' . ($resp['http'] ?? 0) . ')';
}

function bb_json($payload): void {
    header('Content-Type: application/json');
    echo json_encode($payload);
    exit;
}

Before you go live