# Site host API

How a WordPress site, or your own connector, reports to Bynefit with a signed key: a connection test, visit counts and a daily health report. It is what the Bynefit Connect plugin uses, written down so you can build the same thing.

Available

> **Server to server only.** These calls are signed with a secret key and must come from your site's server, never from a page. There is no browser access (no cross-origin headers). For calls a page makes, see the [Hosted sites API](https://bynefit.com/docs/sites-runtime-api).

## Get a site key

An admin of the Space opens the site in the dashboard, then *Settings* and *WordPress* (the older address [/dash/sites/host-keys](https://bynefit.com/dash/sites/host-keys) redirects there), and chooses *Generate key* for that site. The key is shown once, so paste it straight into the plugin or your connector's settings. The same screen lists each key's label, first characters, last use and whether it is active, and revokes one.

- A key looks like `bynli_sh_` followed by 32 hex characters.
- The key is the site's identity. There is no site id or slug in the request.
- There is no rotate button. Make a new key, put it in the plugin, then revoke the old one.
- Sites that Bynefit hosts for you get a key installed for them. You never see it and do not need one.
- Only sites that are WordPress managed by Bynefit, or connected from elsewhere, can have a key.
- A site key is not a site secret. [Keys & secrets](https://bynefit.com/docs/site-secrets) is for sites Bynefit hosts as files; a WordPress site keeps its own service keys on its server, outside any page.

## Sign a request

Three headers on every call:

| Header | Value |
| --- | --- |
| `Authorization` | `Bearer bynli_sh_...` |
| `X-Bynefit-Timestamp` | The time now in Unix seconds, digits only. It must be within 5 minutes of Bynefit's clock. |
| `X-Bynefit-Signature` | `sha256=` and the lowercase hex HMAC-SHA256 of the timestamp, a newline, and the exact body, keyed with the site key itself. |

`X-Bynefit-Timestamp` and `X-Bynefit-Signature` are the names to send. The older `X-Bynli-Timestamp` and `X-Bynli-Signature` are still accepted, so an installed WordPress plugin keeps working. The header name is not part of what is signed. Sending a header under both names with different values is refused with a `400`.

For a `GET` the body is empty, so the signed text is the timestamp and a newline. The query string is not signed. The key is used both to identify the site and as the signing key, so it must never leave your server.

**cURL**

```bash
KEY="bynli_sh_YOUR_SITE_KEY"
TS=$(date +%s)
BODY='{"kind":"heartbeat"}'
SIG="sha256=$(printf '%s\n%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$KEY" -hex | sed 's/^.* //')"

curl -i -X POST "https://bynefit.com/api/site-host/report" \
  -H "Authorization: Bearer $KEY" \
  -H "X-Bynefit-Timestamp: $TS" \
  -H "X-Bynefit-Signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

**JavaScript**

```javascript
const { createHmac } = require("crypto");

function sign(key, body = "", ts = String(Math.floor(Date.now() / 1000))) {
  const signature = "sha256=" + createHmac("sha256", key).update(ts + "\n" + body).digest("hex");
  return { ts, signature };
}

async function call(base, key, method, path, body = "") {
  const { ts, signature } = sign(key, body);
  const res = await fetch(base + path, {
    method,
    headers: {
      Authorization: "Bearer " + key,
      "X-Bynefit-Timestamp": ts,
      "X-Bynefit-Signature": signature,
      "Content-Type": "application/json"
    },
    body: body === "" ? undefined : body
  });
  return { status: res.status, body: await res.json() };
}

module.exports = { sign, call };

call("https://bynefit.com/api", process.env.BYNEFIT_SITE_KEY, "POST", "/site-host/report", JSON.stringify({ kind: "heartbeat" }))
  .then(console.log);
```

**Python**

```python
import os
import hashlib
import hmac
import json
import time
import urllib.request

def sign(key, body=b"", ts=None):
    ts = str(ts if ts is not None else int(time.time()))
    digest = hmac.new(key.encode(), ts.encode() + b"\n" + body, hashlib.sha256).hexdigest()
    return ts, "sha256=" + digest

def call(base, key, method, path, body=b""):
    ts, signature = sign(key, body)
    request = urllib.request.Request(base + path, data=body or None, method=method, headers={
        "Authorization": "Bearer " + key,
        "X-Bynefit-Timestamp": ts,
        "X-Bynefit-Signature": signature,
        "Content-Type": "application/json",
    })
    with urllib.request.urlopen(request) as response:
        return response.status, json.load(response)

print(call("https://bynefit.com/api", os.environ["BYNEFIT_SITE_KEY"], "POST", "/site-host/report", b'{"kind":"heartbeat"}'))
```

**PHP**

```php
function bynefit_site_sign(string $key, string $body = '', ?int $ts = null): array
{
    $ts = $ts ?? time();
    return [(string) $ts, 'sha256=' . hash_hmac('sha256', $ts . "\n" . $body, $key)];
}

function bynefit_site_call(string $base, string $key, string $method, string $path, string $body = ''): array
{
    [$ts, $signature] = bynefit_site_sign($key, $body);
    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POSTFIELDS => $body === '' ? null : $body,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $key,
            'X-Bynefit-Timestamp: ' . $ts,
            'X-Bynefit-Signature: ' . $signature,
            'Content-Type: application/json',
        ],
    ]);
    $response = curl_exec($ch);
    return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), json_decode((string) $response, true)];
}

print_r(bynefit_site_call("https://bynefit.com/api", getenv('BYNEFIT_SITE_KEY'), 'POST', '/site-host/report', '{"kind":"heartbeat"}'));
```

## Report: connection test, visits and daily health

| Request |  |
| --- | --- |
| Method and path | `POST /api/site-host/report` |
| Needs | A site key with a signature. The body is JSON of up to 32 KB. |

The body is an object with a `kind`. Three kinds are accepted.

### `heartbeat`

Checks that the key and signature work. Nothing is stored.

**200 (JSON)**

```json
{ "ok": true, "site_id": 118, "slug": "riverside-aid", "kind": "heartbeat", "recorded": false }
```

### `visits`

Tells Bynefit that your site got visitors, so the owners can be told. Send a count, not visitors: no IP addresses and no visitor details are accepted.

**Body (JSON)**

```json
{ "kind": "visits", "count": 7 }
```

`count` is clamped to 0 to 1000. The answer is `{"ok": true, "site_id": 118, "kind": "visits", "recorded": true, "alerted": false}`. Bynefit groups visits into quiet and roll-up windows and notifies each admin of the Space the way they chose (instant, digest or off), when the site has visit alerts on.

### `daily`

One health report a day. Sending it again for the same date replaces that day.

**Body (JSON)**

```json
{
  "kind": "daily",
  "usage_date": "2026-10-10",
  "storage_bytes": 52428800,
  "bandwidth_bytes": 0,
  "request_count": 0,
  "meta": { "wp_version": "6.6", "php_version": "8.2", "plugin_version": "0.23.2" },
  "insights": {
    "updates": { "core": 0, "plugins": 2, "themes": 0 },
    "posts": 48,
    "pages": 12,
    "comments_pending": 3,
    "users_total": 5,
    "admins": 1,
    "https": true,
    "debug": false
  }
}
```

- `usage_date` is required, as `YYYY-MM-DD`. The three numbers are optional whole numbers, never below zero.
- `meta` and `insights` are free-form objects. Bynefit stores whatever you send, within the size cap, and shows the keys it knows: versions and update counts, content counts, comments waiting, users and admins, the active theme, and the https, debug, search and multisite flags.
- Send counts, versions and flags only. Never send people's names, emails or content.
- The answer is `{"ok": true, "site_id": 118, "slug": "riverside-aid", "kind": "daily", "recorded": true}`.

The first accepted report also attaches an unowned connected site to the person who made the key, which is what makes it show up in the Bynefit Sites app.

## Errors

| Status | Body | Meaning |
| --- | --- | --- |
| `401` | `{"error":"unauthorized"}` | No bearer key, a malformed key, an unknown one, or a revoked one. These look the same on purpose. |
| `400` | `{"error":"bad_timestamp"}` | The timestamp is not digits only. |
| `400` | `{"error":"bad_signature_format"}` | The signature does not start with `sha256=`. |
| `400` | `{"error":"conflicting_headers"}` | A signing header was sent under both its `X-Bynefit-` and `X-Bynli-` names with different values. |
| `401` | `{"error":"signature_invalid"}` | The signature does not match, or the time is more than 5 minutes off. |
| `400` | `{"error":"body_too_large"}`, `invalid_json`, `bad_kind`, `bad_usage_date` | The body is over 32 KB, is not JSON, has an unknown `kind`, or the daily date is not `YYYY-MM-DD`. |
| `405` | `{"error":"method_not_allowed"}` | Wrong method. |
| `429` | `{"error":"rate_limited"}` | Too many calls on this key. Wait the seconds in the `Retry-After` header. The body has no wait time. |
| `500` | `{"error":"server_error"}` | Retry later. |

If the signature check fails, check three things first: you signed the exact bytes you sent, you used a newline and not a dot between the time and the body, and your server clock is right.

## Read the forms list

| Request |  |
| --- | --- |
| Method and path | `GET /api/site-host/forms` |
| Needs | A site key with a signature and an empty body |

**200 (JSON)**

```json
{ "ok": true, "team_id": 42, "forms": [ { "id": "frm_AbC123xYz", "title": "Contact us", "slug": "contact-us" } ] }
```

All of the Space's active forms, not only the ones for this site. At most 200.

## Support tickets

A connected site can show and send Bynefit support tickets for its Space. Tickets belong to the Space, so every site key in the Space can read every ticket of that Space. A person named in `wp_user_email` or `wp_user_name` is whatever your site says; Bynefit does not verify them. A captured request can be replayed for up to five minutes, so keep these calls on your server over HTTPS.

| Call | What it does |
| --- | --- |
| `GET /api/site-host/tickets?status=open` | Up to 50 tickets: `open` (default), `in_progress`, `resolved` or `all`. Returns each ticket's ref, subject, category, priority, status, reply count and a short preview, plus counts per status. |
| `GET /api/site-host/tickets/{ref}` | One ticket with its replies. `404` `not_found` for a ref that is not in your Space. |
| `POST /api/site-host/tickets` | Opens a ticket. `subject` 3 to 200 characters, `body` up to 5000, `category` one of `technical`, `billing`, `general`, `account`. Answers `200` with `{"ok":true,"ticket_id":...,"ticket_ref":"...","priority":"...","created_at":"..."}`. Errors: `400` `subject_too_short`, `subject_too_long`, `empty_subject`, `empty_body`, `invalid_category` or `invalid_json`; `413` `body_too_large`. |
| `POST /api/site-host/tickets/{ref}/reply` | Adds a reply (`body` up to 5000 characters) and re-opens the ticket. |
| `POST /api/site-host/tickets/{ref}/resolve` | Marks it resolved. An optional `note` is posted as a reply first. |

## Plugin version and download

These two need no key.

| Request |  |
| --- | --- |
| Method and path | `GET /api/site-host/version` |
| Needs | Nothing |

Returns the current Bynefit Connect release: `name`, `slug`, `version`, `download_url`, `download_sha256`, `requires`, `tested`, `requires_php`, `last_updated`, `homepage`, `description` and `changelog` (HTML). `?changelog=full` returns the whole history instead of the five newest versions. Check the zip against `download_sha256` before installing it. Cached for 10 minutes.

| Request |  |
| --- | --- |
| Method and path | `GET /api/site-host/download` |
| Needs | Nothing |

Redirects (`302`) to the current zip. Both are limited per address; a `429` asks you to wait.

## What is not part of this API

- Other calls the plugin makes are private and not documented.
- Reading anything back about visitors, members or payments. These endpoints take reports and tickets; they do not return your site's analytics.
- (Planned) A way to rotate a site key in one step.

---

Source: https://bynefit.com/docs/site-host-api
Checked against the live API on October 10, 2026.
