CreditKit Pro REST API reference
CreditKit Pro 2.2.0 registers 20 REST routes in the pcs/v1 namespace, and those routes carry 27 endpoint handlers once each HTTP method is counted on its own. The handlers cover credit balances, the bundle catalogue, the transaction ledger, team membership, store statistics, webhooks and API key creation. Every route is registered in includes/api/class-pcs-rest-api.php.
How do I authenticate?
Most of the API takes an API key. Send the key as Authorization: Bearer pcs_… or as X-API-Key: pcs_…. The query-string fallback was removed in 2.2.0, so a key passed as a URL parameter is ignored and the request answers 401 with the error code no_api_key.
Keys are issued in wp-admin under Credit System → API & Webhooks → API Keys. The plaintext key is displayed once at creation and stored only as a SHA-256 hash in wp_pcs_api_keys. One user may hold 10 active keys at a time, and an eleventh creation attempt fails with api_key_limit. POST /auth/key mints a further key for the calling admin, but that route itself needs a valid admin key, so the first key on a site always comes from the admin screen.
Exactly two routes accept the WordPress login cookie, and neither of them accepts an API key. Everything else in the namespace is either key-authenticated or open to anonymous callers.
| Route | Accepted auth |
|---|---|
GET /credits/me | WordPress login cookie plus an X-WP-Nonce header |
GET /credits/recommend-bundle | WordPress login cookie plus an X-WP-Nonce header |
GET /bundles | None; anonymous callers are served |
GET /bundles/{id} | None; anonymous callers are served |
| Every other route and method | API key in Authorization: Bearer or X-API-Key |
A cookie-authenticated call must carry a valid wp_rest nonce in the X-WP-Nonce header. WordPress treats a cookie request without that nonce as anonymous, and both routes then answer 401 with not_logged_in. Those two routes are the browser surface used by the product-page balance widget, which is why no wildcard Access-Control-Allow-Origin header is emitted for them. Key-authenticated routes do receive a wildcard CORS header, because a browser never attaches cookies to them.
A key can be pinned to a set of IP addresses through the _pcs_ip_whitelist user meta on the key owner. When that whitelist is set, a call from any other address answers 403 with ip_not_allowed.
Endpoint reference
Paths below omit the /wp-json/pcs/v1 prefix. “Self or admin” is the object-level check in assert_user_access_or_admin(): the authenticated user ID equals the target user ID, or the key owner has manage_options. Team routes add a second check: the key owner is the team’s manager_id, or has manage_options. POST /credits/add and POST /credits/spend honour an Idempotency-Key request header, and a replayed key returns the stored response with the header Idempotent-Replay: true for 24 hours.
| Route | Method | Auth | Authorization rule |
|---|---|---|---|
/credits/{user_id} | GET | API key | Self or admin |
/credits/me | GET | Cookie plus nonce | Returns the caller’s own balance |
/credits/recommend-bundle | GET | Cookie plus nonce | Any logged-in user; the payload is catalogue data |
/credits/add | POST | API key | Key owner needs manage_options |
/credits/spend | POST | API key | Self or admin |
/bundles | GET | None | Hidden bundles need manage_options plus include_hidden=true |
/bundles | POST | API key | Key owner needs manage_options |
/bundles/{id} | GET | None | Any caller reads one bundle record |
/bundles/{id} | PUT, PATCH or POST | API key | Key owner needs manage_options |
/bundles/{id} | DELETE | API key | Key owner needs manage_options |
/bundles/{id}/purchase | POST | API key | Self or admin; quantity is capped at 100 |
/transactions | GET | API key | With user_id: self or admin. Without it: manage_options |
/badges/{user_id} | GET | API key | Self or admin |
/teams | GET | API key | manage_team_credits; a non-admin sees only teams it manages |
/teams | POST | API key | manage_team_credits |
/teams/{id}/members | GET | API key | Manages that team, or manage_options |
/teams/{team_id}/members/{user_id} | PUT, PATCH or POST | API key | Manages that team, or manage_options |
/stats | GET | API key | Key owner needs manage_options |
/stats/user/{user_id} | GET | API key | Self or admin |
/webhooks | GET | API key | Key owner needs manage_options |
/webhooks | POST | API key | Key owner needs manage_options |
/webhooks/{id} | GET | API key | Key owner needs manage_options |
/webhooks/{id} | PUT or PATCH | API key | Key owner needs manage_options |
/webhooks/{id} | DELETE | API key | Key owner needs manage_options; answers 204 with no body |
/webhooks/{id}/test | POST | API key | manage_options; 10 test deliveries per 5 minutes |
/webhooks/{id}/deliveries | GET | API key | Key owner needs manage_options |
/auth/key | POST | API key | manage_options; the new key belongs to the caller |
Two rows list POST as an alternative verb because they are registered with WP_REST_Server::EDITABLE, which WordPress expands to POST, PUT and PATCH. PUT is the verb to write against, since both routes are idempotent upserts. The webhook update route is registered as PUT, PATCH only and refuses POST. The webhook {id} segment is a UUID4 in 8-4-4-4-12 hex form, and the route pattern rejects any other shape with a 404 before the handler runs. A webhook secret is returned once at creation and once more when rotate_secret is sent on an update; every read masks it. POST /teams writes budget_period, and the supported values are monthly, quarterly and yearly. GET /transactions pages through limit (maximum 100) and offset, and returns the row count in the X-WP-Total and X-WP-TotalPages response headers.
Error shape
A successful call returns {"success": true, "message": "…", "data": {…}, "timestamp": …}. Admin-gated responses add a version field; public responses omit it so the plugin version is not exposed to anonymous callers.
Failures are rewritten by a rest_request_after_callbacks filter that runs only on pcs/v1 routes, so they do not use the default WordPress error body. The filter emits {"success": false, "error": {"code": "…", "message": "…", "data": {…}}, "timestamp": "…"} and keeps the HTTP status the handler chose. A client can therefore branch on body.success alone rather than sniffing for a code property. Routes outside the namespace are left untouched by the filter.
| Error code | HTTP status | Cause |
|---|---|---|
no_api_key | 401 | No key in the Authorization or X-API-Key header |
invalid_api_key | 401 | The key is unknown or has been revoked |
not_logged_in | 401 | A cookie route was called without a valid session and nonce |
ip_not_allowed | 403 | The caller’s IP is outside the key owner’s whitelist |
forbidden | 403 | The object-level check refused access to another user’s data |
insufficient_permissions | 403 | The key owner lacks manage_options or manage_team_credits |
user_not_found | 404 | The target user ID does not exist |
team_not_found | 404 | The team ID does not exist or is not active |
rate_limit_exceeded | 429 | The per-key request budget is spent |
Rate limits
REST rate limiting counts requests per API key, not per IP address and not per user. The budget is 100 requests per minute and 1,000 requests per hour. Both numbers are hard-coded in PCS_REST_API::check_rate_limit(), with no setting and no filter to raise them. Counters live in a transient keyed on a SHA-256 hash of the key, and each window resets on the wall-clock minute or hour boundary.
Every key-authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. X-RateLimit-Reset holds the Unix timestamp at which the current minute window rolls over. A refused request answers 429 with rate_limit_exceeded and adds Retry-After in whole seconds. The two anonymous bundle routes are not counted, because there is no key to count them against.
The rate-limiting fields on the Credit System → Security screen do not change any of this. enable_rate_limiting, credit_transaction_limit, admin_action_limit and payment_action_limit sit with the plugin’s admin and AJAX guards, and their shipped defaults are 5, 10 and 3. None of the four reaches the pcs/v1 namespace.
Transaction types you’ll see
The type filter on GET /transactions is restricted to eight values, and a request outside that list is rejected by the route schema before the handler runs. The values are bonus, reward, referral, purchase, spend, refund_credit, admin_adjustment and expiry.
POST /credits/add accepts a narrower set: bonus, reward, referral and purchase. refund_credit and admin_adjustment are excluded from that route deliberately, since both skip the fraud and spending-limit pipeline and are issued through their own internal code paths. expiry rows are written by the expiry cron rather than by any endpoint.
Treat referral as a retained historical value rather than a feature. CreditKit Pro 2.2.0 ships no referral programme: the [pcs_referral] shortcode renders an empty string, the referral REST route and its handler were removed, and no code path in the plugin writes a row of that type. The value stays in the enum so ledgers migrated from earlier versions keep validating.
What this doesn’t do
GET /pcs/v1/leaderboardno longer exists. The route published customer display names alongside spend and balance totals to anonymous callers, so it was removed for privacy along with the matching shortcode. A regression test asserts the route stays unregistered.- No route removes a team member. Membership is an idempotent upsert through
PUT /teams/{team_id}/members/{user_id}, which returns 201 on insert and 200 when it updates an existing member’s role. Removal happens in wp-admin through thepcs_remove_team_memberadmin action. - Daily and weekly team budget periods are not supported. The
pcs_teams.budget_periodcolumn is an enum ofmonthly,quarterlyandyearly, and the admin path rejects anything else. - There is no split-tender payment endpoint. An order is paid with credits or with money, and the API exposes no way to divide one order across both.
- There are no holds or reservations endpoints. The reservation subsystem was cut, so
available_balancein a credits response always equalscurrent_balance. - Rate limits cannot be configured. Raising the 100-per-minute or 1,000-per-hour budget requires a code change in the plugin.
POST /auth/keycannot bootstrap a site. The route is gated behind an existing admin key, so the first key of any installation is created in wp-admin.