Skip to content

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.

+-------------------------------------------+
| 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

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',
),
) );
} );

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

Section titled “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;
}
}

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.

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;
}

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.

  • 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).