# Hosted sites API

The public endpoints a hosted site's pages call: site status, page views, forms, events, resources and media. They need no key. The site's slug in the address is how Bynefit knows which site you mean.

Available

> **Who this is for.** The runtime and its modules already call these for you. Read this page to build your own markup against them, or to call them from a server. Nothing here exposes member lists, payments, form answers or visitor totals. See the [hosted sites guide](https://bynefit.com/docs/hosted-sites) for what a page can and cannot reach.

## Conventions

| Topic | How it works |
| --- | --- |
| Base | `https://bynefit.com/api/sites` |
| Authentication | None. The slug (3 to 100 lowercase letters, numbers and hyphens) names the site. |
| Browsers | Every endpoint answers any origin (`Access-Control-Allow-Origin: *`) and never uses cookies, except [secure requests](#secure-requests), which answer only the site's own addresses. Send requests without credentials. |
| Errors | Most answer `200` with `{"ok": false, "error": "..."}`, so check `ok`, not just the status. The widget and events endpoints use real `404` and `500` codes. |
| Metering | Calls are counted per site per day for usage. They are not throttled, except where noted below. |

## Which sites answer

Every endpoint decides the same way whether a site is open to the public. A *live* site is published, not suspended, not archived or deleted or waiting for deletion, and belongs to a Space that is active and not being deleted. Where an endpoint needs a live site, an unknown slug and a site that is not live get the identical answer (form submit also answers a malformed slug with `Invalid site`), so nobody can learn which sites exist by asking.

| Endpoint | Needs | Otherwise |
| --- | --- | --- |
| Site status | A site that is not archived or deleted. Suspended, unpublished and maintenance sites are reported as they are. | `200` `Site not found` |
| Page views | A site that is not archived or deleted | Still a GIF, nothing recorded |
| Form definition | The form's site live, or the Space active for a Space-wide form | `200` `Form not found` |
| Form submit | A live site, with the form active and belonging to it | `200` `Site not available` for a site that is not live; `Form not found` for an unknown or inactive form |
| Events | A live site, and the Space's public events setting on | `404` `site_not_found` |
| Ready-made blocks | A live site. The `events` block also needs public events on. | `404` `Not found` |
| Media library | A live site | `200` `Site not available` |

Only a Space that is switched off or being deleted stops serving its sites. Turning off *public events* hides the events endpoint and the events block, and nothing else.

## Site status

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/runtime-status?slug=my-site` |
| Needs | Nothing. Send slug, or host for a custom domain. |

**cURL**

```bash
curl "https://bynefit.com/api/sites/runtime-status?slug=my-site"
```

**JavaScript**

```javascript
const res = await fetch("https://bynefit.com/api/sites/runtime-status?slug=my-site");
const { ok, status } = await res.json();
if (ok && status.is_maintenance) console.log(status.maintenance_message);
```

**200 (JSON)**

```json
{
  "ok": true,
  "slug": "my-site",
  "kind": "hosted",
  "status": {
    "is_suspended": false,
    "is_maintenance": false,
    "maintenance_message": null,
    "published": true,
    "skip_branding": false,
    "badge_href": null
  }
}
```

- `host` works instead of `slug` for a verified custom domain. If both are sent, `slug` wins.
- A site that is archived or deleted, or a slug that does not exist, answers `200` with `{"ok": false, "error": "Site not found"}`. A suspended, unpublished or in-maintenance site is still reported, so the runtime can show its page.
- `badge_href` is the link the "Powered by Bynefit" badge should use. It is `null` for most sites. When the Space that owns the site is a Bynefit partner whose link is switched on, it is that partner's link on `bynefit.com`, and the badge should use it in place of the general link.
- Cached for 30 seconds.

## Page views

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/track?slug=my-site&path=/about` |
| Needs | Nothing |

Always answers a 1 by 1 transparent GIF, whatever you send, so it can be fired as an image: `new Image().src = ...`. It stores the page path (without query string or fragment), the referring address, the browser's user agent and a hash of the visitor's IP address. No cookies and no raw IP address. Views are recorded for any site that has not been archived or deleted, including one that is unpublished or suspended; an archived or deleted site records nothing. Visitors or sites that send too many views in a minute have the extra ones dropped silently. The totals are shown to the Space owner in the dashboard; there is no endpoint to read them.

## Form definition

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/forms/{form_id}/schema` |
| Needs | Nothing. {form_id} looks like frm_AbC123xYz |

**200 (JSON)**

```json
{
  "ok": true,
  "form": {
    "id": "frm_AbC123xYz",
    "name": "Contact us",
    "team_name": "Riverside Aid",
    "confirmation_message": null,
    "fields": [
      { "id": 12, "label": "Email", "type": "email", "required": true, "placeholder": null, "help": null, "options": null },
      { "id": 13, "label": "Topic", "type": "dropdown", "required": false, "placeholder": null, "help": null, "options": ["Housing", "Food", "Other"] }
    ]
  }
}
```

Field types: `text`, `textarea`, `email`, `tel`, `number`, `date`, `dropdown`, `checkbox`, `radio`, `section_header`, `hidden`. Only active forms answer, and only while the form is open: a form tied to a site needs that site live, and a Space form needs an active Space. Anything else answers `{"ok": false, "error": "Form not found"}`, the same as an unknown form. A malformed id answers `Invalid form id`. Cached for 60 seconds.

## Submit a form

| Request |  |
| --- | --- |
| Method and path | `POST /api/sites/{slug}/forms/{form_id}/submit` |
| Needs | Nothing. The form must be active and belong to the site, and the site must be live: published, not suspended, not archived or deleted, and its Space active. |

Send a form-encoded body (`application/x-www-form-urlencoded` or multipart), not JSON. A value is matched to a field by `field_{id}`, or by the field's label lowercased with anything that is not a letter or number turned into `_` (so `Full name` is `full_name`). Checkbox groups can repeat the name (`topic[]`); the values are joined with a comma. Names that match no field are ignored.

**cURL**

```bash
curl -X POST https://bynefit.com/api/sites/my-site/forms/frm_AbC123xYz/submit \
  -d "full_name=Sam Rivera" \
  -d "email=sam@example.com" \
  -d "message=Do you have evening hours?"
```

**JavaScript**

```javascript
const res = await fetch("https://bynefit.com/api/sites/my-site/forms/frm_AbC123xYz/submit", {
  method: "POST",
  headers: { Accept: "application/json" },
  body: new URLSearchParams({
    full_name: "Sam Rivera",
    email: "sam@example.com",
    message: "Do you have evening hours?"
  })
});
const result = await res.json();
if (!result.ok) console.error(result.error, result.missing);
```

**PHP**

```php
$ch = curl_init("https://bynefit.com/api/sites/my-site/forms/frm_AbC123xYz/submit");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'full_name' => 'Sam Rivera',
        'email' => 'sam@example.com',
        'message' => 'Do you have evening hours?',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
```

**200 (JSON)**

```json
{ "ok": true, "message": "Thanks! We received your submission." }
```

The message is the form's own confirmation message when it has one. Nothing else comes back: no submission id and no echo of what was sent.

## Forms that ask for an email code

The schema answer carries `auth_mode`. When it is `email_otp`, a submit without a confirmed email answers `403` with `"code": "email_code_required"`. The Bynefit form module handles this for you: it shows a short first step, sends the code, and unlocks the form with the confirmed address already in the first email field. A form that asks people to sign in (`bynli_login`) answers `403` with `"code": "auth_required"` and the address of its Bynefit form page instead.

| Request |  |
| --- | --- |
| Method and path | `POST /api/sites/{slug}/forms/{form_id}/otp` |
| Needs | Nothing. The form must be an email-code form on a live site. |

Form-encoded body with `action=send` and `email`: a six digit code is emailed and `{"ok": true, "challenge": "..."}` comes back. Codes are limited to one a minute per address and form, 5 an hour per address and 200 an hour per form, and each caller address is throttled; over a limit answers `429`. Then `action=verify`, `challenge` and `code`: `{"ok": true, "ticket": "...", "email": "..."}`. Five wrong tries lock a code. Send the ticket as `_bynli_otp` with the submit. It is a bearer token: good for an hour, for that form only, for any number of submissions. The submission is stored under the confirmed address, and the form's first email field is replaced by it. An unknown or non-email-code form answers `404` or `400`, and an expired ticket answers `403` with `email_code_required` so the form asks again.

| Status | Body | Meaning |
| --- | --- | --- |
| `200` | `{"ok":false,"error":"Please fill in: Email","missing":["Email"]}` | A required field is empty. `missing` lists the labels. |
| `200` | `{"ok":false,"error":"Invalid site"}` | The slug in the address is malformed. |
| `200` | `{"ok":false,"error":"Site not available"}` | The site is unknown, unpublished, suspended, archived or deleted, or its Space is inactive or being deleted. All of these answer the same. |
| `200` | `{"ok":false,"error":"Form not found"}` | The form is unknown or not active. A malformed id answers `Invalid form`. A form that belongs to another Space or is not enabled for this site answers `Form does not belong to this site` or `Form not enabled on this site`. |
| `403` | `{"ok":false,"code":"email_code_required","error":"Confirm your email with the code we send you before submitting this form."}` | The form is an email-code form and the submit carried no valid `_bynli_otp` ticket. |
| `403` | `{"ok":false,"code":"auth_required","form_url":"..."}` | The form needs people to sign in, which only works from its Bynefit form page. |
| `429` | `{"ok":false,"error":"Too many submissions. Please try again in a minute."}` | More than 5 submissions to the same form from one address in a minute. |
| `500` | `{"ok":false,"error":"Server error. Please try again."}` | Something failed on our side. Retry. |

### What a submission sets off

- The answers are saved with the form. A *lead* form also creates a contact.
- The `form.submitted` webhook fires. It carries the form's id and name, never the answers. See [Webhooks](https://bynefit.com/docs/webhooks).
- Unless it is a registration form, your Space's leaders get a follow-up task and a notification. This is capped at 20 a hour per Space; past the cap the answers are still saved.
- If the person gave an email address (`email`, `email_address` or `_respondent_email`), they get your autoresponder if the form has one, or a short receipt. A malformed address is ignored and the submission still goes through.
- A hidden field named `_bynli_hp` is a bot trap. If it is filled in, the answer is `{"ok": true}` and nothing is saved.

The server only checks that required fields are filled. It does not check that an email looks like an email, that a number is a number, or that a dropdown value is one of the options, so validate in the page as well.

## Events

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/{slug}/events?limit=5&scope=upcoming` |
| Needs | Nothing |

`limit` is 1 to 50 (default 5). `scope` is `upcoming` (default) or `past`. Only a live site answers, and only when the Space has turned on public events. Otherwise the answer is the same `404` `{"ok": false, "error": "site_not_found"}` as for an unknown or malformed slug, so the answer never reveals which sites exist. Cancelled and draft events are left out. `upcoming` covers today up to two years ahead and `past` reaches back two years, newest first.

**cURL**

```bash
curl "https://bynefit.com/api/sites/my-site/events?limit=3&scope=upcoming"
```

**JavaScript**

```javascript
const res = await fetch("https://bynefit.com/api/sites/my-site/events?limit=3&scope=upcoming");
const { events, count } = await res.json();
for (const e of events) console.log(e.event_date_iso, e.title);
```

**PHP**

```php
$data = json_decode(file_get_contents("https://bynefit.com/api/sites/my-site/events?limit=3&scope=upcoming"), true);
foreach ($data['events'] as $e) {
    echo $e['event_date_iso'], ' ', $e['title'], "\n";
}
```

**200 (JSON)**

```json
{
  "ok": true,
  "team": { "slug": "riverside-aid", "name": "Riverside Aid" },
  "scope": "upcoming",
  "count": 1,
  "events": [
    {
      "id": 481,
      "title": "Free tax help night",
      "description": "Bring your documents.",
      "event_date": "2026-11-04 18:00:00",
      "event_date_iso": "2026-11-04T18:00:00+00:00",
      "location": "Community hall",
      "tickets_enabled": true,
      "ticket_price": 0.0,
      "ticket_name": "General",
      "tickets_sold": 12,
      "tickets_limit": 40
    }
  ]
}
```

Errors use real codes: `404` `site_not_found` for an unknown, malformed or gated site (one answer for all, so it never reveals which sites exist), and `500` `server_error`. At most 50 events come back. Cached for 60 seconds. Sold counts and limits come with every event listed. There are no attendee lists or buyers in the answer. Ticket and detail links on a hosted site go to the event's page on Bynefit.

## Ready-made blocks

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/widget/{slug}/{kind}` |
| Needs | Nothing. {kind} is events or resources. |

Returns finished HTML in a JSON field, for dropping into a page. Any other kind, or any site that is not live, answers `404` with `{"ok": false, "error": "Not found"}`, the same body for each, and so does the `events` block when the Space has not turned on public events. Cached for 60 seconds.

| Kind | Options | Answer |
| --- | --- | --- |
| `events` | `scope` (`upcoming` or `past`), `limit` 1 to 50, default 5 | `{"ok":true,"enabled":true,"html":"...","count":N}`. Needs a live site and public events turned on, or it answers `404`. Cancelled and draft events are left out, within the same two-year window as the events endpoint. `count` is the number of upcoming events, not the number shown. |
| `resources` | `category` (letters, numbers, spaces, `_` and `-`, up to 40), `limit` 1 to 50, default 12 | `{"ok":true,"html":"...","count":N}`. The Space's most-upvoted approved resources, filtered by category. |

The HTML is escaped on our side. Set it with `innerHTML` only for these two endpoints, never for text from anywhere else.

## Media library

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/{slug}/media` |
| Needs | Nothing. The site must be live. |

**200 (JSON)**

```json
{
  "ok": true,
  "site": "my-site",
  "count": 1,
  "media": [
    {
      "id": 7,
      "filename": "team.jpg",
      "url": "https://my-site.site.bynefit.com/media/team.jpg",
      "mime_type": "image/jpeg",
      "size_bytes": 184233,
      "uploaded_at": "2026-10-08 09:12:44"
    }
  ]
}
```

Newest first, with no paging, so a big library is a big answer. Not cached. An unknown, malformed or not-live site answers `{"ok": false, "error": "Site not available"}`.

## Secure requests

| Request |  |
| --- | --- |
| Method and path | `GET /api/sites/my-site/proxy/weather` |
| Needs | Nothing from the page. The site owner names the request, its service and its methods in the dashboard, and the call must come from a page on the site. |

The one endpoint here that is not open to every origin. It forwards a page's call to the service the site owner chose and adds the owner's secret on the server, so a page can use a paid or private API without holding its key. It accepts only the methods the owner allowed, answers only the site's own addresses, and has per-visitor, per-minute and per-day limits. The full contract, limits and error codes are in [Keeping keys safe on a hosted site](https://bynefit.com/docs/site-secrets#secure-requests).

## What is not here

- No way to read form answers, visitor totals, members, payments, donations or ticket buyers from a page.
- Donations and tickets are links to Bynefit's own pages, not API calls.
- Managing a site (publishing, uploading media, editing the document) is done in the Bynefit Sites app and the dashboard, not through a public API.
- (Planned) A key-based API for a site's shop, products and orders.

---

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