Use this page to register a credit-spending feature, add a credit source, or read and write the credit ledger. The credit system is built on the Wbcom Credits SDK - a bundled
library at libs/wbcom-credits-sdk/ (kept in libs/, not
vendor/, so it always ships in the release zip). It provides:
- An append-only ledger (the
{prefix}_credit_ledger table; for
this plugin the prefix is wcb, so wp_wcb_credit_ledger).
- A consumer pattern (entities that spend credits - job posting,
featured upgrade).
- An adapter pattern for e-commerce plugins (WooCommerce, WC
Subscriptions, WC Memberships, PMPro, MemberPress).
- A gateway pattern for direct payment processors (Stripe, PayPal).
This doc covers the contract for registering a slug and writing
your own consumer, adapter, or gateway.
Architecture
+-------------------------------------------+
| Wbcom Credits SDK (libs/) |
| |
| +--------------+ |
| | Ledger | <- single source |
| | (DB writes) | of truth |
| +--------------+ |
| ^ |
| +----+----+----------+ |
| | | | |
| Consumers Adapters Gateways |
| (job_post, (Woo, (Stripe, |
| featured) WCS, WCM, PayPal - |
| PMPro, direct) |
| MemberPress) |
+-----^----------^----------^---------------+
| | |
hold/deduct on order on webhook
on actions completed verified
Registering your slug with the SDK
A plugin registers everything (slug, prefix, consumers, settings)
in one call on the wbcom_credits_sdk_registry action, which fires
before the SDK loads. Pro does this in wp-career-board-pro.php:
add_action( 'wbcom_credits_sdk_registry', function ( \Wbcom\Credits\Registry $registry ) {
$registry->register( array(
'slug' => 'wp-career-board',
'prefix' => 'wcb', // table prefix: {wp}_wcb_credit_ledger
'version' => WCBP_VERSION,
'file' => WCBP_FILE,
'user_type' => 'employer',
'consumers' => array( /* see below */ ),
'settings' => array(
'low_threshold' => 5,
'purchase_url' => '',
'admin_settings_hook' => 'wcb_admin_settings_tabs',
),
) );
} );
Consumers - what can spend credits
A "consumer" is something the SDK debits credits FOR. Each consumer
declares three lifecycle actions: hold_on (reserve credits),
deduct_on (settle the hold permanently), and refund_on (release
the hold). The SDK adds the listeners; you fire the actions. The
cost callback receives the item id and returns the credit cost.
Pro registers two consumers inside the consumers array of its
register() call. Neither uses the hold_on / deduct_on /
refund_on actions. Pro's JobCharge class drives them from the
job's lifecycle instead, so every path is covered (auto-publish,
approving a draft, resubmitting, changing board, bulk and
command-line approval):
'consumers' => array(
array(
'id' => 'job_post',
'label' => __( 'Job Posting', 'wp-career-board-pro' ),
// Priced for the job's author, never the current user.
'cost' => static function ( int $item_id ): int {
$user_id = (int) get_post_field( 'post_author', $item_id );
$board_id = (int) get_post_meta( $item_id, '_wcb_board_id', true );
$base = $board_id > 0
? (int) ( ( new \WCB\Pro\Modules\Boards\BoardSettings() )->get( $board_id )['credit_cost'] ?? 0 )
: 0;
return (int) apply_filters( 'wcbp_consumer_cost', $base, $user_id, $item_id, $board_id, 'job_post' );
},
),
array(
'id' => 'featured_upgrade',
'label' => __( 'Featured Upgrade', 'wp-career-board-pro' ),
'cost' => static function ( int $item_id ): int {
$user_id = (int) get_post_field( 'post_author', $item_id );
$base = (int) get_option( 'wcbp_featured_upgrade_cost', 10 );
return (int) apply_filters( 'wcbp_consumer_cost', $base, $user_id, $item_id, 0, 'featured_upgrade' );
},
),
),
JobCharge places a hold when an employer submits a job (a 402
if the balance is short), settles it when the job goes live, and
releases it when the job is rejected, trashed or deleted before it
went live. Pro does not fire wcb_featured_upgrade_requested,
_completed or _failed.
To add your own consumer, append another entry to the consumers
array in your own register() call (or call $registry->register()
again for a separate slug). The cost callback signature is
function ( int $item_id ): int. Either give the entry hold_on,
deduct_on and refund_on action names and fire those actions when
the lifecycle events happen in your code, or drive the consumer
yourself as JobCharge does. The SDK takes care of the ledger
writes.
Adapters - automatic credit grants from e-commerce plugins
An "adapter" listens for a specific plugin's purchase event and
writes a topup ledger row. The SDK ships five adapters, which
register themselves when their host plugin is active:
| Adapter |
File |
Listens to |
| WooCommerce |
libs/wbcom-credits-sdk/src/Adapters/WooCommerce.php |
woocommerce_order_status_completed and woocommerce_order_status_processing |
| WC Subscriptions |
libs/wbcom-credits-sdk/src/Adapters/WooSubscriptions.php |
woocommerce_subscription_payment_complete and woocommerce_subscription_renewal_payment_complete |
| WC Memberships |
libs/wbcom-credits-sdk/src/Adapters/WooMemberships.php |
wc_memberships_user_membership_status_changed |
| Paid Memberships Pro |
libs/wbcom-credits-sdk/src/Adapters/PMPro.php |
pmpro_after_change_membership_level and pmpro_subscription_payment_completed |
| MemberPress |
libs/wbcom-credits-sdk/src/Adapters/MemberPress.php |
mepr_event_transaction_completed |
Each adapter implements AdapterInterface
(libs/wbcom-credits-sdk/src/Adapters/AdapterInterface.php).
Note the methods are instance methods, not static:
interface AdapterInterface {
public function get_id(): string;
public function get_label(): string;
public function is_available(): bool; // host plugin active?
public function register_hooks( string $slug ): void;
public function get_mappable_items(): array; // products/levels for the admin mapping UI
}
To add a new adapter (e.g. for Easy Digital Downloads), implement
the interface, hook the host plugin's purchase event in
register_hooks(), and write a topup row with Credits::topup().
Register the adapter from the SDK's wbcom_credits_register_adapters
action, which passes the adapter registry and the slug:
namespace MyAddon\Credits;
use Wbcom\Credits\Adapters\AdapterInterface;
use Wbcom\Credits\Credits;
class EDD implements AdapterInterface {
public function get_id(): string {
return 'edd';
}
public function get_label(): string {
return 'Easy Digital Downloads';
}
public function is_available(): bool {
return class_exists( 'Easy_Digital_Downloads' );
}
public function register_hooks( string $slug ): void {
add_action( 'edd_complete_purchase', function ( $payment_id ) use ( $slug ) {
$user_id = (int) edd_get_payment_user_id( $payment_id );
foreach ( edd_get_payment_meta_cart_details( $payment_id ) as $item ) {
$credits = (int) $this->credits_for_product( $slug, (int) $item['id'] );
if ( $credits > 0 ) {
// Signature: topup( $slug, $user_id, $amount, $note )
Credits::topup( $slug, $user_id, $credits, 'EDD order #' . $payment_id );
}
}
});
}
public function get_mappable_items(): array {
// Return EDD products in the shape the admin mapping UI expects.
return array();
}
private function credits_for_product( string $slug, int $product_id ): int {
$mappings = (array) get_option( "{$slug}_credit_mappings", array() );
foreach ( $mappings as $row ) {
if ( 'edd' === ( $row['adapter'] ?? '' ) && (int) $row['item_id'] === $product_id ) {
return (int) $row['credits'];
}
}
return 0;
}
}
SDK REST routes
The SDK registers its own REST surface per registered slug, under
the SDK's own wbcom-credits/v1 namespace - separate from the
plugin's wcb/v1 namespace (Pro's GET /wcb/v1/employers/{id}/credits,
documented in 02-extending-free.md, is a
thin Pro-side read of the same ledger, not part of this surface).
Registry::register() wires the REST class, which registers three
routes:
GET /wbcom-credits/v1/wp-career-board/balance Current user's balance (or ?user_id= for an admin).
GET /wbcom-credits/v1/wp-career-board/history Paginated ledger entries (user_id, limit, offset; default limit 50).
POST /wbcom-credits/v1/wp-career-board/topup Admin manual credit change: { user_id, amount, note }. The amount is signed.
balance and history need a logged-in user reading their own data,
or an administrator (manage_options) reading anyone's through
user_id. topup needs manage_options.
Gateways - direct payment processors
A "gateway" is for selling credits directly without an e-commerce
plugin in between (Stripe Checkout, PayPal). The SDK ships two
gateways: Stripe and PayPal
(libs/wbcom-credits-sdk/src/Gateways/).
The SDK's Webhook_Controller registers four more REST routes per
slug, in the same wbcom-credits/v1 namespace:
POST /wbcom-credits/v1/wp-career-board/checkout/{gateway} Logged in. Create a hosted checkout session.
POST /wbcom-credits/v1/wp-career-board/webhook/{gateway} Public, provider-signed. Adjusts the ledger.
POST /wbcom-credits/v1/wp-career-board/claim/{gateway} Logged in. Credit a paid checkout when the buyer returns, body { session_id }.
POST /wbcom-credits/v1/wp-career-board/refund/{gateway} Administrator. Refund a prior checkout.
The webhook checks the gateway's verify_signature() before it
handles an event, so the request itself is authenticated by the
provider signature. The claim route lets a site without a working
webhook credit a paid checkout on return; the payment details come
from the provider, not from the browser.
The browser side of checkout/{gateway} doesn't need a hand-rolled
fetch(): the SDK ships a reusable helper,
assets/js/checkout.js, registered as the wbcom-credits-checkout
script handle (once, however many plugins register) and
localized with wbcomCreditsCfg = { restRoot, nonce }. Enqueue it
where you render a buy button and call the global it exposes:
wp_enqueue_script( 'wbcom-credits-checkout' ); // Where the buy button renders.
// JS: on click
window.wbcomCreditsCheckout( {
slug: 'wp-career-board',
gateway: 'stripe',
pack_id: 'pack_50',
credits: 50,
returnUrl: window.location.href,
} );
// POSTs to /{slug}/checkout/{gateway} with the REST nonce, then redirects
// the browser to the hosted checkout URL the SDK returns.
// Optional: billing (object) and coupon (string). Send pack_id or credits.
The same script exposes window.wbcomCreditsClaim( slug ), which
calls the claim route when the page is a checkout return.
If you're writing a custom gateway, implement GatewayInterface
(libs/wbcom-credits-sdk/src/Gateways/GatewayInterface.php).
Extend Abstract_Gateway, which supplies the webhook handling: you
implement normalize_event() and the other interface methods, and do
not override handle_webhook(). Register your gateway from the
wbcom_credits_register_gateways action, which passes the gateway
registry and the slug:
interface GatewayInterface {
public function get_id(): string;
public function get_label(): string;
public function is_available(): bool;
public function get_settings_fields(): array;
public function create_checkout( string $slug, int $user_id, int $credits,
int $price_cents, string $currency = 'USD',
?string $return_url = null ): string;
public function verify_signature( string $raw_body, array $headers ): bool;
public function normalize_event( array $payload ): ?Gateway_Event;
public function handle_webhook( string $slug, array $payload ): \WP_REST_Response;
public function refund( string $slug, string $session_id, ?int $amount_cents = null ): bool;
}
The ledger
Every credit movement writes one row. The table is named
{wp_prefix}{prefix}_credit_ledger (for this plugin,
wp_wcb_credit_ledger); the SDK creates one ledger table per
registered prefix, so the table itself scopes the data and there is
no slug column. Schema:
| Column |
Type |
Notes |
id |
bigint unsigned PK |
Auto-increment |
user_id |
bigint unsigned |
The credit holder (employer) |
item_id |
bigint unsigned |
The consumed entity (job, etc.); 0 for top-ups/adjustments |
entry_type |
varchar(20) |
One of topup, hold, deduction, refund |
amount |
int |
Signed: positive for topup/refund, negative for hold/deduction |
note |
varchar(255) |
Free-form context string |
expires_at |
datetime, null |
When the credits lapse; empty for never |
reason |
varchar(32) |
What happened, such as topup, purchase, spend or hold_release |
reference |
varchar(191) |
The order or session the row came from |
hold_id |
bigint unsigned |
The hold this row settles or releases; 0 otherwise |
created_at |
datetime |
Defaults to CURRENT_TIMESTAMP |
The balance is the sum of amount for the user. Rows are only added,
except that cancel_hold() deletes an open hold.
To read the ledger:
$balance = \Wbcom\Credits\Credits::get_balance( 'wp-career-board', $user_id );
$rows = \Wbcom\Credits\Credits::get_ledger( 'wp-career-board', $user_id, 50 );
// or directly: \Wbcom\Credits\Ledger::get_history( 'wcb', $user_id, $limit, $offset )
Never INSERT/UPDATE the ledger directly - use the Credits helpers,
which use these exact signatures:
Credits::topup( string $slug, int $user_id, int $amount, string $note = '', ?string $expires_at = null, string $reason = 'topup', string $reference = '', int $item_id = 0 ): int|false;
Credits::hold( string $slug, int $user_id, int $amount, int $item_id, string $note = '' ): int|false;
Credits::deduct( string $slug, int $user_id, int $amount, int $item_id, string $note = '' ): bool;
Credits::refund( string $slug, int $user_id, int $amount, int $item_id, string $note = '' ): int|false;
Credits::cancel_hold( string $slug, int $user_id, int $item_id ): void;
Credits::adjust( string $slug, int $user_id, int $amount, string $note = '' ): int|false;
Credits::get_cost( string $slug, string $consumer_id, int $item_id = 0 ): int;
Credits::get_purchase_url( string $slug ): string;
The 4th argument to topup() is a string note, not an array.
Credits::deduct() settles an open hold: it writes a refund row that
releases the held amount and a deduction row for the cost, in one
transaction. It returns false when there is no open hold.
Where to read further
libs/wbcom-credits-sdk/src/ - the SDK source (bundled, not
loaded over the network).
- Pro hooks for credits in
03-hooks-reference.md:
wcbp_consumer_cost
(cost filter), wcbp_credit_consumed, wcbp_credits_low, and
wcbp_credits_topped_up (re-emitted from the SDK's
wbcom_credits_deducted, wbcom_credits_low and
wbcom_credits_topped_up).