# The update API for your plugin

> The four endpoints your plugin calls to activate, validate, update and deactivate, and the drop-in updater that calls them for you.

The plugins you sell call a small REST API on your store, under `wp-json/elm/v2/`.

## The drop-in updater

The quickest way is the ready-made updater: **WooCommerce › Licenses › Settings › License API › Download updater** gives you `class-elm-plugin-updater.php`, one file with no dependencies (PHP 7.2+). Rename the class, create it with your store's API address, your plugin's main file and its version, and call `activate( $key )` and `deactivate()` from your settings screen. It hooks WordPress's update checks and "View details", caches the answer for 12 hours, an hour when the store refused, or ten minutes when the store didn't answer, and asks again before downloading, because download links last an hour. It also asks again when the site's owner chooses **Check again** under Dashboard › Updates, and it names your plugin when it activates and checks for updates (`plugin`, below). Instructions are in its header, whose example already has your store's API address.

![Settings › License API: the endpoint address with a Copy button, Download updater, requests per minute and trusted proxies.](../../../../assets/docs/elm-license-api.png)

**1** Your store's API address. **2** Download updater.

## Endpoints

Send a per-site `instance_id` on every call: generate a UUID once per site and store it in an option. It's what makes the site limit hold.

| Method | Endpoint | Send | Answer |
|---|---|---|---|
| POST | `/activate` | `key`, `instance_id`, `domain`; optional `plugin` | Takes a seat for this site. Activating again is fine. |
| POST | `/validate` | `key`, `instance_id` (or `domain`) | `valid` is true only while the license works and this site holds a seat. |
| POST | `/update` | `key`, `current_version`, `instance_id` (or `domain`); optional `wp_version`, `php_version`, `channel` (`stable` or `beta`), `plugin` | `update_available`, `version`, `changelog`, `support_active`, a `download_url` valid for one hour, `renew_url`, `icons`, and the fields WordPress's "View details" shows. |
| POST | `/deactivate` | `key`, `instance_id` | Frees this site's seat. |

`/update` gives a download link only to a license that works, whose updates period hasn't ended, from a site that holds a seat. When the updates period has ended it still reports the new version, with `download_url: null`, so the site can ask its owner to renew. A site that sends neither `instance_id` nor `domain` also gets the version with `download_url: null`, and a message to activate the license first.

**Send `plugin`**, your plugin's main file name such as `my-plugin.php`. The store reads which plugin files the product's zip holds, and refuses a key that was sold for another product (`wrong_product`) before any seat is taken. Without it, a customer who owns two of your plugins could paste one's key into the other and be offered the wrong update. The drop-in updater sends it for you, and stops an update whose package doesn't hold your plugin's main file.

**`tested`** answers the site's own WordPress version when it's in the branch you entered, so "Tested up to 7.1" reads as compatible on 7.1.3. **`icons`** is the product's image, shown next to the update on Dashboard › Updates. If your CDN or host blocks images requested from other sites (hotlink protection), allow your product images, or leave the icon out with `add_filter( 'elm_update_response', fn ( $data ) => [ 'icons' => [] ] + $data );`.

```bash
curl -X POST https://yourstore.com/wp-json/elm/v2/activate \
  -H "Content-Type: application/json" \
  -d '{ "key": "XXXX-XXXX-XXXX-XXXX", "domain": "customer-site.com", "instance_id": "8f14e45f-…" }'
```

## Errors

Failures answer `{ "code", "message", "status" }`. Branch on `code`: it never changes, while `message` is in the store's language.

| `code` | HTTP | Meaning |
|---|---|---|
| `invalid_license` | 404 | No license has this key. |
| `invalid_request` | 400 | Empty domain or instance ID. |
| `license_inactive` | 403 | Refunded, revoked, or otherwise not usable. |
| `license_expired` | 403 | Past its expiry date; renewing fixes it. |
| `wrong_product` | 403 | The key was sold for another product than the plugin asking (`plugin`). The message names the key's product. No seat is taken. |
| `activation_limit_reached` | 403 | Every seat is taken; `data.limit` and `data.active` say how many. |
| `not_activated` | 403 | The site named itself but holds no seat. `/validate` answers this with 200 and `valid: false` instead. |
| `busy` | 409 | Another activation of the same license is running; retry in a moment. |
| `activation_failed` | 500 | The seat couldn't be saved; retry. |
| `not_found` | 404 | No such license or activation (`/deactivate`). |
| `rate_limit_exceeded` | 429 | Too many requests; wait `Retry-After` seconds. |

The rate limit is per license key, and per IP address only for keys that don't exist, so an agency's sites behind one address don't share one allowance. If your store sits behind a proxy, list it in Settings so `X-Forwarded-For` is trusted.

## Hooks

Actions fire once, when a change is saved, with the license row (never the plain key): `elm_license_issued`, `elm_license_activated`, `elm_license_deactivated`, `elm_license_revoked`, `elm_license_restored`, `elm_license_edited`, `elm_license_renewed` and `elm_license_expired`. `elm_licenses_refunded` fires with the order ID and the revoked licenses' IDs, and `elm_license_renewal_refunded` when a refunded renewal takes a year back.

Filters: `elm_validate_response`, `elm_update_response`, `elm_site_is_free`, `elm_license_auto_renewal`, `elm_license_key_format` (the format of generated keys), `elm_customer_licenses` (a customer's licenses, for your own account pages), and `elm_write_htaccess_rule`, `elm_htaccess_file` and `elm_htaccess_folder_file` (the download-protection rule).

Checked against ZealDen Sell Your Plugins 1.1.4 on main, 19 October 2026.
