# Events, webhooks and AutomatorWP

Link Manager Pro fires four events. Each one runs an action hook, can be sent to your webhook endpoints, and can start an AutomatorWP automation.
Page: https://docs.linkmanagerpro.com/developers/hooks/events
Use when someone asks: link manager pro webhook signature; verify link manager pro webhook; x-lmp-signature hmac; link manager pro automatorwp triggers
Intent: How-to

Link Manager Pro fires four events: `link_clicked`, `broken_link_found`, `link_created` and `broken_link_fixed`. You can react to them with an action hook, a signed webhook request, or an AutomatorWP trigger.

To set up an endpoint on screen, see [Webhooks and AutomatorWP](https://docs.linkmanagerpro.com/guides/webhooks-automation).

## The four events

Each event runs a WordPress action named `link_manager_pro_` plus the event name, with one flat array of values. The array also holds an `event` key with the event name, set by the plugin. Listener errors never break the page that fired the event, and a listener that fires the same event again is skipped.

| Event (action hook) | When it fires | Payload keys |
|---|---|---|
| `link_clicked` (`link_manager_pro_link_clicked`) | A short link redirect is served to a visitor who is counted. Rate-limited and filtered visits, such as bots, do not fire it. | `link_id` (int), `slug`, `target_url`, `click_count` (int), `country_code` (empty string when unknown), `referrer_domain` (empty string when none) |
| `broken_link_found` (`link_manager_pro_broken_link_found`) | A broken outbound link in your content is detected for the first time. | `url`, `source_post_id` (int), `status_code` (int), `health_type` (`content`), `detected_at` (UTC, `Y-m-d H:i:s`) |
| `link_created` (`link_manager_pro_link_created`) | A short link is created. | `link_id` (int), `slug`, `target_url`, `redirect_type` (int: `301`, `302` or `307`), `created_by` (user ID, `0` when there is no user) |
| `broken_link_fixed` (`link_manager_pro_broken_link_fixed`) | A broken link is marked fixed, or a redirect is created for it. | `url`, `source_post_id` (int), `health_type`, `resolution` (`marked_fixed` or `redirect_created`) |

The hook signatures are on [Action hooks](https://docs.linkmanagerpro.com/developers/hooks/actions). The source is `app/Support/Events.php`.

## Webhook requests

Each endpoint you add under Link Manager › Settings › Webhooks gets the events you ticked as an HTTP request. The queue sends it in the background, never during the visitor's redirect.

The method is one of `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. It defaults to `POST`. Any other value is stored as `POST`.

**JSON body.** Every method except `GET` sends this body, with `Content-Type: application/json`:

```json
{
  "event": "link_clicked",
  "delivery_id": "0b0e3c6e-5d1c-4f56-9d6e-0f1f7f6d2a11",
  "timestamp": 1792756800,
  "site": "https://example.com",
  "data": {
    "link_id": 123,
    "slug": "my-link",
    "target_url": "https://example.com/landing",
    "click_count": 42,
    "country_code": "US",
    "referrer_domain": "google.com"
  }
}
```

`data` holds the event's payload keys from the table above, without `event`. `timestamp` is a Unix time in seconds, and `delivery_id` is a UUID.

**GET requests.** A `GET` request has no body. The event and delivery ID go in the query string as `lmp_event` and `lmp_delivery`. The event's data is not sent.

**Headers.** Every request carries these headers:

| Header | Value |
|---|---|
| `X-LMP-Event` | The event name, for example `link_clicked`. |
| `X-LMP-Delivery` | The delivery ID. It is the same on every retry of one delivery. |
| `X-LMP-Timestamp` | The Unix time in seconds, used in the signature. |
| `X-LMP-Signature` | `sha256=` and the hex signature. Absent when signing is off for the endpoint. |
| `Content-Type` | `application/json`, on requests with a body. |
| `User-Agent` | `LinkManagerPro-Webhook/1.0 (+https://example.com)`, with your site address. |

Custom headers you add to an endpoint are sent too. A custom header named `Content-Type`, `X-LMP-Event`, `X-LMP-Delivery`, `X-LMP-Timestamp` or `X-LMP-Signature` is ignored, whatever its case.

**Limits on the endpoint.** These apply to every request:

- The address must use `https`. Any port is allowed.
- Every address the host name resolves to must be public. Private and reserved addresses are refused.
- Redirects are not followed. Answer with a `2xx` code directly.
- The request times out after 5 seconds. A code from `200` to `299` counts as delivered, and anything else counts as a failed attempt.

**Test ping.** **Send test** sends the event `ping` right away, signed like any delivery. Its `data` is `{ "message": "Link Manager Pro webhook test" }`.

## Verify the signature

The signature proves a request came from your site and the body was not changed. Compute an HMAC-SHA256 of the string `t=`, the `X-LMP-Timestamp` value, a dot, and the raw request body. Use the endpoint's signing secret as the key. Compare the result to the hex part of `X-LMP-Signature`, which has the form `sha256=<hex>`.

Sign the raw body exactly as received, not a re-encoded copy of the parsed JSON. On a `GET` request the body is empty, so the signed string ends after the dot.

The plugin does not enforce a time window. Your receiver chooses one, for example 5 minutes, and rejects older timestamps.

If `X-LMP-Signature` is missing, signing is off for that webhook: **Send unsigned (ignore secret)** is switched on under **Request signing** in the endpoint's form. A signed webhook whose secret can't be read sends nothing: the delivery fails with `last_error` `secret_unreadable`. So a missing header means only that **Send unsigned** is on. Treat unsigned requests as untrusted.

### PHP receiver

This function checks the header and the timestamp, and answers `401` when either is wrong. It runs in a plain PHP file on your own server.

```php
<?php
function myprefix_verify_lmp_webhook( string $secret ): array {
	$body      = file_get_contents( 'php://input' );
	$timestamp = $_SERVER['HTTP_X_LMP_TIMESTAMP'] ?? '';
	$signature = $_SERVER['HTTP_X_LMP_SIGNATURE'] ?? '';

	if ( '' === $signature || ! ctype_digit( $timestamp ) || abs( time() - (int) $timestamp ) > 300 ) {
		http_response_code( 401 );
		exit;
	}

	$expected = 'sha256=' . hash_hmac( 'sha256', 't=' . $timestamp . '.' . $body, $secret );

	if ( ! hash_equals( $expected, $signature ) ) {
		http_response_code( 401 );
		exit;
	}

	return json_decode( $body, true ) ?? array();
}

$event = myprefix_verify_lmp_webhook( 'YOUR_SIGNING_SECRET' );
http_response_code( 200 );
```

### Node.js receiver

This Express handler keeps the raw body with `express.raw`, so the signature is computed over the exact bytes.

```js
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.LMP_SIGNING_SECRET;

app.post('/lmp-webhook', express.raw({ type: '*/*' }), (req, res) => {
  const timestamp = req.get('X-LMP-Timestamp') || '';
  const signature = req.get('X-LMP-Signature') || '';

  if (!signature || !/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(401);
  }

  const expected = 'sha256=' + crypto
    .createHmac('sha256', SECRET)
    .update(`t=${timestamp}.`)
    .update(req.body)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  console.log(event.event, event.delivery_id);
  res.sendStatus(200);
});

app.listen(3000);
```

## Retries and pausing

A delivery is tried up to 5 times. After a failed attempt, the next one waits about 1 minute, then 5 minutes, then 30 minutes, then 2 hours. Each wait varies by up to 20 percent, up or down. After the fifth failure the delivery is marked failed.

An endpoint is paused automatically after 15 failed deliveries in a row. One delivered request resets the count, and so does a delivered test. Resume a paused endpoint with its **Active** switch, which also resets the count.

| Setting | Value |
|---|---|
| Attempts per delivery | 5 |
| Waits between attempts | 60 s, 300 s, 1,800 s, 7,200 s, each plus or minus 20 percent |
| Auto-pause | After 15 failed deliveries in a row |
| Pending deliveries per endpoint | 500. New events for an endpoint that has 500 waiting are not queued. |
| Delivery log kept | 7 days for delivered requests, 30 days for failed ones |
| Deliveries per run | Up to 20, within a 15 second budget |

Delivery runs from WordPress cron, so it needs cron to run on your site. The hook `link_manager_pro_webhook_drain` runs every five minutes. When an event is queued, the plugin also schedules the single event `link_manager_pro_webhook_nudge` to start a run on the next request. Old delivery rows are removed once a day by `link_manager_pro_webhook_purge`.

A paused endpoint's waiting deliveries are moved back an hour without using an attempt. They continue when the endpoint is active again.

## AutomatorWP

The integration loads only when AutomatorWP is active. It adds a **Link Manager Pro** integration with four triggers and one action. The click and broken link triggers are anonymous, so they run without a WordPress user.

Each trigger offers the event's payload keys as tags. Use a tag in any AutomatorWP field, for example `{slug}`.

| Trigger (as shown in AutomatorWP) | Event | Tags |
|---|---|---|
| **A link is clicked** | `link_clicked` | `event`, `link_id`, `slug`, `target_url`, `click_count`, `country_code`, `referrer_domain` |
| **A broken link is found** | `broken_link_found` | `event`, `url`, `source_post_id`, `status_code`, `health_type` |
| **A link is created** | `link_created` | `event`, `link_id`, `slug`, `target_url`, `redirect_type` |
| **A broken link is fixed** | `broken_link_fixed` | `event`, `url`, `source_post_id`, `health_type`, `resolution` |

The tag labels in AutomatorWP are friendlier, such as **Destination URL**, **Total clicks** and **Resolved URL**. The tag names above are the payload keys.

### Action: Create a link

The action **Create a link** adds a short link. It has two fields, and both accept tags.

| Field | Required | What it does |
|---|---|---|
| **Destination URL:** | Yes | Where the short link redirects to. Only `http` and `https` addresses are accepted. |
| **Slug (optional):** | No | The short link's slug. Left blank, the plugin generates one. |

The new link is stored with the source `automatorwp`. The plugin never fires an event while the same event is still running, so an automation that starts from **A link is created** and creates a link cannot loop: that new link's own `link_created` is skipped. If the destination is empty or not `http` or `https`, the action does nothing, and a failed create never stops the automation.

## Related

- [Webhooks and AutomatorWP](https://docs.linkmanagerpro.com/guides/webhooks-automation)
- [Action hooks](https://docs.linkmanagerpro.com/developers/hooks/actions)
- [Hooks overview](https://docs.linkmanagerpro.com/developers/hooks/)
- [REST API overview](https://docs.linkmanagerpro.com/developers/rest-api/)
