The Coupon Affiliates REST API gives external tools, scripts and AI agents structured JSON access to your affiliate program: affiliates, coupons, referred orders, commission, payouts, registrations, click tracking, a change-event feed and store-wide reports. It also delivers outbound webhooks (PRO), so other systems can react the moment something happens in your program. On the free version the same ground is covered by polling the change-event feed, which carries a cursor for exactly that.
Everything lives under one base URL on your own site:
https://example.com/wp-json/wcusage/v2
A first request, using a WordPress application password for an admin user:
curl https://example.com/wp-json/wcusage/v2/me \
-u "admin:abcd efgh ijkl mnop qrst uvwx"
{
"user": { "id": 1, "display_name": "Admin", "login": "admin", "email": "admin@example.com" },
"is_admin": true,
"is_affiliate": false,
"auth": { "method": "wordpress", "key_id": null, "scopes": null },
"coupons": [],
"api_version": "8.2.0"
}
manage_woocommerce, or whichever capability you configured) can read and manage everything./wp-json/… URL form. On a site with plain permalinks, use https://example.com/?rest_route=/wcusage/v2/me instead: the /wp-json/ path is not a REST request at all on such a site, and API keys will not authenticate against it.501 rather than pretending to be empty — see Availability.The API ships switched off. A site never starts answering API requests just because the plugin was updated — an administrator has to turn it on deliberately.
The same screen shows your base URL, the OpenAPI URL, every registered endpoint with the access level it needs, your API keys, and — on PRO — your webhooks.
Each endpoint has its own checkbox on the API screen. Unticking one removes it from the route table for every request — it answers 404 with the core code rest_no_route, exactly as though it had never been registered. This is the right tool for narrowing what any integration can reach at all, regardless of who authenticates.
The settings live in the wcusage_api_settings option. Only the exceptions are stored, so an endpoint added in a later release is on by default once the API itself is on.
| Behaviour | API on | API off |
|---|---|---|
wcusage/v2 routes | Registered | Not registered (404) |
| API key authentication | Works | Refused everywhere, v1 included |
| Webhook deliveries (PRO) | Queued and sent | Not queued; already-queued retries are dropped |
| Adding and testing webhooks in wp-admin (PRO) | Works | Works |
Legacy woo-coupon-usage/v1 routes | Available | Still available (application password or cookie auth only) |
Every endpoint except /openapi requires an authenticated WordPress user. Three methods work; each resolves to a user, and that user's capabilities decide what the request may access.
Built into WordPress. Create one under Users → Profile → Application Passwords, then send it with HTTP Basic auth:
curl https://example.com/wp-json/wcusage/v2/affiliates \
-u "admin:abcd efgh ijkl mnop qrst uvwx"
An application password carries the user's full permissions, on this API and on every other WordPress endpoint. It is the quickest way to get started, and fine for a trusted server-to-server integration you control.
Plugin API keys are the better choice for anything you do not fully control, because they can be scoped, expired and revoked without touching the WordPress account. Create one under Coupon Affiliates → Admin Tools → API, or through the key management endpoints.
A key looks like wcus_9f2c1e4a… and is shown once, at creation — only a SHA-256 hash is stored. Send it as a bearer token:
curl https://example.com/wp-json/wcusage/v2/me \
-H "Authorization: Bearer wcus_9f2c1e4a7b3d5e6f8a9b0c1d2e3f4a5b6c7d8e9f"
If your client cannot set the Authorization header (some hosts strip it), an alternative header is accepted:
X-WCUsage-API-Key: wcus_9f2c1e4a7b3d5e6f8a9b0c1d2e3f4a5b6c7d8e9f
Every key:
wcusage/v2 and woo-coupon-usage/v1. Presenting one to a core or WooCommerce endpoint fails with 403 wcusage_api_key_wrong_namespace. An API key is not a general-purpose WordPress credential.| Scope | Grants |
|---|---|
read | Every GET endpoint: affiliates, coupons, stats, referred orders, payouts, registrations, clicks, events, reports. |
write | Creating and changing data: payout requests, payout status changes, registration approval and decline, and refreshing stored coupon stats. |
manage | Managing the API itself: creating and revoking API keys, and — on PRO — creating, editing, testing and deleting webhooks. |
manage is not a peer of the other two. A key holding it can issue itself another key with any scope, and can point a webhook at a server of its choosing. Treat it as full access for the user behind the key, and give it only to integrations that genuinely administer the API.[/warning]
Scopes apply only to API keys. With an application password or a logged-in cookie, auth.scopes is null and access is governed purely by capabilities. A key missing a required scope receives 403 with the code wcusage_api_insufficient_scope.
On the legacy woo-coupon-usage/v1 namespace, which predates scopes, keys are held to a fail-closed default: GET, HEAD and OPTIONS need read, anything else needs write.
Same-site JavaScript can use the logged-in cookie with a REST nonce, exactly as with any WordPress REST endpoint:
fetch( '/wp-json/wcusage/v2/me', {
headers: { 'X-WP-Nonce': wpApiSettings.nonce },
credentials: 'same-origin'
} ).then( r => r.json() );
Bearer tokens are credentials, so they are refused over plain HTTP unless wp_get_environment_type() reports local or development. Such a request fails with 401 wcusage_api_https_required. The wcusage_api_require_https filter can override it — do that only if you know exactly why.
An address that presents 20 invalid keys within 15 minutes is refused further attempts with 429 wcusage_api_too_many_auth_failures until the window rolls over. Counts are kept per IP address, so one client cannot lock out another. Adjust with the wcusage_api_max_auth_failures filter.
Whatever method you use, GET /me reports exactly what the request is authenticated as, what access level it has, and which scopes are in force. Call it first when setting up any integration.
GET parameters go in the query string; POST and PATCH parameters go in a JSON body with Content-Type: application/json (WordPress also accepts form-encoded bodies).GET /reports/summary reports the currency code.Every collection endpoint takes the same two parameters:
| Param | Type | Description |
|---|---|---|
| page | integer | Page number, starting at 1. Default 1. |
| per_page | integer | Items per page, 1–100. Default 20. |
And returns the same two headers:
X-WP-Total — total matching items.X-WP-TotalPages — total pages at the current per_page.A page past the end of the result set returns an empty array with correct headers, without running the underlying query.
from, to, expires) use Y-m-d, e.g. 2026-08-01. Anything else is rejected with 400 rest_invalid_param.2026-08-07T14:03:22. Empty dates are null.from is given without to, to defaults to today.Errors use the standard WordPress REST shape. Every code raised by this plugin is prefixed wcusage_api_:
{
"code": "wcusage_api_forbidden",
"message": "You do not have permission to access this resource.",
"data": { "status": 403 }
}
Rate-limit and throttle errors add retry_after (seconds) to data.
| Status | Meaning |
|---|---|
| 400 | Invalid parameter, or the action is not possible right now (no unpaid balance, status already set, activity log disabled). |
| 401 | Not authenticated, or the API key is invalid, revoked, expired, or was sent over plain HTTP. |
| 403 | Authenticated but not permitted — wrong capability, missing scope, or a key used outside the plugin's namespaces. |
| 404 | Not found. Also returned for a resource that exists but belongs to somebody else, and for an endpoint switched off in the settings. |
| 409 | Conflict — another request is mid-flight, or the resource changed underneath this one. |
| 429 | Rate limited, throttled, or locked out after repeated authentication failures. |
| 500 | Server error, e.g. the site could not generate a secure token. |
| 501 | The feature this endpoint reads is not present on this install (payouts add-on inactive, table missing). |
404, not 403. That is deliberate: it stops an authenticated affiliate walking the ID space to learn which coupons, payouts or affiliates exist. So do not read a 404 as proof that an ID is unused.[/note]
A full list of plugin error codes is in the Error reference.
Requests to both plugin namespaces are counted per minute, per identity — the API key, the logged-in user, or a shared bucket for anonymous callers:
Over the limit, requests get 429 wcusage_api_rate_limited with retry_after: 60. Malformed requests are counted too, so a client stuck retrying a bad parameter is throttled like any other. Adjust the ceiling with the wcusage_api_rate_limit filter.
Most endpoints read cached figures and are cheap enough to poll. A few recalculate from the order history and are deliberately expensive; those are cached and throttled so one client cannot make the database do unbounded work.
| Endpoint | Behaviour |
|---|---|
/coupons/{id}/stats (all-time) | Reads the stored snapshot — the same figures the affiliate dashboard shows. One meta read. Reports source: "cache". |
/coupons/{id}/stats (with from/to, or refresh=true) | Recalculates from the orders. Each range is cached 60 seconds; an uncached calculation is limited to one per coupon per minute. |
/coupons/{id}/orders | The prepared list is cached whole for 60 seconds per coupon + range + status, so paginating through it costs nothing. An uncached combination is limited to one per coupon per minute. |
/affiliates/{id}/stats (with dates) | Cached 60 seconds per range; uncached calculations limited to one per affiliate per minute. |
/reports/summary | Cached 5 minutes per top value. refresh=true bypasses it. |
| Everything else | Answered live from indexed queries. |
When an uncached recalculation is asked for inside the one-per-minute window, the response is 429 with code wcusage_api_throttled and retry_after: 60. The one exception is all-time coupon stats, which return the stored snapshot with source: "throttled" rather than failing.
429s.[/tip]
/events?after={id} — one cheap indexed query — or use webhooks and stop polling altogether./reports/summary replaces walking every affiliate.per_page=100 on collections instead of many small pages.from/to) for frequent reads; ask for a range only when you need one.The API hands external systems real access to your affiliate program — personal data, commission figures and, in some configurations, the ability to move money. Please read this page before building anything against it.
Enabling the API is an explicit decision, and everything done with the credentials you issue is done on your authority and under your account. In particular, you are responsible for:
Several endpoints return personal data about real people: affiliate names, logins and email addresses, registration profile fields such as phone numbers and websites, and any custom registration fields your store collects.
Commission figures, payout amounts and report totals come from the plugin's own records and are provided for information. They are not an accounting system, a tax record or a substitute for your payment provider's statements.
source and last_refreshed, or request a fresh calculation.A webhook receiver is a public HTTP endpoint that anybody on the internet can send a request to. Nothing about a payload proves it came from your store except a valid signature.
Connecting an external platform or an AI assistant means transmitting affiliate data to that provider, where it may be logged, retained or used for their own purposes according to their terms — not yours. Check what you are agreeing to before you connect it.
AI agents carry a specific extra risk: an agent acts on text it reads, and some of that text can come from outside your control — an application's "how will you promote us" field, a campaign name, a website URL. An agent with write access can be steered by content like that into taking actions you never asked for. Give agents read-only keys unless you have a concrete reason not to, and never give one the manage scope.
code in an error response is the stable part; the message is not, and is translated.429 gracefully rather than assuming a fixed budget./me, /openapi and /webhooks endpoints are usually enough to work out where a problem lies.Coupon Affiliates is free software, licensed under the GNU General Public License version 3. Sections 15 and 16 of that license disclaim all warranties and limit liability, and those terms apply to the API exactly as they apply to the rest of the plugin. The API, the code samples in these docs and the integration patterns they describe are therefore provided as is, without warranty of any kind.
The examples are illustrative starting points, not production-hardened code: they omit the logging, retry policy, input validation and secret management your own environment will need. Review, adapt and test anything you take from here before relying on it.
To the fullest extent permitted by law, the plugin's authors accept no liability for loss or damage arising from use of the API — including lost or exposed data, incorrect commission or payout amounts, missed or duplicated webhook deliveries, or the actions of any third-party service or automated agent you connect to it. Nothing here affects any statutory rights that cannot lawfully be excluded.
The plugin, its API and its add-ons are distributed under the GPLv3; a copy ships as license.txt in the plugin folder. You are free to use, study, modify and redistribute the code on those terms.
Every route in the API, with the access level and scope it needs. All paths are relative to https://example.com/wp-json/wcusage/v2.
| Method & path | Access | Scope | Purpose |
|---|---|---|---|
[get] /me | Any logged-in user | — | Identify the caller, its access level and scopes. |
[get] /affiliates | Admin | read | List affiliates with coupons and balances. |
[get] /affiliates/{id} | Admin or self | read | One affiliate, with profile fields and groups. |
[get] /affiliates/{id}/stats | Admin or self | read | Totals across all of an affiliate's coupons. |
[get] /coupons | Admin | read | List affiliate coupons. |
[get] /coupons/{id} | Admin, owner, upline | read | One coupon, with commission rates and referral URL. |
[get] /coupons/{id}/stats | Admin, owner, upline | read (+write to refresh) | Sales and commission, all-time or by date range. |
[get] /coupons/{id}/orders | Admin, owner, upline | read | Orders referred by a coupon, with commission per order. |
[get] /payouts | Any logged-in user | read | List payouts. Non-admins see only their own. |
[post] /payouts | Admin or coupon owner | write | Request a payout for a coupon's unpaid balance. |
[get] /payouts/{id} | Admin or owner | read | One payout. |
[post] /payouts/{id}/status | Admin | write | Change a payout status. Bookkeeping only. |
[get] /registrations | Admin | read | List affiliate applications. |
[get] /registrations/{id} | Admin | read | One application. |
[post] /registrations/{id}/status | Admin | write | Approve or decline an application. |
[get] /clicks/stats | Admin, or owner with coupon_id | read | Clicks, conversions and conversion rate. |
[get] /events | Admin | read | Change feed with a cursor for polling. |
[get] /reports/summary | Admin | read | Store-wide totals and top affiliates. |
[get] /keys | Admin | manage | List API keys. |
[post] /keys | Admin | manage | Create an API key. |
[delete] /keys/{id} | Admin | manage | Revoke an API key. |
[get] /webhooks | Admin | manage | PRO. List webhook endpoints. |
[post] /webhooks | Admin | manage | PRO. Create a webhook endpoint. |
[patch] /webhooks/{id} | Admin | manage | PRO. Change status or subscribed events. |
[delete] /webhooks/{id} | Admin | manage | PRO. Delete a webhook endpoint. |
[post] /webhooks/{id}/test | Admin | manage | PRO. Send a test delivery. |
[get] /webhooks/events | Admin | read | PRO. The catalog of subscribable events. |
[get] /openapi | Public by default | — | Machine-readable description of this API. |
"Owner" means the affiliate the coupon is assigned to; "upline" means their multi-level parent, which exists in PRO only. Payout and webhook routes are PRO only. Every endpoint can additionally be switched off per site — see Enabling the API.
Identify the authenticated caller, its access level and its scopes. This is the ideal first call for any integration — an AI agent can use it to discover what it is allowed to do before attempting anything.
[get] /wp-json/wcusage/v2/me
Permission: any authenticated user. No scope is needed for the identity fields; the coupons array requires read.
{
"user": { "id": 1456, "display_name": "Sarah J", "login": "sarahj", "email": "sarah@example.com" },
"is_admin": false,
"is_affiliate": true,
"auth": { "method": "api_key", "key_id": 3, "scopes": ["read"] },
"coupons": [
{
"id": 8338,
"code": "sarah10",
"user_id": 1456,
"date_created": "2023-05-02T10:11:12",
"unpaid_commission": 40.46,
"pending_order_commission": 0,
"pending_payout_commission": 0
}
],
"api_version": "8.2.0"
}
| Field | Type | Description |
|---|---|---|
| user | object | id and display_name always; login and email only when the caller is an admin or is that user. |
| is_admin | boolean | Whether the caller passes the plugin's admin access check. |
| is_affiliate | boolean | Whether the user has at least one affiliate coupon. |
| auth.method | string | api_key or wordpress. |
| auth.key_id | integer | The API key's ID, or null. |
| auth.scopes | array | Scopes in force, or null for capability-based auth (full access for that user). |
| coupons | array | The caller's own affiliate coupons with balances. Empty for non-affiliates. |
| api_version | string | The installed Coupon Affiliates version. |
auth.scopes: null means the request is not scope-limited — it authenticated with an application password or cookie, so the user's capabilities are the only limit.[/note]
An affiliate is a WordPress user with at least one published coupon assigned to them. There is no separate affiliate table; these endpoints derive the entity the same way the admin list table does, and aggregate across all of an affiliate's coupons.
[get] /wp-json/wcusage/v2/affiliates
Permission: admin, read scope.
| Param | Type | Description |
|---|---|---|
| search | string | Partial match against user login, email or display name. |
| page / per_page | integer | Standard pagination. |
[
{
"user": { "id": 1456, "display_name": "Sarah J", "login": "sarahj", "email": "sarah@example.com" },
"coupons": [
{ "id": 8338, "code": "sarah10", "user_id": 1456, "date_created": "2023-05-02T10:11:12",
"unpaid_commission": 40.46, "pending_order_commission": 0, "pending_payout_commission": 0 }
],
"unpaid_commission": 40.46,
"pending_payout_commission": 0
}
]
Results are ordered by user ID ascending. Only users holding at least one published assigned coupon appear.
[get] /wp-json/wcusage/v2/affiliates/{user_id}
Permission: admin, or the affiliate themselves. read scope.
Returns the list shape plus detail fields, and each coupon carries its cached all-time stats block:
| Extra field | Type | Description |
|---|---|---|
| date_registered | string | When the WordPress account was created. |
| profile | object | Registration profile fields: phone, website, promote, referrer. |
| groups | array | Affiliate group roles the user holds. |
| mla_parents | object | PRO only. Multi-level upline chain, keyed by tier. Absent entirely in the free build. |
[get] /wp-json/wcusage/v2/affiliates/{user_id}/stats
Permission: admin, or the affiliate themselves. read scope.
| Param | Type | Description |
|---|---|---|
| from | date | Optional start date (Y-m-d). When set, figures are recalculated from the orders for the range instead of read from the all-time cache. |
| to | date | Optional end date. Defaults to today when from is set. |
{
"user_id": 1456,
"from": null,
"to": null,
"totals": {
"orders_count": 19,
"total_sales": 1977.80,
"total_discount": 197.78,
"total_commission": 181.60,
"unpaid_commission": 40.46,
"pending_payout_commission": 0
},
"coupons": [
{ "id": 8338, "code": "sarah10", "orders_count": 19, "total_sales": 1977.80,
"total_discount": 197.78, "total_commission": 181.60 }
]
}
The two balance figures in totals are always current balances; they are not affected by from/to.
429 wcusage_api_throttled.[/note]
Affiliate coupons, with balances, stats and referred orders. Reads go through the same functions the affiliate dashboard uses, so caps, rounding and commission rules always match what affiliates see.
[get] /wp-json/wcusage/v2/coupons
Permission: admin, read scope.
| Param | Type | Description |
|---|---|---|
| user_id | integer | Only coupons assigned to this affiliate. Omit for every assigned affiliate coupon. |
| search | string | Match against the coupon code. |
| page / per_page | integer | Standard pagination. |
Returns published coupons that have an affiliate assigned, newest first. Coupons with no assigned affiliate are never listed.
[get] /wp-json/wcusage/v2/coupons/{id}
Permission: admin, the assigned affiliate, or their multi-level upline (PRO). read scope.
{
"id": 8338,
"code": "sarah10",
"user_id": 1456,
"user": { "id": 1456, "display_name": "Sarah J" },
"date_created": "2023-05-02T10:11:12",
"unpaid_commission": 40.46,
"pending_order_commission": 0,
"pending_payout_commission": 0,
"stats": {
"orders_count": 19, "total_sales": 1977.80, "total_discount": 197.78,
"total_shipping": 0, "total_commission": 181.60, "last_refreshed": "2026-08-01T02:00:00"
},
"commission": { "percent": 10, "percent_override": "", "fixed_per_order": "", "fixed_per_product": "" },
"referral_url": "https://example.com/affiliate-dashboard/?couponid=sarah10"
}
| Field | Description |
|---|---|
| unpaid_commission | Commission earned, cleared, and not yet paid out or requested. |
| pending_order_commission | Commission on orders still inside the pending period (not yet payable). |
| pending_payout_commission | Commission tied up in payouts that have been requested but not paid. |
| commission.percent | The percentage rate resolved for this coupon: its own override if it has one, otherwise the store's default rate. |
| commission.percent_override | The per-coupon override itself. Empty when the coupon inherits the store default. |
| commission.fixed_per_order | Fixed amount per referred order, if configured. Empty when unused. |
| commission.fixed_per_product | Fixed amount per product, if configured. Empty when unused. |
| referral_url | The affiliate's referral URL for this coupon, pointing at the affiliate dashboard page. |
| stats.last_refreshed | When the stored snapshot was last rebuilt, or null if it never has been. |
Draft and private coupons are addressable — a coupon does not have to be published to have an affiliate and a balance. Trashed and auto-draft coupons answer 404.
code_ambiguous below.[/note]
[get] /wp-json/wcusage/v2/coupons/{id}/stats
Permission: admin, owner, or MLA upline. read scope; refresh=true additionally needs write.
| Param | Type | Description |
|---|---|---|
| from / to | date | Optional range. Without dates, the stored all-time snapshot is returned (fast). With dates, the range is calculated from the orders. |
| refresh | boolean | Recalculate the all-time figures from the orders and save the result. Default false. Ignored when a date range is given, since those are always calculated. |
{
"coupon_id": 8338,
"code": "sarah10",
"from": null,
"to": null,
"source": "cache",
"code_ambiguous": false,
"orders_count": 19,
"total_sales": 1977.80,
"total_discount": 197.78,
"total_shipping": 0,
"total_commission": 181.60,
"status_counts": { "Completed": 17, "Refunded": 2 },
"last_refreshed": "2026-08-01T02:00:00",
"unpaid_commission": 40.46,
"pending_order_commission": 0,
"pending_payout_commission": 0
}
| Field | Description |
|---|---|
| source | cache — the stored snapshot or a short-lived cached calculation. live — freshly calculated for this request. throttled — a rebuild was wanted but the per-coupon budget was spent, so the stored snapshot was returned instead. |
| code_ambiguous | true when another published coupon answers to the same code. The stats layer is keyed by code, so figures for this coupon are not reliably addressable and are never saved back to it. |
| status_counts | Order counts by display status name, e.g. {"Completed": 17}. An empty object when the figures came from the stored snapshot, which holds no breakdown. |
| last_refreshed | null for ranged requests — those are always calculated, so there is no "last refreshed" moment to report. |
The response shape is stable regardless of which path answered: status_counts and last_refreshed are always present, even when empty.
refresh=true is a write wearing a GET's clothes — it rescans the coupon's entire order history and saves the result. A read-only key gets 403 wcusage_api_insufficient_scope; it still receives figures from the stored snapshot on an ordinary request.[/warning]
[get] /wp-json/wcusage/v2/coupons/{id}/orders
Permission: admin, owner, or MLA upline. read scope.
| Param | Type | Description |
|---|---|---|
| from / to | date | Optional date range. |
| status | string | Order status slug without the wc- prefix, e.g. completed. |
| page / per_page | integer | Standard pagination. |
[
{
"order_id": 8355,
"date": "2023-07-28T12:46:58",
"status": "completed",
"total": 179.80,
"discount": 17.98,
"commission": 16.18
}
]
Newest first. Only orders that actually counted towards this coupon are listed — an order that used the code but was later reassigned to another affiliate is excluded, which keeps this endpoint consistent with /stats.
One request considers at most 5,000 orders (the newest ones). When that cap is hit, the response carries the header X-WCUsage-Truncated: 1, so a client can tell a capped total from a real one. Raise it with the wcusage_api_max_order_rows filter if your program needs a deeper window.
wcusage_api_order_item filter.[/note]
Read payout history and create payout requests. Payouts are a PRO feature; on a build or install without them, these endpoints are absent or answer 501 wcusage_api_unavailable.
gateway_triggered.[/warning]
These are the highest-consequence endpoints in the API. Prove any automation on a staging store before pointing it at a live one, reconcile amounts against your payment provider rather than treating these figures as an accounting record, and keep the responsibility for verifying payments with a person. If you would rather nothing automated could ever create a payout, switch the /payouts endpoints off on the API screen — everything else keeps working.
[get] /wp-json/wcusage/v2/payouts
Permission: any authenticated user, read scope. Non-admins are always restricted to their own payouts, whatever user_id they send.
| Param | Type | Description |
|---|---|---|
| user_id | integer | Filter by affiliate. Admin only — overridden for everyone else. |
| coupon_id | integer | Filter by coupon. |
| status | string | One of pending, created, paid, cancel. |
| from / to | date | Filter by request date. |
| page / per_page | integer | Standard pagination. |
{
"id": 321,
"user": { "id": 1456, "display_name": "Sarah J" },
"coupon_id": 8338,
"coupon_code": "sarah10",
"amount": 40.46,
"method": "PayPal",
"method_type": "paypal",
"status": "pending",
"has_details": true,
"transaction_id": "",
"invoice_id": 0,
"date": "2026-08-07T09:00:00",
"date_paid": null
}
[note]Payout destination details — PayPal addresses, bank accounts, wallet addresses — are never returned by the API. Only the boolean has_details tells you whether any are stored.[/note]
[get] /wp-json/wcusage/v2/payouts/{id}
Permission: admin or the payout's owner. read scope. Somebody else's payout answers 404.
[post] /wp-json/wcusage/v2/payouts
Permission: admin (any coupon), or the coupon's affiliate (their own). write scope.
| Body param | Type | Description |
|---|---|---|
| coupon_id | integer | Required. The coupon's full unpaid balance is requested; there is no partial-amount parameter. |
curl -X POST https://example.com/wp-json/wcusage/v2/payouts \
-H "Authorization: Bearer wcus_..." \
-H "Content-Type: application/json" \
-d '{"coupon_id": 8338}'
Returns 201 with the created payout, plus two extra fields:
gateway_triggered — true when auto-accept moved the payout straight to created or paid, meaning a real gateway may have been called.gateway_notice — present only when a gateway handler produced a message worth passing on.Only one open request can exist per coupon at a time. If one already exists (pending, created or processing), the existing payout is returned with 200 and the header X-WCUsage-Existing: 1 — so a retrying client can never create duplicates. Concurrent requests for the same coupon are serialised with a lock; the loser gets 409 wcusage_api_in_progress.
Requests are held to the same rules as the affiliate dashboard, so the API cannot be used to route around a store's configuration:
| Error code | Condition |
|---|---|
payouts_disabled | Payouts are switched off in the plugin settings. |
requests_disabled | Affiliate self-service requests are switched off; only the store owner creates payouts. Not applied to admin callers. |
no_affiliate | The coupon has no affiliate assigned. |
no_balance | The unpaid balance is zero or less. |
below_threshold | The unpaid balance is under the store's minimum payout threshold. Enforced for admins too. |
no_payout_details | The affiliate has not saved payout details and the store requires them. |
method_disabled | The affiliate's saved payout method is no longer enabled on the store. Not applied to admin callers. |
invoice_required | The store requires an invoice upload for this payout method. Invoices cannot be uploaded through the API, so the request must be made from the affiliate dashboard. |
not_created | Everything checked out but the site refused the request — typically a custom wcusage_before_payout_submit filter, or a failed write. |
[post] /wp-json/wcusage/v2/payouts/{id}/status
Permission: admin, write scope.
| Body param | Type | Description |
|---|---|---|
| status | string | Required. One of pending, created, paid, cancel. |
This runs the same status flow as the admin screen: balances move between the unpaid, pending-payout and paid pools, the activity log records the change, notification emails are sent and webhooks fire. No payment gateway is contacted.
| From | To |
|---|---|
pending | paid, cancel, created |
created | paid, cancel, pending |
paid | cancel, pending, created |
cancel | pending, created |
Anything else is refused with 400 wcusage_api_invalid_transition rather than leaving the balances inconsistent. Setting the status a payout already has returns 400 wcusage_api_no_change.
processing and failed are deliberately not accepted. Nothing in the plugin writes them and the status arithmetic has no branch for them, so moving a payout through either one would strand its balance with no way back.[/note]
Two further guards:
409 wcusage_api_insufficient_unpaid rather than letting the ledger drift.409 wcusage_api_conflict.Read and moderate affiliate applications. These endpoints read the plugin's registration table, and approval goes through the same function the admin screen calls, so the full accept flow runs identically.
[get] /wp-json/wcusage/v2/registrations
Permission: admin, read scope.
| Param | Type | Description |
|---|---|---|
| status | string | pending, accepted or declined. |
| user_id | integer | Filter by WordPress user. |
| page / per_page | integer | Standard pagination. |
{
"id": 512,
"user": { "id": 2201, "display_name": "New Affiliate", "login": "newaffiliate", "email": "new@example.com" },
"coupon_code": "newaff15",
"status": "pending",
"type": "",
"promote": "Instagram and my newsletter",
"referrer": "",
"website": "https://blog.example.net",
"custom_fields": { "Audience size": "12000" },
"date": "2026-08-05T18:22:41",
"date_accepted": null
}
custom_fields holds whatever extra registration fields the store has configured, as a flat object. It is an empty object when there are none.
[get] /wp-json/wcusage/v2/registrations/{id}
Permission: admin, read scope.
[post] /wp-json/wcusage/v2/registrations/{id}/status
Permission: admin, write scope.
| Body param | Type | Description |
|---|---|---|
| status | string | Required. accepted or declined. |
| message | string | Optional message included in the notification email. |
| send_email | boolean | Whether to send the notification email. Default true. |
Returns the updated registration.
[warning]Accepting runs the full approval flow: the affiliate coupon is created from the template, roles are assigned, emails are sent and hooks fire — exactly as if approved from the admin screen.[/warning]| Error code | Condition |
|---|---|
no_change (400) | The registration already has that status. |
already_accepted (400) | The registration was already accepted. Accepting is not reversible through the API, because an accept → decline → accept cycle would create a second published coupon with the same code and its own separate balance. Reverse it from the admin screens if you really must. |
no_coupon_code (409) | The registration carries no coupon code, so its status cannot be changed. Add one from the admin screens first. |
not_updated (409) | Something on the site refused the change. The response reports what the row actually says rather than what was asked for. |
unavailable (501) | The registrations table does not exist on this install. |
Aggregated referral-link click statistics, counted in SQL rather than loaded row by row.
[get] /wp-json/wcusage/v2/clicks/stats
Permission: admin for store-wide figures; affiliates must pass a coupon_id they own. read scope.
| Param | Type | Description |
|---|---|---|
| coupon_id | integer | Limit to one coupon. Required for non-admin users — omitting it returns 400 wcusage_api_coupon_required. |
| campaign | string | Filter by campaign name (exact match). |
| from / to | date | Optional date range. |
{
"coupon_id": 8338,
"campaign": null,
"from": "2026-07-01",
"to": "2026-07-31",
"clicks": 412,
"conversions": 19,
"conversion_rate": 4.61
}
conversion_rate is a percentage rounded to two decimals, and is 0 when there were no clicks.
501 wcusage_api_unavailable.[/note]
A cursor-based change feed over the plugin's activity log. This is the polling counterpart to webhooks: ask "what happened since event X" and act on the answer. Webhooks are PRO, so on the free version this endpoint is how you keep an external system in step.
[get] /wp-json/wcusage/v2/events
Permission: admin, read scope.
| Param | Type | Description |
|---|---|---|
| after | integer | Cursor. Returns only events with a higher ID, oldest first. Without it, newest first. |
| event | string | Filter by event type. |
| user_id | integer | Filter by the acting user. |
| page / per_page | integer | Standard pagination. page is ignored in cursor mode. |
[
{ "id": 9911, "event": "payout_paid", "event_id": 321, "user_id": 1, "info": "40.46", "date": "2026-08-07T10:15:00" },
{ "id": 9912, "event": "referral", "event_id": 8355, "user_id": 0, "info": "sarah10", "date": "2026-08-07T10:16:31" }
]
The response header X-WCUsage-Last-Event carries the highest event ID returned. Store it and pass it as after on the next poll:
GET /wp-json/wcusage/v2/events?after=9912&per_page=100
→ X-WCUsage-Last-Event: 9987
| Event | event_id refers to |
|---|---|
referral | Order |
commission_added, commission_removed | Coupon |
mla_commission_added, mla_commission_removed | Coupon (PRO) |
registration, registration_accept | Registration |
payout_request, payout_paid, payout_reversed, payout_cancelled | Payout (PRO) |
reward_earned | Reward (PRO) |
new_campaign | Campaign (PRO) |
direct_link_domain | Direct link (PRO) |
mla_invite | Invite (PRO) |
lifetime_link_edited | Customer (PRO) |
api_key_created, api_key_revoked | API key |
user_id is the acting user, and is 0 for system and guest actions such as a referral from a logged-out shopper. info is free-form context whose meaning depends on the event type.
registration.declined webhook (PRO) if you need them.[/note]
[warning]The feed needs the activity log enabled in the plugin settings (it is by default). When it is off, this endpoint answers 400 wcusage_api_log_disabled. Webhooks are unaffected — they fire from the same funnel, before the log setting is consulted.[/warning]
A store-wide program summary, built for dashboards and AI assistants that need the whole picture in one call.
[get] /wp-json/wcusage/v2/reports/summary
Permission: admin, read scope.
| Param | Type | Description |
|---|---|---|
| top | integer | How many top affiliates to include, by all-time commission. 0–50, default 10. |
| refresh | boolean | Bypass the 5-minute report cache. Default false. |
{
"generated": "2026-08-07T14:05:00",
"currency": "USD",
"totals": {
"affiliates": 448,
"coupons": 512,
"orders_count": 10231,
"total_sales": 812337.20,
"total_discount": 79110.55,
"total_commission": 81233.71,
"unpaid_commission": 6120.44,
"pending_payout_commission": 1240.00
},
"payouts": { "pending_count": 12, "pending_amount": 1240.00, "paid_count": 981, "paid_amount": 73873.27 },
"pending_registrations": 7,
"top_affiliates": [
{ "user_id": 1456, "orders_count": 19, "total_sales": 1977.80, "total_commission": 181.60,
"user": { "id": 1456, "display_name": "Sarah J", "login": "sarahj", "email": "sarah@example.com" } }
],
"cached": false
}
cached: true means the response came from the 5-minute cache. The payouts block is present only when the payouts add-on is available — the free build omits it entirely rather than reporting four zeros as though the program had simply never paid anybody.
unpaid_commission, pending_payout_commission) are always current.[/note]
Keys can be managed in the admin UI (Coupon Affiliates → Admin Tools → API) or programmatically. Every route here requires an admin with the manage scope.
[get] /wp-json/wcusage/v2/keys
Standard pagination. Returns metadata only — never tokens or hashes:
[
{
"id": 4,
"user_id": 1456,
"description": "Zapier integration",
"key_prefix": "wcus_9f2c1e",
"scopes": ["read"],
"status": "active",
"last_used": "2026-08-07T13:55:00",
"date_created": "2026-08-01T09:12:00",
"date_expires": null
}
]
key_prefix is the first 12 characters of the token — enough to recognise a key in your own logs, useless as a credential.
[post] /wp-json/wcusage/v2/keys
| Body param | Type | Description |
|---|---|---|
| user_id | integer | The user the key acts as. Defaults to the current user. Use an affiliate's user ID to create a key limited to their own data. |
| description | string | Label, e.g. "Zapier integration". Up to 200 characters. |
| scopes | array | Any of read, write, manage. Default ["read"]. |
| expires | date | Optional expiry (Y-m-d). The key stops working at the end of that day, in the site's timezone. |
{
"id": 4,
"token": "wcus_9f2c1e4a7b3d5e6f8a9b0c1d2e3f4a5b6c7d8e9f",
"notice": "Store this token now - it cannot be shown again.",
"user_id": 1456
}
[warning]The token is returned only once, at creation, with 201. Only a SHA-256 hash is stored on the server, so a lost token cannot be recovered — revoke it and create another.[/warning]
You may always create a key for yourself. Creating one for another user requires the capability to edit that user, so a lower-privileged manager cannot mint a key that acts as a full administrator. Otherwise the response is 403 wcusage_api_cannot_create_for_user.
[delete] /wp-json/wcusage/v2/keys/{id}
{ "revoked": true }
Revocation is immediate: clients using the key receive 401 on their next request. It is idempotent — revoking an already-revoked key reports success. The same "could you edit that user" rule applies, so one plugin admin cannot destroy an administrator's integration credential.
Key creation and revocation are both recorded in the activity log (api_key_created, api_key_revoked), so they show up in /events.
/webhooks* routes are not registered and the Webhooks card does not appear on the API screen. The free equivalent is to poll /events, which carries a cursor so you only ever fetch what is new.[/note]
Webhooks push signed JSON notifications to a URL you choose, the moment something happens in your affiliate program — no polling required. Set them up under Coupon Affiliates → Admin Tools → API → Webhooks, or through the management API.
Every lifecycle event in the plugin already flows through one funnel, and the webhook dispatcher listens to it. Deliveries are queued (Action Scheduler when WooCommerce provides it, otherwise WP-Cron), so your endpoint never slows down a checkout or an admin action.
Every event below needs PRO, since webhooks themselves do. These first ones are raised by the core plugin, so they arrive on any PRO install:
| Event | Fires when |
|---|---|
referral.created | A referred order is attributed to an affiliate. |
registration.created | A new affiliate registration is submitted. |
registration.accepted | A registration is approved. |
registration.declined | A registration is declined. |
commission.added | Commission is credited to an affiliate. |
commission.removed | Commission is removed (refund, cancellation, manual deduction). |
affiliate.created | An affiliate's coupon has been created and is ready to use. Fires after approval, and also when an admin creates an affiliate directly. |
These further events come from individual PRO add-ons, so they need that add-on to be active as well:
| Event | Fires when | Add-on |
|---|---|---|
payout.requested | An affiliate requests a payout. | Payouts |
payout.paid | A payout is marked as paid. | Payouts |
payout.reversed | A payout is reversed. | Payouts |
payout.cancelled | A payout is cancelled and its amount returned to the unpaid balance. | Payouts |
affiliate.payout_details_updated | An affiliate changes their payout method or details. | Payouts |
reward.earned | An affiliate earns a reward or bonus. | Rewards |
campaign.created | An affiliate creates a campaign. | Campaigns |
directlink.created | An affiliate registers a direct-link domain. | Direct Link |
commission.mla_added | Multi-level commission is credited to an upline. | Multi-Level |
commission.mla_removed | Multi-level commission is removed from an upline. | Multi-Level |
mla.invite_created | A multi-level affiliate invite is created. | Multi-Level |
mla.sub_registered | Someone registers as a sub-affiliate under an existing affiliate. | Multi-Level |
Subscribe an endpoint to specific events, or to * for everything. The live catalog for your install is always available at [get] /wp-json/wcusage/v2/webhooks/events — it lists only events this build can actually raise, so you never subscribe to a notification that could never arrive.
affiliate.payout_details_updated arriving shortly before payout.requested is a well-known fraud pattern. It is worth watching even if you do nothing else with webhooks.[/tip]
wcusage_api_webhook_require_https filter.Every delivery is a JSON POST with the same envelope. The data block is rebuilt from the live object at delivery time, so a retry never carries stale figures:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: CouponAffiliates-Webhook/8.2.0
X-WCUsage-Event: payout.paid
X-WCUsage-Delivery: whd_66b4a1e2c3d4f5.12345678
X-WCUsage-Signature: t=1786457100,v1=5f8a2b...
{
"event": "payout.paid",
"created": "2026-08-07T14:05:00+00:00",
"site": "https://example.com",
"data": {
"payout_id": 321,
"user_id": 1456,
"coupon_id": 8338,
"amount": 40.46,
"method": "PayPal",
"method_type": "paypal",
"status": "paid",
"date": "2026-08-07T09:00:00",
"date_paid": "2026-08-07T14:04:58"
}
}
| Envelope field | Description |
|---|---|
| event | The webhook event name, e.g. payout.paid. |
| created | When the delivery was built, RFC 3339 in UTC. |
| site | The sending site's home URL — useful when one receiver serves several stores. |
| data | Event-specific payload; shapes below. |
| Event(s) | data fields |
|---|---|
referral.created | order_id, coupon, user_id, commission |
commission.added, commission.removed, commission.mla_added, commission.mla_removed | coupon_id, coupon, user_id, note, order_id (parsed from the note; null when there is none) |
payout.requested, payout.paid, payout.reversed, payout.cancelled | payout_id, user_id, coupon_id, amount, method, method_type, status, date, date_paid |
registration.created, registration.accepted, registration.declined | registration_id, user_id, coupon_code, status, type, date |
affiliate.created | user_id, coupon, coupon_id, coupon_ids (every coupon they now hold) |
affiliate.payout_details_updated | user_id, method_type, has_details |
directlink.created | directlink_id, coupon_id, coupon, user_id, website, campaign, status |
mla.invite_created | invite_id, user_id, status, date |
mla.sub_registered | user_id, parent_user_id, coupon |
reward.earned, campaign.created | object_id, info |
ping (test delivery) | message |
mla.invite_created, for example, deliberately omits the invitee's email address. Add fields for your own trusted receiver with the wcusage_api_webhook_payload filter.[/note]
Each webhook has a secret (whsec_…), shown in the admin UI and returned once on creation through the API. Every delivery is signed with HMAC-SHA256 over "<timestamp>.<raw body>":
X-WCUsage-Signature: t=<unix timestamp>,v1=<hex hmac>
Verify it before trusting anything in the payload:
<?php
$secret = 'whsec_your_webhook_secret';
$body = file_get_contents( 'php://input' );
$header = $_SERVER['HTTP_X_WCUSAGE_SIGNATURE'] ?? '';
$parts = [];
foreach ( explode( ',', $header ) as $pair ) {
[ $k, $v ] = array_pad( explode( '=', $pair, 2 ), 2, '' );
$parts[ trim( $k ) ] = trim( $v );
}
$expected = hash_hmac( 'sha256', ( $parts['t'] ?? '' ) . '.' . $body, $secret );
if ( ! hash_equals( $expected, $parts['v1'] ?? '' ) ) {
http_response_code( 401 );
exit; // signature mismatch
}
if ( abs( time() - (int) ( $parts['t'] ?? 0 ) ) > 300 ) {
http_response_code( 401 );
exit; // replayed or stale delivery
}
http_response_code( 200 );
// then process json_decode( $body, true ) out of band
[tip]Always compare with a constant-time function (hash_equals), sign over the raw body rather than a re-encoded copy, and reject timestamps older than a few minutes to prevent replay.[/tip]
PATCH request; either resets the failure counter and clears the last error.X-WCUsage-Delivery to deduplicate if your handler is not idempotent.GET /webhooks.Tune the two limits with the wcusage_api_webhook_max_attempts and wcusage_api_webhook_max_failures filters.
All routes require an admin with the manage scope, except the event catalog, which needs read. PRO only — none of these routes are registered in the free build, so they answer 404 rest_no_route there.
[get] /wp-json/wcusage/v2/webhooks
[
{
"id": 1,
"name": "Ops Slack relay",
"url": "https://hooks.example.net/coupon-affiliates",
"events": ["payout.requested", "payout.paid"],
"status": "active",
"failures": 0,
"last_delivery": "2026-08-07T14:05:01",
"last_error": "",
"date_created": "2026-07-30T11:00:00"
}
]
Signing secrets are not included in list responses. Full administrators can read them on the admin page; other plugin admins see them masked.
[post] /wp-json/wcusage/v2/webhooks
| Body param | Type | Description |
|---|---|---|
| name | string | Label for the webhook. Up to 100 characters. |
| url | string | Required. HTTPS delivery URL. Validated against loopback and private addresses. |
| events | array | Required. Event names from the catalog, or ["*"] for all. Unknown names are dropped; if nothing valid remains the request fails with 400 wcusage_api_no_events. |
Returns 201 including the signing secret. Store it — it is the only way to verify deliveries, and this is the only response that carries it.
[patch] /wp-json/wcusage/v2/webhooks/{id}
| Body param | Type | Description |
|---|---|---|
| status | string | active or disabled. Re-activating resets the failure counter and clears the last error. |
| events | array | Replaces the subscribed events entirely. |
Sending neither returns 400 wcusage_api_no_fields.
[post] /wp-json/wcusage/v2/webhooks/{id}/test
Sends a signed ping delivery immediately and synchronously, and returns the HTTP status your endpoint answered with:
{ "code": 200 }
A transport-level failure (DNS, TLS, timeout) returns 502 wcusage_api_delivery_failed with the underlying message. Test deliveries do not count towards the failure counter.
[delete] /wp-json/wcusage/v2/webhooks/{id}
{ "deleted": true }
[get] /wp-json/wcusage/v2/webhooks/events
Permission: admin, read scope.
[
{ "event": "referral.created", "description": "A referred order was attributed to an affiliate." },
{ "event": "payout.paid", "description": "A payout was marked as paid." }
]
Read this rather than hard-coding the list: it reflects exactly which add-ons are active on the install.
Nothing here needs a client library — the API is plain HTTP with a bearer token. These are complete, working starting points.
[note]The samples on this page are illustrative and provided as is. They deliberately keep to the shortest thing that works, so they omit the logging, retry policy, input validation and secret handling a production integration needs. Review and adapt them before relying on them — see Security & Disclaimers.[/note]Talking to another store from a WordPress site, using the HTTP API that is already loaded:
$response = wp_remote_get(
'https://example.com/wp-json/wcusage/v2/reports/summary?top=5',
array(
'timeout' => 15,
'headers' => array(
'Authorization' => 'Bearer ' . WCUSAGE_API_KEY,
'Accept' => 'application/json',
),
)
);
if ( is_wp_error( $response ) ) {
error_log( 'Coupon Affiliates API unreachable: ' . $response->get_error_message() );
return;
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code ) {
// Every plugin error carries a machine-readable code.
error_log( 'API error ' . $code . ': ' . ( $body['code'] ?? 'unknown' ) );
return;
}
printf( 'Unpaid commission: %s %.2f', $body['currency'], $body['totals']['unpaid_commission'] );
Walking a collection to the end, using the pagination headers rather than guessing:
<?php
function wcu_api_get( $path, $params = [] ) {
$url = 'https://example.com/wp-json/wcusage/v2' . $path;
if ( $params ) {
$url .= '?' . http_build_query( $params );
}
$ch = curl_init( $url );
curl_setopt_array( $ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv( 'WCUSAGE_KEY' ) ],
CURLOPT_TIMEOUT => 20,
] );
$raw = curl_exec( $ch );
$status = curl_getinfo( $ch, CURLINFO_RESPONSE_CODE );
$header_size = curl_getinfo( $ch, CURLINFO_HEADER_SIZE );
curl_close( $ch );
$headers = substr( $raw, 0, $header_size );
$body = json_decode( substr( $raw, $header_size ), true );
$pages = 1;
if ( preg_match( '/^X-WP-TotalPages:\s*(\d+)/mi', $headers, $m ) ) {
$pages = (int) $m[1];
}
return [ 'status' => $status, 'body' => $body, 'pages' => $pages ];
}
$page = 1;
do {
$result = wcu_api_get( '/affiliates', [ 'page' => $page, 'per_page' => 100 ] );
if ( 200 !== $result['status'] ) {
throw new RuntimeException( 'API error: ' . ( $result['body']['code'] ?? $result['status'] ) );
}
foreach ( $result['body'] as $affiliate ) {
printf( "%-30s %8.2f unpaid\n",
$affiliate['user']['display_name'],
$affiliate['unpaid_commission']
);
}
$page++;
} while ( $page <= $result['pages'] );
Including the one piece of error handling that matters most in practice — honouring retry_after:
const BASE = 'https://example.com/wp-json/wcusage/v2';
const KEY = process.env.WCUSAGE_KEY;
async function api( path, params = {} ) {
const url = new URL( BASE + path );
Object.entries( params ).forEach( ( [ k, v ] ) => url.searchParams.set( k, v ) );
const res = await fetch( url, { headers: { Authorization: `Bearer ${KEY}` } } );
const body = await res.json();
if ( res.status === 429 ) {
const wait = ( body?.data?.retry_after ?? 60 ) * 1000;
await new Promise( r => setTimeout( r, wait ) );
return api( path, params ); // one retry after the window
}
if ( ! res.ok ) {
throw new Error( `${body.code}: ${body.message}` );
}
return { body, total: Number( res.headers.get( 'X-WP-Total' ) || 0 ) };
}
const { body: me } = await api( '/me' );
console.log( `Authenticated as ${me.user.display_name}, admin: ${me.is_admin}, scopes:`, me.auth.scopes );
import os, requests
BASE = "https://example.com/wp-json/wcusage/v2"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['WCUSAGE_KEY']}"
def get(path, **params):
r = SESSION.get(f"{BASE}{path}", params=params, timeout=20)
if r.status_code != 200:
raise RuntimeError(f"{r.status_code} {r.json().get('code', '')}")
return r
# All referred orders for one coupon last month, newest first.
page, orders = 1, []
while True:
r = get("/coupons/8338/orders", **{"from": "2026-07-01", "to": "2026-07-31",
"page": page, "per_page": 100})
orders += r.json()
if r.headers.get("X-WCUsage-Truncated") == "1":
print("Warning: order window was capped; narrow the date range.")
if page >= int(r.headers.get("X-WP-TotalPages", 1)):
break
page += 1
print(f"{len(orders)} orders, {sum(o['commission'] for o in orders):.2f} commission")
# Quick health check
curl -s https://example.com/wp-json/wcusage/v2/me \
-H "Authorization: Bearer $WCUSAGE_KEY" | jq
# Top 5 affiliates by all-time commission
curl -s "https://example.com/wp-json/wcusage/v2/reports/summary?top=5" \
-H "Authorization: Bearer $WCUSAGE_KEY" \
| jq -r '.top_affiliates[] | "\(.user.display_name)\t\(.total_commission)"'
# Anything that needs paying out
curl -s "https://example.com/wp-json/wcusage/v2/payouts?status=pending&per_page=100" \
-H "Authorization: Bearer $WCUSAGE_KEY" \
| jq -r '.[] | "#\(.id)\t\(.user.display_name)\t\(.amount)\t\(.method)"'
[tip]Keep the key in an environment variable or your platform's secret store, never in the script. If a key does leak, revoke it on the API screen — the WordPress account behind it is untouched.[/tip]
Zapier, Make, n8n, Pabbly Connect, Activepieces and similar tools all speak plain HTTP, so no special connector is needed. There are two ways to wire them up.
ping payload and learns the structure.data into whatever comes next — a Slack message, a spreadsheet row, a CRM record.This is instant, costs no polling quota, and scales to any program size.
[warning]Most no-code tools cannot verify the HMAC signature. Treat the catch-hook URL itself as a secret: it is long and unguessable, so do not publish it, and never let the automation perform a destructive or financial action purely on the say-so of an unverified payload. Where the action matters, have the scenario read the object back from the API (for exampleGET /payouts/{id}) before acting on it.[/warning]
If you are on the free version, or your tool cannot receive webhooks, poll the change feed on a schedule instead:
https://example.com/wp-json/wcusage/v2/events with the query after={{ last_id }}&per_page=100, and a header Authorization: Bearer wcus_….X-WCUsage-Last-Event in a data store / variable, and feed it back as last_id next run.event.Starting from after=0 would replay the whole history, so seed the cursor once with a single unfiltered call and store the highest id you see.
Actions such as approving an application are ordinary HTTP requests:
Method: POST
URL: https://example.com/wp-json/wcusage/v2/registrations/512/status
Headers: Authorization: Bearer wcus_...
Content-Type: application/json
Body: { "status": "accepted", "send_email": true }
Use a key with write for this, and keep it separate from any read-only key you use elsewhere so you can revoke one without breaking the other.
An Apps Script bound to a sheet, refreshing a leaderboard on a timer:
function refreshAffiliates() {
const key = PropertiesService.getScriptProperties().getProperty( 'WCUSAGE_KEY' );
const url = 'https://example.com/wp-json/wcusage/v2/reports/summary?top=50';
const res = UrlFetchApp.fetch( url, {
headers: { Authorization: 'Bearer ' + key },
muteHttpExceptions: true
} );
if ( res.getResponseCode() !== 200 ) {
throw new Error( res.getContentText() );
}
const data = JSON.parse( res.getContentText() );
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName( 'Affiliates' );
sheet.clear();
sheet.appendRow( [ 'Affiliate', 'Orders', 'Sales', 'Commission' ] );
data.top_affiliates.forEach( function ( a ) {
sheet.appendRow( [ a.user.display_name, a.orders_count, a.total_sales, a.total_commission ] );
} );
sheet.getRange( 'A1:D1' ).setFontWeight( 'bold' );
}
Add a time-driven trigger for refreshAffiliates and store the key under Project Settings → Script Properties, not in the code.
All three can read a JSON/REST source with a bearer header. Point them at:
/reports/summary?top=50 for the program overview — one row of totals plus a leaderboard, already aggregated and cached for five minutes./affiliates?per_page=100 for a per-affiliate table, walking pages with X-WP-TotalPages./coupons/{id}/orders?from=&to= for transaction-level detail, one coupon at a time./reports/summary is cached for exactly that long, so a faster schedule returns identical data (cached: true) while still spending rate limit.[/tip]
For an incremental ETL, drive it from /events rather than re-reading collections. Store the last event ID alongside your data, pull only what is new, and reconcile occasionally with a full pass over /affiliates. That keeps a nightly job to a handful of requests regardless of program size.
A small receiver that verifies the signature and relays events into Slack. The same shape works for Discord, Teams, Telegram or an SMS gateway — only the last few lines change.
[warning]Your receiver is a public URL that anyone can post to. Nothing proves a payload came from your store except a valid signature, so verify it and reject stale timestamps before doing anything with the contents — and escape those contents like any other external input when you pass them on.[/warning]<?php
// webhook-receiver.php
$secret = getenv( 'WCUSAGE_WEBHOOK_SECRET' );
$body = file_get_contents( 'php://input' );
$header = $_SERVER['HTTP_X_WCUSAGE_SIGNATURE'] ?? '';
$parts = [];
foreach ( explode( ',', $header ) as $pair ) {
[ $k, $v ] = array_pad( explode( '=', $pair, 2 ), 2, '' );
$parts[ trim( $k ) ] = trim( $v );
}
$expected = hash_hmac( 'sha256', ( $parts['t'] ?? '' ) . '.' . $body, $secret );
if ( ! hash_equals( $expected, $parts['v1'] ?? '' ) || abs( time() - (int) ( $parts['t'] ?? 0 ) ) > 300 ) {
http_response_code( 401 );
exit;
}
// Acknowledge immediately - the sender allows 8 seconds and retries on anything else.
http_response_code( 200 );
fastcgi_finish_request();
$payload = json_decode( $body, true );
$data = $payload['data'] ?? [];
switch ( $payload['event'] ) {
case 'payout.requested':
$text = sprintf( ':moneybag: Payout requested: %s asked for %.2f via %s (payout #%d)',
get_display_name( $data['user_id'] ), $data['amount'], $data['method'], $data['payout_id'] );
break;
case 'registration.created':
$text = sprintf( ':wave: New affiliate application for code *%s*', $data['coupon_code'] );
break;
case 'affiliate.payout_details_updated':
$text = sprintf( ':warning: Affiliate %d changed their payout details (%s)',
$data['user_id'], $data['method_type'] ?: 'none set' );
break;
default:
return; // not interested
}
$ch = curl_init( getenv( 'SLACK_WEBHOOK_URL' ) );
curl_setopt_array( $ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [ 'Content-Type: application/json' ],
CURLOPT_POSTFIELDS => json_encode( [ 'text' => $text ] ),
CURLOPT_RETURNTRANSFER => true,
] );
curl_exec( $ch );
Two details worth copying:
fastcgi_finish_request() (or a queue) returns the 2xx immediately, so a slow Slack call never turns into a retry storm.* and filtering in your receiver is easier to maintain than editing the subscription list every time you want another event — as long as your handler ignores what it does not recognise.[/tip]
The API was designed with agents in mind: stable numeric IDs, plain JSON, self-describing errors, a machine-readable spec and a single "who am I" call.
read scope only, and an expiry date.https://example.com/wp-json/wcusage/v2/openapi. Custom GPT Actions, most agent frameworks and any OpenAPI-aware tool builder import it directly, generating one callable function per endpoint.For frameworks without OpenAPI import, hand-define a handful of tools instead — /me, /reports/summary, /affiliates/{id}/stats, /coupons/{id}/orders and /events cover the overwhelming majority of questions.
/me first. It learns whether it is an admin or a single affiliate, and which scopes it holds, instead of guessing and hitting 403s.404 can mean "not yours", so it should not conclude an ID is unused.retry_after on 429 rather than retrying immediately — otherwise a loop burns the whole rate limit./reports/summary for "how are we doing" questions. Left to itself an agent will happily page through every affiliate to compute a total the API already has.An agent acts on the text it reads, and some of that text comes from outside your control — an applicant's "how will you promote us" answer, a campaign name, a website URL an affiliate typed in. An agent with write access can be steered by content like that into doing something you never asked for. Read-only access removes the problem entirely; anything more needs a person in the loop on the actions that matter.
[warning]Never give an agent themanage scope. A key holding it can mint further keys with any scope and point a webhook at any server — that is full account access, in the hands of something driven by text it reads from the outside world.[/warning]
If an agent must act, grant write deliberately and remember what it can then do: approve or decline applications, create payout requests, and change payout statuses. Payout status changes never contact a gateway, but on a store with automatic payouts a created payout request can pay real money immediately. If that is a concern, switch the /payouts endpoints off on the API screen and let the agent read everything else.
Poll the change feed and keep a cursor — one indexed query per poll, no matter how busy the program is:
curl -s "https://example.com/wp-json/wcusage/v2/events?after=9912&event=referral&per_page=100" \
-H "Authorization: Bearer $WCUSAGE_KEY" -D headers.txt
# the new cursor for next time
grep -i '^X-WCUsage-Last-Event' headers.txt
On PRO, subscribe a webhook to referral.created and skip the polling entirely.
# totals for the month
GET /wp-json/wcusage/v2/affiliates/1456/stats?from=2026-07-01&to=2026-07-31
# the orders behind them, one coupon at a time
GET /wp-json/wcusage/v2/coupons/8338/orders?from=2026-07-01&to=2026-07-31&per_page=100
Keep the range fixed between calls so both are served from cache rather than rescanned.
# 1. find pending applications
GET /wp-json/wcusage/v2/registrations?status=pending&per_page=100
# 2. approve the ones that qualify
POST /wp-json/wcusage/v2/registrations/512/status
{ "status": "accepted", "message": "Welcome aboard!", "send_email": true }
Needs an admin key with write. Remember that accepting is one-way through the API, so apply your criteria before calling, not after.
POST /wp-json/wcusage/v2/keys
{ "user_id": 1456, "description": "Sarah's reporting script", "scopes": ["read"], "expires": "2027-01-01" }
The resulting key can call /me, /affiliates/1456/stats, its own coupons, their orders and click stats, and /payouts filtered to itself — and nothing else. Creating it requires manage plus the capability to edit that user.
# everything paid in a period
GET /wp-json/wcusage/v2/payouts?status=paid&from=2026-07-01&to=2026-07-31&per_page=100
Match on transaction_id where your gateway provides one, and on id otherwise. date is when the payout was requested and date_paid when it was settled — use the latter for period allocation.
Subscribe a webhook to affiliate.payout_details_updated and payout.requested. When both arrive for the same user_id within a short window, hold the payout for manual review:
POST /wp-json/wcusage/v2/payouts/321/status
{ "status": "cancel" }
Cancelling returns the amount to the affiliate's unpaid balance, so nothing is lost; re-open it with pending once you are satisfied.
GET /wp-json/wcusage/v2/coupons/8338/stats?refresh=true
Rebuilds the stored snapshot from the order history and saves it, so the affiliate's dashboard and every later API read are current. Needs the write scope, and is limited to one rebuild per coupon per minute — space the calls out, and treat source: "throttled" as "try this one again later".
GET /wp-json/wcusage/v2/me # what am I, and what may I do?
GET /wp-json/wcusage/v2/webhooks # failures, last_error, last_delivery
GET /wp-json/wcusage/v2/keys # last_used, status, date_expires
A webhook with a rising failures count, or a key whose last_used has gone stale, is usually the first sign that something upstream broke.
[get] /wp-json/wcusage/v2/openapi
Returns an OpenAPI 3.1 document generated live from the registered routes — every path, parameter, enum, default and required flag, plus both security schemes (bearer API key and Basic application password). Because it is generated from the actual route definitions, it never drifts from the implementation, and it automatically reflects which endpoints you have switched off and which add-ons are active.
The document is public by default, since the WordPress REST index already enumerates routes. Restrict it to admins with:
add_filter( 'wcusage_api_openapi_public', '__return_false' );
Amend the generated spec — to add descriptions, tags or examples — with the wcusage_api_openapi_spec filter.
{
"openapi": "3.1.0",
"info": {
"title": "Coupon Affiliates REST API",
"description": "Affiliate data for WooCommerce: affiliates, coupons, referred orders, commission, payouts, registrations, clicks, events and reports.",
"version": "8.2.0"
},
"servers": [ { "url": "https://example.com/wp-json/wcusage/v2" } ],
"paths": {
"/coupons/{id}/stats": {
"get": {
"operationId": "get_coupons_id_stats",
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } },
{ "name": "from", "in": "query", "required": false, "schema": { "type": "string" } },
{ "name": "refresh", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
],
"security": [ { "bearerApiKey": [] }, { "basicAppPassword": [] } ],
"responses": { "200": { "description": "Success." }, "401": { "description": "Authentication required or invalid." }, "403": { "description": "Insufficient permissions or scope." } }
}
}
},
"components": { "securitySchemes": { "bearerApiKey": { "type": "http", "scheme": "bearer" }, "basicAppPassword": { "type": "http", "scheme": "basic" } } }
}
Path parameters, query parameters and JSON request bodies are all derived from the routes' own argument definitions, including types, enums, defaults and required flags. Because the document is built per request, it reflects exactly what this install exposes: endpoints switched off in the settings are absent, and so are routes belonging to add-ons that are not active.
Using it is covered under AI Agents & Assistants and No-Code Platforms — most tools that accept an OpenAPI URL will generate a working client from it directly.
Not every endpoint exists on every install. Three things decide it: the plugin edition, which add-ons are active, and which endpoints an administrator has switched on.
| Endpoint | Free | PRO | Notes |
|---|---|---|---|
/me, /openapi | Yes | Yes | |
/affiliates, /coupons, /reports/summary | Yes | Yes | PRO adds mla_parents to the affiliate detail view and the payouts block to the report. |
/registrations | Yes | Yes | 501 when the registrations table is absent. |
/clicks/stats | Yes | Yes | 501 when the clicks table is absent. |
/events | Yes | Yes | 501 when the activity table is absent; 400 when the activity log is switched off. |
/keys | Yes | Yes | |
/webhooks | — | Yes | Not registered at all in the free build. Poll /events instead. |
/payouts | — | Yes | Not registered at all in the free build; 501 when the add-on is inactive. |
MLA-related access (an upline reading a downline's coupon) and the add-on webhook events likewise exist only where those add-ons do. The safest way to discover what a given install offers is to read /openapi and /webhooks/events rather than assuming.
All codes are prefixed wcusage_api_ in the response. Core WordPress codes such as rest_no_route, rest_invalid_param and rest_missing_callback_param can also appear.
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | No authenticated user. Send an application password or an API key. |
forbidden | 403 | Authenticated, but this user may not access the resource. |
invalid_key | 401 | The API key is unknown, revoked or expired. |
invalid_key_user | 401 | The user the key acts as no longer exists. |
https_required | 401 | API keys may only be used over HTTPS. |
too_many_auth_failures | 429 | Too many invalid keys from this address; wait for the window to roll over. |
insufficient_scope | 403 | The key lacks the scope this route needs. |
key_wrong_namespace | 403 | An API key was presented to an endpoint outside the plugin's namespaces. |
rate_limited | 429 | Per-minute request limit exceeded. See retry_after. |
throttled | 429 | An expensive recalculation was requested again too soon. See retry_after. |
not_found | 404 | No such resource — or it belongs to somebody else. |
unavailable | 501 | The feature or table this endpoint reads is not present on this install. |
log_disabled | 400 | The activity log is switched off, so the events feed is empty. |
coupon_required | 400 | Non-admin callers must pass a coupon_id to /clicks/stats. |
no_change | 400 | The payout or registration already has the requested status. |
already_accepted | 400 | The registration was already accepted; reversal is admin-only. |
no_coupon_code | 409 | The registration has no coupon code, so its status cannot change. |
not_updated | 409 | Something on the site refused the registration status change. |
payouts_disabled | 400 | Payout requests are switched off in the plugin settings. |
requests_disabled | 403 | Affiliates cannot self-request payouts on this store. |
no_affiliate | 400 | The coupon has no affiliate assigned. |
no_balance | 400 | There is no unpaid commission to request. |
below_threshold | 400 | The unpaid balance is under the store's minimum payout threshold. |
no_payout_details | 400 | The affiliate has not saved payout details. |
method_disabled | 400 | The saved payout method is no longer enabled on the store. |
invoice_required | 400 | This payout method requires an invoice upload; use the affiliate dashboard. |
in_progress | 409 | A payout request for this coupon is already being processed. |
not_created | 409 | The payout request was refused by a filter or a failed write. |
invalid_transition | 400 | That payout status change is not permitted through the API. |
insufficient_unpaid | 409 | A cancelled payout cannot be re-opened; its amount is no longer in the unpaid balance. |
conflict | 409 | The payout status changed while this request was in flight. |
cannot_create_for_user | 403 | You may not create an API key acting as that user. |
cannot_manage_key | 403 | You may not revoke that user's API key. |
invalid_user | 400 | The user for this API key does not exist. |
invalid_expiry | 400 | The expiry date is not in Y-m-d format. |
invalid_url | 400 | The webhook URL is invalid or points at a disallowed host. |
no_events | 400 | No valid webhook events were supplied. |
no_fields | 400 | A webhook update supplied nothing to change. |
delivery_failed | 502 | A test delivery could not reach the endpoint. |
no_entropy | 500 | The server could not generate a secure token or secret. |
db_error | 500 | The API key could not be saved. |
| Filter | Default | Purpose |
|---|---|---|
wcusage_api_rate_limit | 120 / 30 | Requests per minute. Receives the default and the bucket identity (key_*, user_*, ip_*). |
wcusage_api_max_auth_failures | 20 | Invalid-key attempts allowed per address per 15 minutes. Zero or less disables the check. |
wcusage_api_require_https | true outside local/dev | Whether bearer keys require HTTPS. |
wcusage_api_max_order_rows | 5000 | How many referred orders one /coupons/{id}/orders request may consider. |
wcusage_api_report_batch_size | 200 | How many coupons /reports/summary primes meta for at a time. Lower it on very large programs with tight memory. |
wcusage_api_openapi_public | true | Whether /openapi is publicly readable. |
wcusage_api_webhook_require_https | true outside local/dev | Whether webhook URLs must be HTTPS. |
wcusage_api_webhook_max_attempts | 5 | Delivery attempts per event. |
wcusage_api_webhook_max_failures | 25 | Consecutive failures before an endpoint is auto-disabled. |
wcusage_api_webhook_events | — | Extend or modify the webhook event catalog. |
wcusage_api_auth_failure_buckets, wcusage_api_ip_buckets | 256 | How many buckets failed attempts and anonymous callers are spread across. |
Enabling the API, and enabling individual endpoints, is done from Coupon Affiliates → Admin Tools → API rather than by a filter. Those settings live in the wcusage_api_settings option.
| Filter | Applies to |
|---|---|
wcusage_api_coupon_summary | Every coupon object the API returns. |
wcusage_api_prepare_affiliate | Affiliate objects. Receives the detailed flag. |
wcusage_api_prepare_payout | Payout objects. |
wcusage_api_prepare_registration | Registration objects. |
wcusage_api_order_item | Referred-order rows — the place to add extra fields for a trusted integration. |
wcusage_api_report_summary | The /reports/summary payload. |
wcusage_api_openapi_spec | The generated OpenAPI document. |
wcusage_api_webhook_payload | Webhook payloads, just before delivery. |
// Add the affiliate's country to every coupon the API returns.
add_filter( 'wcusage_api_coupon_summary', function ( $data, $coupon_id ) {
$user_id = (int) $data['user_id'];
$data['country'] = $user_id ? get_user_meta( $user_id, 'billing_country', true ) : '';
return $data;
}, 10, 2 );
[warning]These filters run for every caller of the affected endpoint, not only for your own integration. Several of them cover endpoints an affiliate or a multi-level upline can reach, so anything you add there is disclosed to them too. Check who can call the endpoint before adding customer data, payout details or another affiliate's information — the omissions in the default responses are deliberate.[/warning]
| Action | Fires |
|---|---|
wcusage_activity_recorded | For every lifecycle event, with ( $event, $event_id, $info, $actor_id ). This is the same funnel the webhook dispatcher listens to, and it fires whether or not database logging is enabled — hook it to build a custom integration with no polling and no HTTP. |
add_action( 'wcusage_activity_recorded', function ( $event, $event_id, $info, $actor_id ) {
if ( 'payout_request' === $event ) {
// $event_id is the payout ID.
}
}, 10, 4 );
The original three endpoints under woo-coupon-usage/v1 remain available for backwards compatibility. They predate scopes, pagination and schemas, and they require a full administrator account — not merely the plugin's configurable admin capability.
[get] /wp-json/woo-coupon-usage/v1/coupon-info?coupon_id={id}
{
"coupon_name": "sarah10",
"unpaid_commission": 40.46,
"pending_payouts": 0,
"coupon_user_id": 1456,
"referral_url": "https://example.com/affiliate-dashboard/?couponid=sarah10"
}
[get] /wp-json/woo-coupon-usage/v1/users-coupons?user={login} — an array of coupon IDs assigned to that login. An unknown login returns an empty array.
[post] /wp-json/woo-coupon-usage/v1/request-payout — body coupon_id and user (login). Returns 1 when a payout request was submitted and 0 otherwise, with no explanation of why. PRO only.
read, writes need write.wcusage/v2. It adds affiliate self-service access, API keys and scopes, pagination and filtering, meaningful error codes, webhooks and an OpenAPI document. The v1 routes exist for existing integrations only and will not gain features.[/note]