BGPVR Operator guide
Operator guide

Integrations & API Pro

Needs a Pro licence or higher.

Let your billing system, web shop or membership site create, renew and switch off BGPVR accounts by itself. This chapter describes exactly what BGPVR offers for that today, and what it does not do yet.

On this page
  1. Who it is for
  2. Two ways in, one set of rules
  3. API keys: create, store, revoke
  4. The provisioning request
  5. What each event does
  6. The same request twice
  7. Answers and errors
  8. Examples with curl
  9. Examples in PHP (WHMCS)
  10. Signed webhook receiver
  11. Discourse forum Pro
  12. Security
  13. Not available yet (planned)
  14. Watch out
This chapter describes BGPVR 2.9.318: update first

2.9.318 contains security fixes for the integration features and tightens staff permissions, so update to 2.9.318 before you connect a billing system. Also fixed in 2.9.318:

  • with welcome emails switched on, a create that contained an email address failed after the client had been saved, and the password was lost;
  • a renewal without package_id removed the client's package;
  • switching off a username that does not exist answered 200 "disabled" (now 404);
  • on Pro, pressing Save in Modules → Registration switched invite-code redemption off;
  • the two webhook test buttons worked only right after Regenerate, and “Disable Test Client” did not switch the test client off.

Who it is for

Operators who sell access through another system (WHMCS, HostBill, Blesta, a web shop, a payment platform, a membership site) and do not want to type every customer into the console. That system calls BGPVR when someone pays, renews, stops paying or leaves. If you want BGPVR itself to take the payments, you do not need this chapter: use Settings → Billing (Business plan).

Two ways in, one set of rules

API keySigned webhook
AddressPOST /api/ext/clients/provisionPOST /api/webhooks/provision
How the caller proves who it isX-API-Key headerX-Webhook-Signature header (HMAC-SHA256 of the body)
PlanAPI keys: Pro and upWebhooks: Pro and up
Switch in the consoleNone: works as soon as a key exists“Enable Webhook Auto-Provisioning” must be on
Speed limit60 requests a minute per key30 requests a minute per sending address
Best forWHMCS hooks, your own scripts and shopPlatforms that can only send signed webhooks

Both take the same JSON request and give the same answers. Use an API key unless your platform can only send signed webhooks.

What a request can do today: create a client; renew or reactivate one; move a client back to an earlier package; switch a client off (never delete); or, instead of an account, return single-use invite codes. Nothing else: there is no way yet to list, read, search, edit or delete clients through the API (see Not available yet at the end).

API keys: create, store, revoke

Only the administrator account can see, create and delete API keys (and, further down, regenerate the webhook secret and use the test buttons); staff logins get “Permission denied”. What else stays with the admin is listed under Staff Accounts in the Console reference below.

  1. In the sidebar open Settings → tab API & Security → card API & Integrations → Backend API Keys → Generate New API Key.
  2. In Key Name name it after the system that will use it (for example WHMCS) and press Save.
  3. The API Key Created window shows the key once. Copy it into your billing system now. BGPVR keeps only a salted one-way hash of it, so nobody can display it again. A lost key cannot be recovered: delete it and make a new one.
  • Format: bgpvr_k1_ followed by 48 hexadecimal characters (57 characters in total). The list shows only the first 16 characters, the name, “Last used” and an Active badge.
  • What a key can do: create, renew and switch off clients and make invite codes through the address above. Nothing else: it cannot read client data, sign in to the console or change settings. Every key gets the same permission; there is no per-key permission editor.
  • How many: Pro 3, Business 10, Enterprise Unlimited. Keys made by the admin mobile app appear in the same list as “Admin Mobile: <device>” and count toward the limit; deleting one signs that phone out (at the latest when its 12-hour session ends). Those keys cannot create clients.
  • Speed limit: 60 requests a minute per key; the 61st gets 429. After 10 wrong keys in a minute from one address, further wrong attempts get 429 instead of 401. Behind a reverse proxy on the same machine or the local network, each caller counts with its own address, as long as the proxy passes it in X-Forwarded-For. The counters live in memory and start again when BGPVR restarts. For a bulk import, one request a second is safe.
  • Revoke: the bin icon next to the key → Click again to delete. The key is deleted at once; the next call with it gets 401 Invalid API key. A deleted key cannot be switched back on.

Test a key (both curl examples below use these two variables):

BGPVR=https://tv.example.com           # your BGPVR address
KEY='paste-your-API-key-here'          # Settings → API & Security → Backend API Keys
curl -s "$BGPVR/api/ext/clients/provision" -H "X-API-Key: $KEY"
{
  "service": "BGPVR External Client Provisioning API",
  "status": "ok"
}

The provisioning request

POST https://your-server/api/ext/clients/provision with the headers X-API-Key: <key> and Content-Type: application/json. The body must be one JSON object. The signed webhook further down takes exactly the same body.

event — text, required
What happened; see What each event does below. If left out: 400 error.
user.username — text, required (not in invite mode)
The client's login name, and how BGPVR finds an existing client (exact match, upper and lower case differ). Send it as text: a number makes the request fail with 500. BGPVR does not check the characters here, so keep to letters, digits and _ . -. It cannot be changed later through the API. If left out: 400 error.
user.email — text
Stored in lower case. Must not belong to another client (409). On a renewal it replaces the stored address only when sent. If left out: none.
user.display_name — text
Name shown in the console. On a renewal it is updated only when sent. If left out: none.
password — text, top level
Used only when the client is created; never changes an existing password. Left out, BGPVR makes a random 12-character one like Kq7m-T3xa-Wn9P, or uses the username when Settings → Forum Sync → Auto-Generate Password is “No”. Send it as text: a list or an object gets a 400 error. Its length is not checked, so send a strong one.
provisioning_mode (or mode, fulfillment) — client or invite
invite returns invite codes instead of creating an account. If left out: client.
count — number, 1–50
Invite mode: how many codes. If left out: 1.
prefix — text
Invite mode: start of each code. Letters and digits only, made upper case, at most 6. If left out: BGP.
source — text
Label written to the client's security log. If left out: external_api or signed_webhook.
plan.package_id — number
The package to put the client on. It must exist. On a create, an unknown number, null or anything that is not a whole number is dropped without an error and the client gets no package; on a renewal the client keeps its package. If left out: on a create, the Default Provisioning Package; on a renewal, the client's current package.
plan.duration_days — number, 0–3650
Expiry date = today + this many days (server date). 0 = no expiry date. If left out: Default Expiry (30).
plan.max_connections — number, 1–100
How many streams at the same time. If left out: the package's value, else 1.
plan.allow_timeshift, plan.allow_dvr, plan.allow_vod — 0 or 1
Catch-up, recording and VOD for this client. If left out: the package's value, else 1.
plan.stream_profile_id — number
Forces a stream profile. Not checked, so use a real one. If left out: the package's profile.
plan.locker_mode — text
Security Locker mode. If left out: the package's mode.
plan.expires_in_days — number, 1–3650
Invite mode: how long the codes can be redeemed. If left out: Default Expiry (30), or 1 day when Default Expiry is 0.

Every plan field may also be sent at the top level; the plan object wins. A number outside its range is brought into range; in the number fields other than package_id, anything that is not a number falls back to the default. The defaults are set in Settings → Forum Sync (Default Provisioning Package, Default Expiry, Auto-Generate Password) and apply to the API and to the signed webhook as well, not only to the forum. On a renewal, a field left out keeps the client's current value as long as the package does not change.

Finding a package number

The Packages page does not show package numbers yet. Either choose a Default Provisioning Package in Settings → Forum Sync and leave package_id out, or read the number from the page: right-click a package card → Inspect; id="pkg-card-3" means package 3. The answer's plan.package_id and plan.package_name confirm which package was used.

What each event does

user_subscribed, user_activated, user_added (all three the same)
No client with that username: Creates the client, active, with the plan. 201, "action": "created"; the answer contains the password.
Client exists: Renews or reactivates: status active, expiry = today + duration_days, email and name updated when sent, password unchanged. A package_id of a different existing package moves the client to it, and the limits not sent come from that package. Otherwise the client keeps its package and every limit not sent; only a client with no package, or on the Free Tier package, is moved to the Default Provisioning Package (when one is set). 200, "reactivated".
user_unsubscribed
No client with that username: 200, "no_change"
Client exists: Undoes a package change: back to the package the client had before an earlier request moved it to another one; if there is none, to the Free Tier package (chosen in Signup & Referrals, a Business-plan page). The status does not change: an active client stays active, a disabled one stays disabled. 200, "downgraded", or "no_change" when there is nothing to move to.
user_deactivated, user_removed (both the same)
No client with that username: 404, {"error": "Client not found", "username": …}; nothing is changed. (Fixed in 2.9.318: earlier versions answered 200 "disabled".)
Client exists: Status disabled: blocked on every app and device. The package moves back as for user_unsubscribed. The client is never deleted. 200, "disabled" (or "downgraded" when the package moved back too). Reactivate it later with user_activated.
anything else
400, Unknown event: …
Renewals: count the days yourself

duration_days counts from today, not from the current expiry date: a customer who renews 10 days early with "duration_days": 30 ends up with 30 days, not 40. To keep the remaining days, send the number of days until the new paid-until date. An exp_date of 2026-11-05 means access ends at 00:00 server time on 5 November. Fixed in 2.9.318: a renewal without package_id keeps the client's package and limits. Earlier versions removed the package, so with them always send the whole plan.

The same request twice

  • A create for a username that already exists never makes a duplicate: it is a renewal (200 reactivated). Retrying a create after a timeout is therefore safe, but the retry's answer has no password; only the first answer had it. If it was lost, reset the password in the console.
  • Repeating a renewal on the same day gives the same expiry date: days are not added twice.
  • Repeating a switch-off changes nothing more.
  • Invite mode is not repeat-safe: every call makes new codes.

Answers and errors

The answer to a create (from the curl example below; password and token hidden):

{
  "access_token": "<hidden>",
  "account_url": "https://tv.example.com/account",
  "action": "created",
  "exp_date": "2026-11-05",
  "password": "<hidden>",
  "plan": {
    "allow_dvr": 1,
    "allow_timeshift": 1,
    "allow_vod": 1,
    "duration_days": 30,
    "expires_in_days": 30,
    "locker_mode": "",
    "max_connections": 2,
    "package_id": 3,
    "package_name": "Premium",
    "stream_profile_id": null
  },
  "username": "john_doe",
  "welcome_email_sent": null
}
  • action: created, reactivated, disabled, downgraded, no_change or invite_created.
  • password: only in the created answer. Store it and show it to the customer.
  • access_token: the client's own account token (it opens their account page and playlist). Keep it secret like a password; normal integrations do not need it.
  • exp_date: "" means no expiry date. plan: the values actually applied, after defaults; check that package_id is not null.
  • welcome_email_sent: null when welcome emails are off, "skipped_no_email", true or false. false means the email could not be sent; the client was created and the answer still contains the password.
HTTPBodyCause
400{"error": "Invalid JSON payload"}The body is not valid JSON.
400{"error": "Missing username in payload"}No user.username (client mode).
400{"error": "Unknown event: user_paid"}event missing or misspelled.
400{"error": "password must be a string"}password is a list or an object.
401{"error": "API key required (X-API-Key header)"}No X-API-Key header.
401{"error": "Invalid API key"}Wrong or deleted key.
403{"error": "API key lacks permission: clients"}An admin-mobile device key was used.
403{"current_tier": "free", "error": "This feature requires a Pro or Enterprise license.", "feature": "api_keys", "upgrade_url": "https://bgpvr.com/pricing"}The plan does not include API keys ("feature": "webhooks" on the webhook address).
403{"current_count": 100, "current_tier": "pro", "error": "Pro tier allows 100 users. Upgrade to increase the limit.", "limit": 100, "upgrade_url": "https://bgpvr.com/pricing"}Creating would pass the plan's subscriber limit. Every client counts, disabled ones too. Renewals and invite codes still work.
404{"error": "Client not found", "username": "john_doe"}user_deactivated or user_removed for a username that does not exist; nothing changed.
409{"error": "Email already exists"}The email belongs to another client.
429{"error": "Too many requests. Please try again later."}Speed limit; wait a minute.
500HTML error pageThe body is JSON but not an object (for example a list), or username is not text.

Examples with curl

Every example was run against a BGPVR 2.9.318 test server on 6 October 2026; the answers are the real ones, shown formatted, with the server address replaced and secrets hidden. Package 3 is a package called “Premium”.

Create a client for 30 days on package 3 (answer: see Answers and errors above):

curl -s -X POST "$BGPVR/api/ext/clients/provision" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"event": "user_subscribed",
       "user": {"username": "john_doe", "email": "john@example.com", "display_name": "John Doe"},
       "plan": {"package_id": 3, "duration_days": 30, "max_connections": 2}}'

Renew or reactivate: the same event and username. Here the plan is sent again; without package_id the client keeps its package and limits. Run on the same day as the create, the expiry date stays the same:

curl -s -X POST "$BGPVR/api/ext/clients/provision" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"event": "user_subscribed",
       "user": {"username": "john_doe"},
       "plan": {"package_id": 3, "duration_days": 30, "max_connections": 2}}'
{
  "action": "reactivated",
  "exp_date": "2026-11-05",
  "plan": {
    "allow_dvr": 1,
    "allow_timeshift": 1,
    "allow_vod": 1,
    "duration_days": 30,
    "expires_in_days": 30,
    "locker_mode": "",
    "max_connections": 2,
    "package_id": 3,
    "package_name": "Premium",
    "stream_profile_id": null
  },
  "username": "john_doe"
}

Switch off (the account is kept and can be reactivated with the call above):

curl -s -X POST "$BGPVR/api/ext/clients/provision" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"event": "user_deactivated", "user": {"username": "john_doe"}}'
{"action": "disabled", "package_id": null, "username": "john_doe"}

Invite codes instead of an account, for example for a shop that sells codes:

curl -s -X POST "$BGPVR/api/ext/clients/provision" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"event": "user_subscribed", "provisioning_mode": "invite", "count": 2, "prefix": "SHOP",
       "plan": {"package_id": 3, "duration_days": 30, "expires_in_days": 14}}'
{
  "action": "invite_created",
  "codes": [
    "SHOP-G68QT-YNDD9",
    "SHOP-2TEPP-JURMX"
  ],
  "count": 2,
  "expires_at": "2026-10-20 15:18:16",
  "invite_url": "https://tv.example.com/invite",
  "plan": {
    "allow_dvr": 1,
    "allow_timeshift": 1,
    "allow_vod": 1,
    "duration_days": 30,
    "expires_in_days": 14,
    "locker_mode": "",
    "max_connections": 2,
    "package_id": 3,
    "package_name": "Premium",
    "stream_profile_id": null
  }
}

The customer redeems a code at https://your-server/invite, choosing a username, a password of at least 8 characters and an email address; the account gets the plan stored with the code. A code that is not redeemed within expires_in_days stops working (expires_at is in UTC). Each code works once. Redemption is on by default. It stops when “Invite redemption” (Signup & Referrals) or “Public Self-Signup” (Modules → Registration) is switched off. Both are Business-plan switches: on Pro, Signup & Referrals is hidden and Public Self-Signup shows greyed out and off, yet codes made through the API still redeem. Fixed in 2.9.318: on Pro, pressing Save in the Modules → Registration card no longer switches redemption off. If an earlier version already switched it off that way, a Pro console cannot switch it back on: contact us.

Examples in PHP (WHMCS)

The same calls from PHP 7.4 or later with the curl extension. Save this helper once:

<?php
// bgpvr_client.php — send provisioning requests to BGPVR (PHP 7.4+ with the curl extension).
const BGPVR_URL     = 'https://tv.example.com';     // your BGPVR address
const BGPVR_API_KEY = 'paste-your-API-key-here';    // Settings → API & Security → Backend API Keys

/** POST one provisioning request. Returns [HTTP status, decoded JSON answer]. */
function bgpvr_provision(array $payload): array
{
    $ch = curl_init(BGPVR_URL . '/api/ext/clients/provision');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode($payload),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'X-API-Key: ' . BGPVR_API_KEY],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
    ]);
    $raw    = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $answer = is_string($raw) ? json_decode($raw, true) : null;
    return [$status, is_array($answer) ? $answer : ['error' => "No JSON answer (HTTP $status)"]];
}
<?php
require __DIR__ . '/bgpvr_client.php';

// 1. Create a client (if the username already exists, this renews it instead)
[$status, $r] = bgpvr_provision([
    'event' => 'user_subscribed',
    'user'  => ['username' => 'jane_roe', 'email' => 'jane@example.com', 'display_name' => 'Jane Roe'],
    'plan'  => ['package_id' => 3, 'duration_days' => 30, 'max_connections' => 2],
]);
echo $status, ' ', $r['action'] ?? $r['error'], ' ', $r['exp_date'] ?? '', PHP_EOL;
// Save $r['password'] now: BGPVR returns it only in this answer.

// 2. Renew or reactivate: same event, same username, the whole plan again
[$status, $r] = bgpvr_provision([
    'event' => 'user_subscribed',
    'user'  => ['username' => 'jane_roe'],
    'plan'  => ['package_id' => 3, 'duration_days' => 30, 'max_connections' => 2],
]);
echo $status, ' ', $r['action'] ?? $r['error'], ' ', $r['exp_date'] ?? '', PHP_EOL;

// 3. Switch the client off (the account is kept and can be reactivated later)
[$status, $r] = bgpvr_provision(['event' => 'user_deactivated', 'user' => ['username' => 'jane_roe']]);
echo $status, ' ', $r['action'] ?? $r['error'], PHP_EOL;

// 4. Sell codes instead of accounts: two single-use invite codes
[$status, $r] = bgpvr_provision([
    'event' => 'user_subscribed', 'provisioning_mode' => 'invite', 'count' => 2, 'prefix' => 'SHOP',
    'plan'  => ['package_id' => 3, 'duration_days' => 30, 'expires_in_days' => 14],
]);
echo $status, ' ', isset($r['codes']) ? implode(', ', $r['codes']) : $r['error'], PHP_EOL;

Output:

201 created 2026-11-05
200 reactivated 2026-11-05
200 disabled
201 SHOP-TDZ2D-8XLZN, SHOP-EC3EA-ZRQ4P

WHMCS. There is no ready-made BGPVR module for WHMCS yet, but a hook file connects the two. With duration_days set to 0, BGPVR gives the account no expiry date and WHMCS decides: when it suspends an overdue service the client is switched off in BGPVR, and unsuspending switches it back on. The BGPVR username is whmcs + the WHMCS service number, so each service is one BGPVR client.

<?php
// WHMCS: save as includes/hooks/bgpvr.php, and bgpvr_client.php as includes/bgpvr/bgpvr_client.php.
// The hooks follow WHMCS's Create / Suspend / Unsuspend / Terminate actions, so the product
// needs a module (the built-in "Autorelease" module is enough).
if (!defined('WHMCS')) {
    die('This file cannot be accessed directly');
}
require_once dirname(__DIR__) . '/bgpvr/bgpvr_client.php';

// WHMCS product ID => BGPVR plan. duration_days 0 = no expiry date in BGPVR:
// WHMCS suspends an overdue service, and that switches the client off in BGPVR.
const BGPVR_PRODUCTS = [
    12 => ['package_id' => 3, 'duration_days' => 0, 'max_connections' => 2],
];

function bgpvr_whmcs(string $event, array $p): void
{
    $plan = BGPVR_PRODUCTS[(int) $p['pid']] ?? null;
    if ($plan === null) {
        return;                                     // not a BGPVR product
    }
    $payload = ['event' => $event, 'source' => 'whmcs',
    'user'  => ['username' => 'whmcs' . $p['serviceid']], 'plan' => $plan];
    if ($event === 'user_subscribed') {
        $c = $p['clientsdetails'];
        $payload['user']['email'] = $c['email'];
        $payload['user']['display_name'] = trim($c['firstname'] . ' ' . $c['lastname']);
        if (!empty($p['password'])) {
$payload['password'] = $p['password'];  // the service password WHMCS shows the customer
        }
    }
    [$status, $r] = bgpvr_provision($payload);
    if ($status === 409 && isset($payload['user']['email'])) {
        unset($payload['user']['email']);           // email already on another BGPVR client: retry without it
        [$status, $r] = bgpvr_provision($payload);
    }
    logActivity("BGPVR $event whmcs{$p['serviceid']}: HTTP $status " . ($r['action'] ?? $r['error']));
}

add_hook('AfterModuleCreate',    1, function ($vars) { bgpvr_whmcs('user_subscribed',  $vars['params']); });
add_hook('AfterModuleUnsuspend', 1, function ($vars) { bgpvr_whmcs('user_activated',   $vars['params']); });
add_hook('AfterModuleSuspend',   1, function ($vars) { bgpvr_whmcs('user_deactivated', $vars['params']); });
add_hook('AfterModuleTerminate', 1, function ($vars) { bgpvr_whmcs('user_removed',     $vars['params']); });

What BGPVR received when the four hooks fired for service 1042 (and a second service of the same customer, whose email was already taken):

logActivity: BGPVR user_subscribed whmcs1042: HTTP 201 created
logActivity: BGPVR user_deactivated whmcs1042: HTTP 200 disabled
logActivity: BGPVR user_activated whmcs1042: HTTP 200 reactivated
logActivity: BGPVR user_removed whmcs1042: HTTP 200 disabled
logActivity: BGPVR user_subscribed whmcs1043: HTTP 201 created
  • Tested against BGPVR with a stand-in for WHMCS's add_hook(), not inside a WHMCS installation: try it on a staging WHMCS before going live.
  • Terminate switches the client off but does not delete it, so it still counts toward your plan's subscriber limit; delete it in Clients when you want the place back.
  • An upgrade or downgrade in WHMCS (Change Package) is not handled by this file.
  • BGPVR allows one client per email address, hence the retry without email when a customer has a second service.

Signed webhook receiver

For systems that send signed webhooks instead of using an API key.

  1. Settings → API & Security → API & Integrations: tick Enable Webhook Auto-Provisioning and press Save in the card header. (Forum Sync's “Enable forum webhook provisioning” is the same switch.) While it is off, every call gets 403 {"error": "Webhooks are disabled"}.
  2. The Webhook URL field shows the address: https://your-server/api/webhooks/provision.
  3. Get the secret. After it has been saved, the Webhook Secret field only shows ********: BGPVR never shows a stored secret again, and every install starts with a random one nobody has seen. Press Regenerate, then again on Click again to confirm, and copy the new value from the field before you leave the page. The old secret stops working at that moment.

How to sign. Send the header X-Webhook-Signature: sha256=<signature>, where the signature is:

  • HMAC-SHA256, written as 64 lower-case hexadecimal characters;
  • keyed with the secret exactly as the console shows it: its 64 characters as text (UTF-8). Do not convert the hexadecimal secret to bytes first;
  • computed over the exact bytes of the request body. Sign the string you send; if a library rebuilds the JSON (spacing, key order), the signature no longer matches.

There is no timestamp, so BGPVR cannot tell a replayed request from a new one: send webhooks over HTTPS only. The limit is 30 requests a minute per sending address (behind a reverse proxy, the sender's own address from X-Forwarded-For).

Worked example. With the example secret 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef and the 102-byte body {"event":"user_subscribed","user":{"username":"demo_user"},"plan":{"package_id":3,"duration_days":30}} the header is X-Webhook-Signature: sha256=426c18084d33552ed2ebee9ae3a604556e05d5f79077dbb5c6806519aa297522. Check your own code against these values. In curl (with BGPVR set as above):

SECRET='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'   # example only
BODY='{"event":"user_subscribed","user":{"username":"demo_user"},"plan":{"package_id":3,"duration_days":30}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
echo "$SIG"
curl -s -X POST "$BGPVR/api/webhooks/provision" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: sha256=$SIG" \
  --data-binary "$BODY"
426c18084d33552ed2ebee9ae3a604556e05d5f79077dbb5c6806519aa297522
{
  "access_token": "<hidden>",
  "account_url": "https://tv.example.com/account",
  "action": "created",
  "exp_date": "2026-11-05",
  "password": "<hidden>",
  "plan": {
    "allow_dvr": 1,
    "allow_timeshift": 1,
    "allow_vod": 1,
    "duration_days": 30,
    "expires_in_days": 30,
    "locker_mode": "",
    "max_connections": 2,
    "package_id": 3,
    "package_name": "Premium",
    "stream_profile_id": null
  },
  "username": "demo_user",
  "welcome_email_sent": null
}

In PHP, the same body gives the same signature:

<?php
// Send a signed webhook to BGPVR (for senders that sign requests instead of using an API key).
$bgpvr  = 'https://tv.example.com';
$secret = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';  // example only
$body   = json_encode([
    'event' => 'user_subscribed',
    'user'  => ['username' => 'demo_user'],
    'plan'  => ['package_id' => 3, 'duration_days' => 30],
]);
$signature = 'sha256=' . hash_hmac('sha256', $body, $secret);  // sign the exact bytes you send

$ch = curl_init($bgpvr . '/api/webhooks/provision');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $body,
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json', 'X-Webhook-Signature: ' . $signature],
    CURLOPT_RETURNTRANSFER => true,
]);
echo $signature, PHP_EOL, curl_exec($ch), PHP_EOL;
sha256=426c18084d33552ed2ebee9ae3a604556e05d5f79077dbb5c6806519aa297522
{"access_token":"<hidden>","account_url":"https://tv.example.com/account","action":"created","exp_date":"2026-11-05","password":"<hidden>","plan":{"allow_dvr":1,"allow_timeshift":1,"allow_vod":1,"duration_days":30,"expires_in_days":30,"locker_mode":"","max_connections":2,"package_id":3,"package_name":"Premium","stream_profile_id":null},"username":"demo_user","welcome_email_sent":null}

If one byte of the body changes after signing, the answer is 401 {"error": "Invalid signature"}; the same happens when the secret is wrong or not set.

The two test buttons

Create Test Client and Disable Test Client in the same card have BGPVR sign a test request with the stored secret and check it like a real webhook, so they work at any time. They create, renew or switch off a client called ui_webhook_test; delete it in Clients when you are done. If one of your real clients is called ui_webhook_test, the buttons show an error and leave it alone. Fixed in 2.9.318: in earlier versions the buttons worked only right after Regenerate, and “Disable Test Client” did not switch the test client off.

Discourse forum Pro

Membership of a Discourse group can grant BGPVR access. In Settings → Forum Sync: tick Enable forum webhook provisioning, copy the Discourse Webhook URL (https://your-server/api/webhooks/discourse) into a new Discourse webhook with the group user added and removed events. Type a long random secret into Discourse Webhook Secret, press Save, and enter the same value in the Discourse webhook's Secret field. The field shows ******** whenever a secret is stored (BGPVR never shows it again); without a Discourse secret of its own, the signed-webhook secret is used. Discourse signs its webhooks itself (X-Discourse-Event-Signature).

  • Set Provisioning Group IDs to your own paid group. The default 41 is a group on BGPVR's own forum. Provisioning Group Names (optional) grants access by group name as well as by ID. Only names you type there count; leave it empty to go by ID only. After updating to 2.9.318, check this field: the update clears, once, the names earlier versions filled in by default, so type your group's name again if access should come from its name. Group Package Map (41=forum, vip=Premium) gives each group its own package; otherwise the Default Provisioning Package is used.
  • Joining the group creates or renews the client with the Default Expiry (0 = no expiry date). Other groups and other events are answered 200 “ignored”.
  • Leaving the group moves the client back to its previous package, or to the Free Tier package. It does not switch the client off, although the card's text says so. With neither package and Default Expiry 0, a member who leaves keeps access: keep an expiry (Default Expiry above 0), or on Business choose a Free Tier package.
  • Discourse Forum URL, Discourse API Key and Discourse API Username are only used to look up the member's username and email when the webhook does not contain them. Provisioning Mode can hand out invite codes instead of accounts.
  • BGPVR's answer to a join contains the new client's password, and Discourse keeps webhook answers in its webhook event log, where forum admins can read them.

Security

  • Use HTTPS only. The key, the signature and the new customer's password all travel in these requests.
  • BGPVR has no IP allow-list for API keys or webhooks. If your billing server has a fixed address, allow only that address to reach /api/ext/ and /api/webhooks/ at your reverse proxy or firewall.
  • Give each system its own key, named after it, and keep keys on servers only: never in a browser page, an app or a public repository. “Last used” shows keys nobody uses any more; delete them.
  • Rotate a key without downtime: create a new key, switch the system to it, then delete the old one. Regenerating the webhook secret takes effect at once, so update the sender immediately.
  • Store the passwords that come back in the answers as carefully as any customer password.
  • Staff logins cannot manage API keys or the webhook secret, and they see secret settings masked. Even so, give console logins, staff accounts included, only to people you trust.

Not available yet (planned)

  • A full client management API: list, search and read clients, change a package, extend by N days, suspend and unsuspend, reset a password, schedule a deletion, list packages. Seeing who is connected comes after that.
  • API keys with their own permissions and an IP allow-list.
  • Outgoing webhooks: BGPVR telling your billing system that a client expired, renewed, was created in the console or changed package.
  • A ready-made WHMCS module.
  • An OpenAPI reference that developers can load into their tools.

Watch out

  • Usernames are matched exactly: John_Doe and john_doe are two clients.
  • Usernames are shared by every source: the console, the forum, a shop and any other system. A create or renewal for a username that already exists takes over that client: it switches the client back on even if you disabled it in the console, and replaces its email address, so password-reset codes then go to the new address. Give each system its own username prefix (for example shop_), and do not let customers choose usernames freely.
  • Expiry dates use the server's date; invite expires_at is in UTC.