Getting Started

Introduction

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

What you can do with it

Who can use it

Requirements

[warning]Before connecting anything to a live store, read Security & Disclaimers. This API returns personal data about real people and can perform actions that cannot be undone through the API — and on some configurations, actions that move real money.[/warning]

Enabling the API

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.

  1. Go to Coupon Affiliates → Admin Tools → API in wp-admin.
  2. Press Enable API at the top of the page.
  3. Create an API key, or use a WordPress application password.

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.

Turning individual endpoints on and off

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.

What the master switch controls

BehaviourAPI onAPI off
wcusage/v2 routesRegisteredNot registered (404)
API key authenticationWorksRefused everywhere, v1 included
Webhook deliveries (PRO)Queued and sentNot queued; already-queued retries are dropped
Adding and testing webhooks in wp-admin (PRO)WorksWorks
Legacy woo-coupon-usage/v1 routesAvailableStill available (application password or cookie auth only)
[warning]Disabling the API stops outbound webhook deliveries as well as inbound requests. If a system depends on webhooks, switching the API off silently stops feeding it.[/warning]

Authentication

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.

Application passwords

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.

API keys

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:

Scopes

ScopeGrants
readEvery GET endpoint: affiliates, coupons, stats, referred orders, payouts, registrations, clicks, events, reports.
writeCreating and changing data: payout requests, payout status changes, registration approval and decline, and refreshing stored coupon stats.
manageManaging the API itself: creating and revoking API keys, and — on PRO — creating, editing, testing and deleting webhooks.
[warning]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.

[tip]When connecting an AI agent or a third-party SaaS, create a dedicated read-only key on a dedicated account. It cannot change anything on your site whatever the client does, and you can revoke it without disturbing anything else.[/tip]

Cookie authentication

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() );

HTTPS

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.

Failed-attempt lockout

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.

Who am I?

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.

Requests & Responses

Conventions

Pagination

Every collection endpoint takes the same two parameters:

ParamTypeDescription
pageintegerPage number, starting at 1. Default 1.
per_pageintegerItems per page, 1–100. Default 20.

And returns the same two headers:

A page past the end of the result set returns an empty array with correct headers, without running the underlying query.

Dates

Errors

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.

StatusMeaning
400Invalid parameter, or the action is not possible right now (no unpaid balance, status already set, activity log disabled).
401Not authenticated, or the API key is invalid, revoked, expired, or was sent over plain HTTP.
403Authenticated but not permitted — wrong capability, missing scope, or a key used outside the plugin's namespaces.
404Not found. Also returned for a resource that exists but belongs to somebody else, and for an endpoint switched off in the settings.
409Conflict — another request is mid-flight, or the resource changed underneath this one.
429Rate limited, throttled, or locked out after repeated authentication failures.
500Server error, e.g. the site could not generate a secure token.
501The feature this endpoint reads is not present on this install (payouts add-on inactive, table missing).
[note]A resource owned by somebody else answers 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.

Rate limiting

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.

Performance & Caching

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.

What is cached

EndpointBehaviour
/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}/ordersThe 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/summaryCached 5 minutes per top value. refresh=true bypasses it.
Everything elseAnswered live from indexed queries.

Throttle responses

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.

[tip]Poll a fixed date range rather than a moving one. A client that keeps asking for yesterday is always served from cache; a client that shifts the range slightly on every call forces a fresh scan each minute and will start seeing 429s.[/tip]

Working efficiently

Security & Disclaimers

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.

Your responsibilities

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:

[warning]Test on staging first. Do not point a new integration at a live store until you have watched it run end to end somewhere disposable. Several endpoints write data that cannot be undone through the API — accepting a registration creates a coupon, and on a store with automatic payouts a payout request may pay real money immediately.[/warning]

Personal data

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.

[note]This page is general guidance about how the software behaves, not legal advice. If you are unsure whether a particular integration is lawful in your jurisdiction, take proper advice before building it.[/note]

Money and financial records

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.

Treat inbound webhooks as untrusted

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.

Third-party and AI services

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.

Stability and support

No warranty

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.

Licensing

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.

[tip]Two habits prevent most serious incidents: give every integration its own key with the narrowest scope that works, and keep a current backup before running anything that writes in bulk.[/tip]

Endpoint Index

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 & pathAccessScopePurpose
[get] /meAny logged-in userIdentify the caller, its access level and scopes.
[get] /affiliatesAdminreadList affiliates with coupons and balances.
[get] /affiliates/{id}Admin or selfreadOne affiliate, with profile fields and groups.
[get] /affiliates/{id}/statsAdmin or selfreadTotals across all of an affiliate's coupons.
[get] /couponsAdminreadList affiliate coupons.
[get] /coupons/{id}Admin, owner, uplinereadOne coupon, with commission rates and referral URL.
[get] /coupons/{id}/statsAdmin, owner, uplineread (+write to refresh)Sales and commission, all-time or by date range.
[get] /coupons/{id}/ordersAdmin, owner, uplinereadOrders referred by a coupon, with commission per order.
[get] /payoutsAny logged-in userreadList payouts. Non-admins see only their own.
[post] /payoutsAdmin or coupon ownerwriteRequest a payout for a coupon's unpaid balance.
[get] /payouts/{id}Admin or ownerreadOne payout.
[post] /payouts/{id}/statusAdminwriteChange a payout status. Bookkeeping only.
[get] /registrationsAdminreadList affiliate applications.
[get] /registrations/{id}AdminreadOne application.
[post] /registrations/{id}/statusAdminwriteApprove or decline an application.
[get] /clicks/statsAdmin, or owner with coupon_idreadClicks, conversions and conversion rate.
[get] /eventsAdminreadChange feed with a cursor for polling.
[get] /reports/summaryAdminreadStore-wide totals and top affiliates.
[get] /keysAdminmanageList API keys.
[post] /keysAdminmanageCreate an API key.
[delete] /keys/{id}AdminmanageRevoke an API key.
[get] /webhooksAdminmanagePRO. List webhook endpoints.
[post] /webhooksAdminmanagePRO. Create a webhook endpoint.
[patch] /webhooks/{id}AdminmanagePRO. Change status or subscribed events.
[delete] /webhooks/{id}AdminmanagePRO. Delete a webhook endpoint.
[post] /webhooks/{id}/testAdminmanagePRO. Send a test delivery.
[get] /webhooks/eventsAdminreadPRO. The catalog of subscribable events.
[get] /openapiPublic by defaultMachine-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.

Endpoints

Me

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"
}
FieldTypeDescription
userobjectid and display_name always; login and email only when the caller is an admin or is that user.
is_adminbooleanWhether the caller passes the plugin's admin access check.
is_affiliatebooleanWhether the user has at least one affiliate coupon.
auth.methodstringapi_key or wordpress.
auth.key_idintegerThe API key's ID, or null.
auth.scopesarrayScopes in force, or null for capability-based auth (full access for that user).
couponsarrayThe caller's own affiliate coupons with balances. Empty for non-affiliates.
api_versionstringThe installed Coupon Affiliates version.
[note]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]

Affiliates

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.

List affiliates

[get] /wp-json/wcusage/v2/affiliates

Permission: admin, read scope.

ParamTypeDescription
searchstringPartial match against user login, email or display name.
page / per_pageintegerStandard 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 one affiliate

[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 fieldTypeDescription
date_registeredstringWhen the WordPress account was created.
profileobjectRegistration profile fields: phone, website, promote, referrer.
groupsarrayAffiliate group roles the user holds.
mla_parentsobjectPRO only. Multi-level upline chain, keyed by tier. Absent entirely in the free build.

Affiliate stats

[get] /wp-json/wcusage/v2/affiliates/{user_id}/stats

Permission: admin, or the affiliate themselves. read scope.

ParamTypeDescription
fromdateOptional start date (Y-m-d). When set, figures are recalculated from the orders for the range instead of read from the all-time cache.
todateOptional 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.

[note]A ranged request recalculates every one of the affiliate's coupons from the order history, so one call costs as much as their whole trading history. Results are cached for 60 seconds per range, and an uncached range is limited to one per affiliate per minute — over that, the response is 429 wcusage_api_throttled.[/note]

Coupons

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.

List coupons

[get] /wp-json/wcusage/v2/coupons

Permission: admin, read scope.

ParamTypeDescription
user_idintegerOnly coupons assigned to this affiliate. Omit for every assigned affiliate coupon.
searchstringMatch against the coupon code.
page / per_pageintegerStandard pagination.

Returns published coupons that have an affiliate assigned, newest first. Coupons with no assigned affiliate are never listed.

Get one coupon

[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"
}
FieldDescription
unpaid_commissionCommission earned, cleared, and not yet paid out or requested.
pending_order_commissionCommission on orders still inside the pending period (not yet payable).
pending_payout_commissionCommission tied up in payouts that have been requested but not paid.
commission.percentThe percentage rate resolved for this coupon: its own override if it has one, otherwise the store's default rate.
commission.percent_overrideThe per-coupon override itself. Empty when the coupon inherits the store default.
commission.fixed_per_orderFixed amount per referred order, if configured. Empty when unused.
commission.fixed_per_productFixed amount per product, if configured. Empty when unused.
referral_urlThe affiliate's referral URL for this coupon, pointing at the affiliate dashboard page.
stats.last_refreshedWhen 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.

[note]Always reference coupons by ID. WooCommerce coupon codes are case-insensitive and not guaranteed unique, and the stats layer is keyed by code — see code_ambiguous below.[/note]

Coupon stats

[get] /wp-json/wcusage/v2/coupons/{id}/stats

Permission: admin, owner, or MLA upline. read scope; refresh=true additionally needs write.

ParamTypeDescription
from / todateOptional range. Without dates, the stored all-time snapshot is returned (fast). With dates, the range is calculated from the orders.
refreshbooleanRecalculate 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
}
FieldDescription
sourcecache — 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_ambiguoustrue 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_countsOrder 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_refreshednull 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.

[warning]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]

Referred orders

[get] /wp-json/wcusage/v2/coupons/{id}/orders

Permission: admin, owner, or MLA upline. read scope.

ParamTypeDescription
from / todateOptional date range.
statusstringOrder status slug without the wc- prefix, e.g. completed.
page / per_pageintegerStandard 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.

[note]Order rows deliberately carry no customer data — only IDs, totals and commission. A trusted server-side integration can add fields with the wcusage_api_order_item filter.[/note]

Payouts

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.

[warning]Changing a payout's status is bookkeeping only — no payment gateway is ever contacted. Creating a payout is different: if your store has automatic payouts enabled, the normal submit flow may pay it immediately through PayPal, Stripe or Wise, exactly as a dashboard request would. The response flags this with 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.

List payouts

[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.

ParamTypeDescription
user_idintegerFilter by affiliate. Admin only — overridden for everyone else.
coupon_idintegerFilter by coupon.
statusstringOne of pending, created, paid, cancel.
from / todateFilter by request date.
page / per_pageintegerStandard 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 one payout

[get] /wp-json/wcusage/v2/payouts/{id}

Permission: admin or the payout's owner. read scope. Somebody else's payout answers 404.

Request a payout

[post] /wp-json/wcusage/v2/payouts

Permission: admin (any coupon), or the coupon's affiliate (their own). write scope.

Body paramTypeDescription
coupon_idintegerRequired. 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:

Idempotency

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.

Rules that are enforced

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 codeCondition
payouts_disabledPayouts are switched off in the plugin settings.
requests_disabledAffiliate self-service requests are switched off; only the store owner creates payouts. Not applied to admin callers.
no_affiliateThe coupon has no affiliate assigned.
no_balanceThe unpaid balance is zero or less.
below_thresholdThe unpaid balance is under the store's minimum payout threshold. Enforced for admins too.
no_payout_detailsThe affiliate has not saved payout details and the store requires them.
method_disabledThe affiliate's saved payout method is no longer enabled on the store. Not applied to admin callers.
invoice_requiredThe 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_createdEverything checked out but the site refused the request — typically a custom wcusage_before_payout_submit filter, or a failed write.

Update payout status

[post] /wp-json/wcusage/v2/payouts/{id}/status

Permission: admin, write scope.

Body paramTypeDescription
statusstringRequired. 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.

Permitted transitions

FromTo
pendingpaid, cancel, created
createdpaid, cancel, pending
paidcancel, pending, created
cancelpending, 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.

[note]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:

Registrations

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.

List registrations

[get] /wp-json/wcusage/v2/registrations

Permission: admin, read scope.

ParamTypeDescription
statusstringpending, accepted or declined.
user_idintegerFilter by WordPress user.
page / per_pageintegerStandard 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 one registration

[get] /wp-json/wcusage/v2/registrations/{id}

Permission: admin, read scope.

Approve or decline

[post] /wp-json/wcusage/v2/registrations/{id}/status

Permission: admin, write scope.

Body paramTypeDescription
statusstringRequired. accepted or declined.
messagestringOptional message included in the notification email.
send_emailbooleanWhether 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 codeCondition
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.

Clicks

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.

ParamTypeDescription
coupon_idintegerLimit to one coupon. Required for non-admin users — omitting it returns 400 wcusage_api_coupon_required.
campaignstringFilter by campaign name (exact match).
from / todateOptional 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.

[note]The IP addresses stored with clicks are never exposed through the API. If the clicks table does not exist on the install, the endpoint answers 501 wcusage_api_unavailable.[/note]

Events

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.

ParamTypeDescription
afterintegerCursor. Returns only events with a higher ID, oldest first. Without it, newest first.
eventstringFilter by event type.
user_idintegerFilter by the acting user.
page / per_pageintegerStandard 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 types

Eventevent_id refers to
referralOrder
commission_added, commission_removedCoupon
mla_commission_added, mla_commission_removedCoupon (PRO)
registration, registration_acceptRegistration
payout_request, payout_paid, payout_reversed, payout_cancelledPayout (PRO)
reward_earnedReward (PRO)
new_campaignCampaign (PRO)
direct_link_domainDirect link (PRO)
mla_inviteInvite (PRO)
lifetime_link_editedCustomer (PRO)
api_key_created, api_key_revokedAPI 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.

[note]Declined registrations are not written to the activity log, so they never appear in this feed. Subscribe to the 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]

Reports

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.

ParamTypeDescription
topintegerHow many top affiliates to include, by all-time commission. 0–50, default 10.
refreshbooleanBypass 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.

[note]Totals are summed from the same stored per-coupon snapshots the affiliate dashboards show, so they move when those snapshots are rebuilt rather than order by order. Balances (unpaid_commission, pending_payout_commission) are always current.[/note]

API Key Management

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.

List keys

[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.

Create a key

[post] /wp-json/wcusage/v2/keys

Body paramTypeDescription
user_idintegerThe 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.
descriptionstringLabel, e.g. "Zapier integration". Up to 200 characters.
scopesarrayAny of read, write, manage. Default ["read"].
expiresdateOptional 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.

Revoke a key

[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.

[note]Permanent deletion of a key row is available on the admin screen only. The API offers revocation, which keeps the audit trail intact.[/note]

Webhooks

Webhooks Overview

[note]Webhooks are a PRO feature. In the free build the delivery layer is not shipped at all: the /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.

Available events

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:

EventFires when
referral.createdA referred order is attributed to an affiliate.
registration.createdA new affiliate registration is submitted.
registration.acceptedA registration is approved.
registration.declinedA registration is declined.
commission.addedCommission is credited to an affiliate.
commission.removedCommission is removed (refund, cancellation, manual deduction).
affiliate.createdAn 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:

EventFires whenAdd-on
payout.requestedAn affiliate requests a payout.Payouts
payout.paidA payout is marked as paid.Payouts
payout.reversedA payout is reversed.Payouts
payout.cancelledA payout is cancelled and its amount returned to the unpaid balance.Payouts
affiliate.payout_details_updatedAn affiliate changes their payout method or details.Payouts
reward.earnedAn affiliate earns a reward or bonus.Rewards
campaign.createdAn affiliate creates a campaign.Campaigns
directlink.createdAn affiliate registers a direct-link domain.Direct Link
commission.mla_addedMulti-level commission is credited to an upline.Multi-Level
commission.mla_removedMulti-level commission is removed from an upline.Multi-Level
mla.invite_createdA multi-level affiliate invite is created.Multi-Level
mla.sub_registeredSomeone 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.

[tip]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]

Endpoint requirements

[note]Webhook events fire from the plugin's activity funnel before the activity-log setting is consulted, so they keep working even on stores that have database logging switched off. They do stop when the API master switch is off.[/note]

Deliveries & Security

Payload format

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 fieldDescription
eventThe webhook event name, e.g. payout.paid.
createdWhen the delivery was built, RFC 3339 in UTC.
siteThe sending site's home URL — useful when one receiver serves several stores.
dataEvent-specific payload; shapes below.

Payload shapes by event

Event(s)data fields
referral.createdorder_id, coupon, user_id, commission
commission.added, commission.removed, commission.mla_added, commission.mla_removedcoupon_id, coupon, user_id, note, order_id (parsed from the note; null when there is none)
payout.requested, payout.paid, payout.reversed, payout.cancelledpayout_id, user_id, coupon_id, amount, method, method_type, status, date, date_paid
registration.created, registration.accepted, registration.declinedregistration_id, user_id, coupon_code, status, type, date
affiliate.createduser_id, coupon, coupon_id, coupon_ids (every coupon they now hold)
affiliate.payout_details_updateduser_id, method_type, has_details
directlink.createddirectlink_id, coupon_id, coupon, user_id, website, campaign, status
mla.invite_createdinvite_id, user_id, status, date
mla.sub_registereduser_id, parent_user_id, coupon
reward.earned, campaign.createdobject_id, info
ping (test delivery)message
[note]Payloads never carry customer personal data, payout destination details, or the email address of a person who is not a user of the site. 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]

Verifying signatures

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]

Retries and automatic disabling

Tune the two limits with the wcusage_api_webhook_max_attempts and wcusage_api_webhook_max_failures filters.

Webhook Management API

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.

List webhooks

[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.

Create a webhook

[post] /wp-json/wcusage/v2/webhooks

Body paramTypeDescription
namestringLabel for the webhook. Up to 100 characters.
urlstringRequired. HTTPS delivery URL. Validated against loopback and private addresses.
eventsarrayRequired. 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.

Update a webhook

[patch] /wp-json/wcusage/v2/webhooks/{id}

Body paramTypeDescription
statusstringactive or disabled. Re-activating resets the failure counter and clears the last error.
eventsarrayReplaces the subscribed events entirely.

Sending neither returns 400 wcusage_api_no_fields.

[note]The delivery URL cannot be changed after creation, by design — it is validated against SSRF only on the way in. To point a webhook somewhere else, delete it and create a new one. The signing secret is likewise fixed.[/note]

Test a webhook

[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 a webhook

[delete] /wp-json/wcusage/v2/webhooks/{id}

{ "deleted": true }

Event catalog

[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.

Integrations

Code Samples

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]

PHP (inside WordPress)

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'] );

PHP (standalone, with pagination)

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'] );

JavaScript / Node

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

Python

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")

Command line

# 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]

No-Code Platforms

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.

Push: webhook trigger (recommended, PRO)

  1. In your automation tool, create a scenario starting with a Webhook / Catch Hook trigger and copy the URL it gives you.
  2. In WordPress, go to Coupon Affiliates → Admin Tools → API → Webhooks and add an endpoint with that URL, subscribed to the events you care about.
  3. Press Test. Your tool receives a ping payload and learns the structure.
  4. Map the fields from 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 example GET /payouts/{id}) before acting on it.[/warning]

Pull: scheduled polling

If you are on the free version, or your tool cannot receive webhooks, poll the change feed on a schedule instead:

  1. Add a Scheduled trigger (every 5–15 minutes is plenty).
  2. Add an HTTP GET module pointed at https://example.com/wp-json/wcusage/v2/events with the query after={{ last_id }}&per_page=100, and a header Authorization: Bearer wcus_….
  3. Store the response header X-WCUsage-Last-Event in a data store / variable, and feed it back as last_id next run.
  4. Iterate the returned array and branch on 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.

Writing back

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.

Reporting & Dashboards

Google Sheets

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.

Looker Studio, Power BI, Metabase

All three can read a JSON/REST source with a bearer header. Point them at:

[tip]Refresh no more often than every five minutes. /reports/summary is cached for exactly that long, so a faster schedule returns identical data (cached: true) while still spending rate limit.[/tip]

Warehousing the data

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.

Notifications

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:

[tip]Subscribing to * 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]

AI Agents & Assistants

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.

Setting one up

  1. Create a WordPress user for the agent, at the access level it genuinely needs — an affiliate account for an affiliate-facing assistant, an admin account only for a program-management assistant.
  2. Create an API key against that user with the read scope only, and an expiry date.
  3. Give the tool the OpenAPI URL: 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.
  4. Configure bearer authentication with the key.

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.

Prompting notes

Safety

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 the manage 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.

[tip]An affiliate-facing assistant is the safest deployment by construction: give it a read-only key on the affiliate's own account, and the API itself guarantees it can never see anybody else's data, however it is prompted.[/tip]

Recipes

Sync new referrals into another system

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.

Build a monthly affiliate statement

# 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.

Auto-approve applications that meet your criteria

# 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.

Give an affiliate read-only API access to their own data

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.

Reconcile payouts with your accounting system

# 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.

Flag a payout-fraud pattern

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.

Refresh stale coupon statistics nightly

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".

Check the health of your integration

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.

Developer Reference

OpenAPI Document

Fetching the spec

[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.

What it contains

{
  "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.

Availability

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.

EndpointFreePRONotes
/me, /openapiYesYes
/affiliates, /coupons, /reports/summaryYesYesPRO adds mla_parents to the affiliate detail view and the payouts block to the report.
/registrationsYesYes501 when the registrations table is absent.
/clicks/statsYesYes501 when the clicks table is absent.
/eventsYesYes501 when the activity table is absent; 400 when the activity log is switched off.
/keysYesYes
/webhooksYesNot registered at all in the free build. Poll /events instead.
/payoutsYesNot 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.

Error Reference

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.

CodeStatusMeaning
unauthorized401No authenticated user. Send an application password or an API key.
forbidden403Authenticated, but this user may not access the resource.
invalid_key401The API key is unknown, revoked or expired.
invalid_key_user401The user the key acts as no longer exists.
https_required401API keys may only be used over HTTPS.
too_many_auth_failures429Too many invalid keys from this address; wait for the window to roll over.
insufficient_scope403The key lacks the scope this route needs.
key_wrong_namespace403An API key was presented to an endpoint outside the plugin's namespaces.
rate_limited429Per-minute request limit exceeded. See retry_after.
throttled429An expensive recalculation was requested again too soon. See retry_after.
not_found404No such resource — or it belongs to somebody else.
unavailable501The feature or table this endpoint reads is not present on this install.
log_disabled400The activity log is switched off, so the events feed is empty.
coupon_required400Non-admin callers must pass a coupon_id to /clicks/stats.
no_change400The payout or registration already has the requested status.
already_accepted400The registration was already accepted; reversal is admin-only.
no_coupon_code409The registration has no coupon code, so its status cannot change.
not_updated409Something on the site refused the registration status change.
payouts_disabled400Payout requests are switched off in the plugin settings.
requests_disabled403Affiliates cannot self-request payouts on this store.
no_affiliate400The coupon has no affiliate assigned.
no_balance400There is no unpaid commission to request.
below_threshold400The unpaid balance is under the store's minimum payout threshold.
no_payout_details400The affiliate has not saved payout details.
method_disabled400The saved payout method is no longer enabled on the store.
invoice_required400This payout method requires an invoice upload; use the affiliate dashboard.
in_progress409A payout request for this coupon is already being processed.
not_created409The payout request was refused by a filter or a failed write.
invalid_transition400That payout status change is not permitted through the API.
insufficient_unpaid409A cancelled payout cannot be re-opened; its amount is no longer in the unpaid balance.
conflict409The payout status changed while this request was in flight.
cannot_create_for_user403You may not create an API key acting as that user.
cannot_manage_key403You may not revoke that user's API key.
invalid_user400The user for this API key does not exist.
invalid_expiry400The expiry date is not in Y-m-d format.
invalid_url400The webhook URL is invalid or points at a disallowed host.
no_events400No valid webhook events were supplied.
no_fields400A webhook update supplied nothing to change.
delivery_failed502A test delivery could not reach the endpoint.
no_entropy500The server could not generate a secure token or secret.
db_error500The API key could not be saved.

Hooks & Filters

Behaviour filters

FilterDefaultPurpose
wcusage_api_rate_limit120 / 30Requests per minute. Receives the default and the bucket identity (key_*, user_*, ip_*).
wcusage_api_max_auth_failures20Invalid-key attempts allowed per address per 15 minutes. Zero or less disables the check.
wcusage_api_require_httpstrue outside local/devWhether bearer keys require HTTPS.
wcusage_api_max_order_rows5000How many referred orders one /coupons/{id}/orders request may consider.
wcusage_api_report_batch_size200How many coupons /reports/summary primes meta for at a time. Lower it on very large programs with tight memory.
wcusage_api_openapi_publictrueWhether /openapi is publicly readable.
wcusage_api_webhook_require_httpstrue outside local/devWhether webhook URLs must be HTTPS.
wcusage_api_webhook_max_attempts5Delivery attempts per event.
wcusage_api_webhook_max_failures25Consecutive failures before an endpoint is auto-disabled.
wcusage_api_webhook_eventsExtend or modify the webhook event catalog.
wcusage_api_auth_failure_buckets, wcusage_api_ip_buckets256How 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.

Response filters

FilterApplies to
wcusage_api_coupon_summaryEvery coupon object the API returns.
wcusage_api_prepare_affiliateAffiliate objects. Receives the detailed flag.
wcusage_api_prepare_payoutPayout objects.
wcusage_api_prepare_registrationRegistration objects.
wcusage_api_order_itemReferred-order rows — the place to add extra fields for a trusted integration.
wcusage_api_report_summaryThe /reports/summary payload.
wcusage_api_openapi_specThe generated OpenAPI document.
wcusage_api_webhook_payloadWebhook 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]

Actions

ActionFires
wcusage_activity_recordedFor 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 );

Legacy v1 API

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.

How v1 relates to v2

[note]New integrations should use 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]