# Account (https://hopla.page/docs/account)
## Plan and features [#plan-and-features]
`GET /account` returns the features available to the page. For example, a script can check `features.scheduling.included` before setting dates on a block.
```json
{
"account": {
"username": "camille",
"url": "https://hopla.page/camille",
"createdAt": "2026-10-20T09:12:00.000Z",
"plan": "pro",
"trial": null,
"subscription": { "plan": "pro", "status": "active", "period": "month", "periodEnd": "2026-11-20T09:12:00.000Z" },
"complimentaryPlan": null,
"features": {
"scheduling": { "included": true, "plan": "starter" },
"emailList": { "included": true, "plan": "pro" },
"api": { "included": true, "plan": "pro" }
},
"analyticsRetentionDays": 365
}
}
```
| Field | Description |
| --- | --- |
| `plan` | Effective plan, trial included: `free`, `starter`, `pro` or `premium` |
| `trial` | End date of the Pro trial, or `null` |
| `subscription` | Paid subscription: plan, status, billing period (`month` monthly, `year` yearly), end of the current period |
| `complimentaryPlan` | Complimentary plan granted by Hopla, and its end date (`null`: no end date) |
| `features` | Each gated feature: whether it is included (`included`), and the required plan (`plan`) |
| `analyticsRetentionDays` | Analytics retention available, in days |
Plan changes, payment and cancellation are only available in the admin panel, never through the API.
## API keys [#api-keys]
`GET /api-keys` lists the account keys without exposing their secret: name, prefix, write access, last use. `current` marks the key used for the request.
```json
{
"apiKeys": [
{ "id": "2916f190dae2", "name": "Claude Code", "prefix": "hopla_hT-a", "writeAccess": true, "createdAt": "2026-10-28T10:00:00.000Z", "lastUsedAt": "2026-10-28T21:40:00.000Z", "current": true }
]
}
```
Keys are created and revoked in Réglages (Settings), **API et MCP**: a key cannot create another key. See [Authentication](/authentication).
## Data export [#data-export]
`GET /export` returns all account data as JSON: account and plan, settings, blocks, views and clicks (without IP addresses), subscribers and keys (without their secret). It implements the GDPR right of access and contains the same data as "Exporter mes données" (Export my data) in Réglages, without image files (their URLs are included).
```bash
curl https://app.hopla.page/api/v2/export \
-H "Authorization: Bearer $HOPLA_API_KEY" -o hopla-export.json
```
# Analytics (https://hopla.page/docs/analytics)
```bash
curl "https://app.hopla.page/api/v2/analytics?period=7" \
-H "Authorization: Bearer $HOPLA_API_KEY"
```
## Period [#period]
- `period`: number of trailing days, `7`, `14`, `30` (default), `90` or `365`.
- `from` and `to`: start and end dates as `YYYY-MM-DD`, Europe/Paris time zone. For example `?from=2026-10-01&to=2026-10-15`.
Each value comes with the value for the previous period of the same length, to compute trends. The period is capped to the analytics retention of the current plan (`analyticsRetentionDays` in [Account](/account)).
## Response [#response]
```json
{
"analytics": {
"days": 7,
"views": 1284,
"visitors": 902,
"clicks": 311,
"daily": [{ "date": "2026-10-22", "views": 170, "visitors": 121, "clicks": 40 }],
"blocks": [{ "id": "a1b2c3d4e5f6", "title": "Mon nouveau podcast", "clicks": 96, "previous": 71 }],
"sources": [{ "name": "instagram", "views": 640, "previous": 512 }],
"devices": [{ "name": "mobile", "views": 1101 }],
"countries": [{ "code": "FR", "views": 1050, "previous": 930 }],
"previousPeriod": { "views": 1120, "visitors": 801, "clicks": 260 }
}
}
```
| Field | Description |
| --- | --- |
| `days` | Period length, in days |
| `views`, `visitors`, `clicks` | Totals for the period: views, unique visitors, clicks. A visitor is counted once per day |
| `daily` | Daily time series, oldest first |
| `blocks` | Clicks per block, with `previous` for the previous period |
| `sources` | Referrers: a social network, a QR code, or direct traffic |
| `devices` | Devices: mobile, desktop, tablet |
| `countries` | ISO country code (`FR`), empty when unknown |
| `previousPeriod` | The three totals for the previous period |
## Weekly views (all plans) [#weekly-views-all-plans]
`GET /analytics/week` returns views for the last 7 days and the 7 days before, even without analytics history (free plan).
```json
{ "week": { "views": 412, "previous": 377, "everViewed": true } }
```
`everViewed` is `true` if the page has had at least one view.
## Summary [#summary]
`GET /analytics/summary` returns the summary shown at the top of the admin panel analytics tab: a sentence ready to display, in French, and its highlights. It accepts the same period parameters as `GET /analytics`.
```json
{
"summary": {
"sentence": "En 30 jours, 1 284 vues, 12 % de plus que les 30 jours d’avant.",
"highlights": [
{ "text": "Ta page a eu 1 284 vues, 12 % de plus que les 30 jours d’avant.", "trend": "up" },
{ "text": "« Mon nouveau podcast » a eu 96 clics, 35 % de plus.", "trend": "up" }
],
"days": 30
}
}
```
`highlights` contains one sentence per highlight, with its trend in `trend`: `up`, `down` or `neutral`. See the [Slack example](/examples#weekly-report-in-slack).
## Privacy [#privacy]
Analytics are collected without cookies, and visitor IP addresses are never stored.
# Authentication (https://hopla.page/docs/authentication)
Every request to the API or the MCP server is authenticated with an API key, sent as a bearer token in the `Authorization` header:
```http
Authorization: Bearer hopla_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
## Create a key [#create-a-key]
On [app.hopla.page](https://app.hopla.page/admin), open **Réglages** (Settings), then **API et MCP**, and click **Nouvelle clé** (New key).
- **Nom** (name): identifies the key, for example "Claude Code" or "Weekly report".
- **Peut modifier ma page** (write access): when enabled, the key can read and write the profile, theme and blocks. When disabled, the key has read-only access to the page and analytics.
The key is shown only once. Hopla stores only its SHA-256 hash, so it cannot be recovered: if you lose it, create a new one.
An account can have up to 10 keys. Use one key per integration, so each can be revoked independently.
## Permissions [#permissions]
| Operation | Read-only key | Read and write key |
| --- | --- | --- |
| Read the page, account and reference values | Yes | Yes |
| Read analytics and the summary | Yes | Yes |
| Read the email list and subscribers | Yes | Yes |
| Fetch website, feed or review page metadata | Yes | Yes |
| Update settings and blocks, upload images | No, 403 `read_only_key` | Yes |
| Delete subscribers, sync subscribers | No, 403 `read_only_key` | Yes |
| MCP tools exposed | The 16 read tools | All 25 tools |
A key can only access the page of the account that created it. `GET /api-keys` lists the account keys without exposing their secret: see [Account](/account).
## Plan requirements [#plan-requirements]
The API and MCP server are included in the Pro plan and in the 14-day trial. If the account leaves the Pro plan, its keys are suspended (403 `plan`) but not deleted, and are reactivated when the account returns to Pro.
On a self-hosted instance without billing configured (see the [self-hosting guide](https://github.com/liliansev/linkinbio/blob/main/docs/SELF-HOSTING.md)), every account has access to the API and MCP server on the instance domain: `https://your-domain/api/v2` and `https://your-domain/mcp`.
## Security [#security]
A key with write access grants full control over the page. Never expose it in client-side code, a mobile app or a Git repository.
- Store keys in environment variables or a secrets manager.
- Use a read-only key for dashboards and agents that do not need to modify the page.
- Revoke unused or leaked keys in Réglages, **API et MCP**. Revocation is immediate.
The API does not use cookies, so a cross-origin request has no privileges without a key. CORS is therefore open to all origins.
# Blocks (https://hopla.page/docs/blocks)
A Hopla page is an ordered list of blocks (`blocks`): links, section headings, text, videos, products… The API returns them in display order, each with its `id`.
## Types [#types]
Without `type`, a new block is a link (`link`). The type is immutable after creation: to change it, delete the block and create a new one.
## Fields [#fields]
| Field | Description |
| --- | --- |
| `title` | Title: button label, section heading or paragraph text. 160 characters, 1,000 for a text block |
| `url` | Target URL: `https://…`, `mailto:` or `tel:`. Hopla adds a missing `https://`; an unparsable URL returns 400 `invalid_url` |
| `subtitle`, `description` | Subtitle (one line), then longer text |
| `icon` | Icon or social network name: `youtube`, `instagram`, `fusee`… |
| `image` | Thumbnail https URL |
| `visible` | Visible on the page (default: `true`). `false` keeps the block hidden |
| `featured`, `format` | Featured block, displayed as `button`, `banner` or `card` |
| `startsAt`, `endsAt` | ISO 8601 start and end of the display window |
| `countdown` | Show a countdown before `startsAt` instead of hiding the block |
| `price` | Product price, as displayed: "29 €" |
| `amounts` | Suggested tip amounts in euros, for `tip` |
| `questions` | FAQ items for `faq`: a list of `{ question, answer }` |
| `proof` | Rating for `proof` blocks (reviews): `{ kind, rating, count, source }` |
The [API reference](/reference#model/block) documents every field, including read-only ones.
## Create a block [#create-a-block]
`POST /blocks` inserts the block at the top of the page, or at index `position` (0 = top).
```bash
curl -X POST https://app.hopla.page/api/v2/blocks \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "heading", "title": "Mes formations", "position": 3}'
```
Common block examples:
```json
{
"title": "Réserver une séance",
"url": "https://cal.com/camille/seance",
"featured": true,
"format": "card",
"description": "45 minutes, en visio ou à Lyon."
}
```
```json
{
"type": "product",
"title": "Programme 8 semaines",
"url": "https://boutique.example.com/programme",
"price": "49 €",
"image": "https://boutique.example.com/programme.jpg"
}
```
```json
{
"type": "faq",
"title": "Questions fréquentes",
"questions": [
{ "question": "Les séances se font où ?", "answer": "À Lyon, ou en visio." },
{ "question": "Faut-il du matériel ?", "answer": "Un tapis suffit." }
]
}
```
```json
{
"type": "proof",
"title": "Ce qu’en disent mes élèves",
"url": "https://g.page/r/exemple",
"proof": { "kind": "rating", "rating": 4.9, "count": 128, "source": "Google" }
}
```
```json
{
"type": "video",
"title": "Ma routine du matin",
"url": "https://www.youtube.com/watch?v=xxxxxxxxxxx"
}
```
## Update, hide or schedule a block [#update-hide-or-schedule-a-block]
`PATCH /blocks/{id}` updates only the fields sent; other fields are unchanged.
```bash title="Hide a block without deleting it"
curl -X PATCH https://app.hopla.page/api/v2/blocks/a1b2c3d4e5f6 \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"visible": false}'
```
```bash title="Show it from December 1 to 15, with a countdown before"
curl -X PATCH https://app.hopla.page/api/v2/blocks/a1b2c3d4e5f6 \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"startsAt": "2026-12-01T09:00:00+01:00", "endsAt": "2026-12-15T23:59:00+01:00", "countdown": true}'
```
To clear a date, send an empty string: `{"endsAt": ""}`.
## Duplicate a block [#duplicate-a-block]
`POST /blocks/{id}/duplicate` inserts a copy right below the block, with a new `id`. With `{"hidden": true}`, the copy is hidden, so you can edit it before publishing.
```bash
curl -X POST https://app.hopla.page/api/v2/blocks/a1b2c3d4e5f6/duplicate \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"hidden": true}'
```
## Images in a block [#images-in-a-block]
`image` (link thumbnail, product photo) and `images` (gallery) expect the path of an image hosted by Hopla. Upload it first with [`POST /media`](/images), then use the returned `path` in the block.
## Reorder blocks [#reorder-blocks]
`PUT /blocks/order` expects the `id` of **every** block, each exactly once, top to bottom. If an id is missing, duplicated or does not belong to the page, the API returns 400 `invalid_order` and changes nothing.
```bash
curl -X PUT https://app.hopla.page/api/v2/blocks/order \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ids": ["f6e5d4c3b2a1", "a1b2c3d4e5f6", "0a1b2c3d4e5f"]}'
```
## Delete a block [#delete-a-block]
`DELETE /blocks/{id}` permanently deletes the block. To hide it temporarily, use `{"visible": false}`.
## Plan restrictions [#plan-restrictions]
Blocks go through the same validation as in the admin panel. When the plan changes, no data is deleted: features not included in the new plan are kept and reactivated when the account upgrades again.
# Email list (https://hopla.page/docs/email-list)
The `email-signup` block (Pro plan) stores each subscriber in Hopla and forwards it to the integration configured in **Intégrations** (Integrations): Brevo, Mailchimp, Kit, MailerLite, beehiiv, Systeme.io, MailCheap, Loops, or Make, Zapier and n8n via webhook. The API lets you read and manage this list.
Email addresses are personal data. A key with access to the list can read all of them: only use it with trusted tools, and use a read-only key for dashboards.
## Summary [#summary]
```bash
curl https://app.hopla.page/api/v2/email-list -H "Authorization: Bearer $HOPLA_API_KEY"
```
```json
{
"list": {
"subscribers": 128,
"thisWeek": 9,
"pending": 0,
"connected": { "tool": "brevo", "list": "Newsletter", "key": "xkeysib-…a3f" }
}
}
```
`subscribers` is the total, `thisWeek` the subscribers of the last 7 days. `pending` counts subscribers not yet synced to the newsletter (provider unavailable, or no integration configured). `connected` (connected integration) is `null` without an integration; only a masked prefix of the integration credential is returned.
## Subscribers [#subscribers]
Newest first, 100 per page (up to 500 with `limit`). Offset pagination: pass the `next` value of a response as the `offset` parameter of the next request, until it is `null`.
```bash
curl "https://app.hopla.page/api/v2/subscribers?limit=100&offset=0" \
-H "Authorization: Bearer $HOPLA_API_KEY"
```
```js title="all-subscribers.mjs"
const all = [];
let offset = 0;
while (offset !== null) {
const response = await fetch(`https://app.hopla.page/api/v2/subscribers?limit=500&offset=${offset}`, {
headers: { Authorization: `Bearer ${process.env.HOPLA_API_KEY}` },
});
const page = await response.json();
all.push(...page.subscribers);
offset = page.next;
}
console.log(all.length, 'subscribers');
```
```python title="all_subscribers.py"
import os, requests
subscribers, offset = [], 0
while offset is not None:
page = requests.get(
"https://app.hopla.page/api/v2/subscribers",
params={"limit": 500, "offset": offset},
headers={"Authorization": f"Bearer {os.environ['HOPLA_API_KEY']}"},
).json()
subscribers += page["subscribers"]
offset = page["next"]
print(len(subscribers), "subscribers")
```
```json
{
"subscribers": [
{ "id": "92c5ca872beb", "email": "ana@example.com", "firstName": "Ana", "subscribedAt": "2026-10-27T18:02:11.000Z", "source": "instagram", "synced": true }
],
"total": 128,
"next": 100
}
```
| Field | Description |
| --- | --- |
| `firstName` | First name; empty if the block does not ask for it |
| `subscribedAt` | Signup date |
| `source` | Referrer: `instagram`, `tiktok`, `qr`, `direct`… |
| `synced` | Already synced to the newsletter |
## CSV export [#csv-export]
`?format=csv` returns the full list as a single file, like the admin panel export: `email,first_name,subscribed_at,source`.
```bash
curl "https://app.hopla.page/api/v2/subscribers?format=csv" \
-H "Authorization: Bearer $HOPLA_API_KEY" -o subscribers.csv
```
## Delete a subscriber [#delete-a-subscriber]
For an erasure request: `DELETE /subscribers/{id}` permanently deletes the subscriber from Hopla. The copy already synced to the newsletter is not deleted: remove it from that provider as well.
```bash
curl -X DELETE https://app.hopla.page/api/v2/subscribers/92c5ca872beb \
-H "Authorization: Bearer $HOPLA_API_KEY"
```
## Sync pending subscribers [#sync-pending-subscribers]
After configuring an integration or after a provider outage, `POST /subscribers/sync` pushes pending subscribers and returns the counts: `synced` (sent) and `failed` (rejected).
```json title="200 OK"
{ "synced": 9, "failed": 0 }
```
Without a configured integration, the API returns 409 `no_integration`. Integrations are configured in the admin panel, **Intégrations**: their credentials never go through the API.
# Endpoints (https://hopla.page/docs/endpoints)
Base URL: `https://app.hopla.page/api/v2`, JSON over HTTPS, UTF-8. Every request is authenticated with an [API key](/authentication) in the `Authorization` header. Endpoints with a method other than `GET` modify the page, except the three [web fetching](/web-fetching) endpoints: a read-only key receives 403 `read_only_key` on them.
`GET https://app.hopla.page/api/v2`, without authentication, returns a plain text version of this documentation, for AI agents.
# Errors and rate limits (https://hopla.page/docs/errors)
## Rate limits [#rate-limits]
| Resource | Limit |
| --- | --- |
| Requests per key | 120 per minute, 5,000 per day |
| Failed authentications per IP address | 20 per minute |
| Request body | 256 KB; 2.2 MB for a data URL image and for an MCP message |
| Keys per account | 10 |
| Web fetching (`/fetch/preview`, `/fetch/feed`, `/fetch/rating`) | 30 per 10 minutes per account |
| Newsletter sync (`/subscribers/sync`) | 20 per 10 minutes per account |
| Image (`/media`) | 1.5 MB, WebP, JPEG or PNG |
When a rate limit is exceeded, the API returns `429` with a `Retry-After` header: wait that many seconds before retrying.
```js title="Retry after a 429"
async function request(url, options, retries = 3) {
const response = await fetch(url, options);
if (response.status === 429 && retries > 0) {
const seconds = Number(response.headers.get('Retry-After') ?? 60);
await new Promise(r => setTimeout(r, seconds * 1000));
return request(url, options, retries - 1);
}
return response;
}
```
## Error format [#error-format]
Every error returns an HTTP status and an `error` object:
```json
{ "error": { "code": "invalid_api_key", "message": "Invalid or revoked API key." } }
```
Use `code` in your error handling logic: its value is stable. `message` is a human-readable description, in English, and may change.
## Error codes [#error-codes]
| Status | `code` | Description |
| --- | --- | --- |
| 400 | `invalid_json` | Request body is not valid JSON |
| 400 | `empty_update` | `PATCH /settings` without any setting |
| 400 | `unknown_theme` | Unknown theme: see `GET /themes` |
| 400 | `unknown_block_type` | Unknown block type |
| 400 | `block_type_unavailable` | Block type not available to this account |
| 400 | `block_rejected` | Block rejected by validation, for example scheduling without the Starter plan |
| 400 | `invalid_order` | `PUT /blocks/order` without every id, or with a duplicate id |
| 400 | `invalid_period` | Invalid period, or outside the analytics retention |
| 400 | `invalid_url` | Block `url` cannot be parsed (`https://`, `mailto:` or `tel:` expected), or a web fetching request without an `https://` URL |
| 400 | `missing_image` | `POST /media` without an image |
| 401 | `missing_api_key` | Missing `Authorization: Bearer hopla_…` header |
| 401 | `invalid_api_key` | Invalid or revoked key |
| 403 | `read_only_key` | The key is read-only |
| 403 | `plan_required` | The plan does not include this feature (API: Pro; analytics: Starter) |
| 403 | `terms_not_accepted` | Updated terms of service must be accepted on app.hopla.page |
| 403 | `account_suspended` | Account suspended |
| 404 | `block_not_found` | Block not found |
| 404 | `subscriber_not_found` | Subscriber not found |
| 404 | `not_found` | Route not found |
| 405 | `method_not_allowed` | Method not allowed on this route |
| 409 | `no_integration` | `POST /subscribers/sync` without a connected newsletter or webhook |
| 413 | `payload_too_large` | Request body over 256 KB (2.2 MB for an image) |
| 413 | `image_too_large` | Image over 1.5 MB |
| 413 | `storage_quota_exceeded` | Image storage quota exceeded for this account |
| 415 | `unsupported_image_format` | Image format other than WebP, JPEG or PNG |
| 422 | `fetch_failed` | The site did not respond, or did not return HTML |
| 429 | `rate_limited` | Rate limit exceeded for this key, or web fetching rate limit exceeded |
| 429 | `too_many_invalid_keys` | Too many failed authentications from this IP address |
| 500 | `internal_error` | Internal server error: retry shortly |
The MCP server returns the same errors: the tool result has `isError: true`, with the message as text.
API v1 returns the same statuses with an `erreur` object and French codes (`cle_invalide`, `bloc_introuvable`…): see [Migrating from v1](/migrating-from-v1).
# Examples (https://hopla.page/docs/examples)
Each example reads the API key from the `HOPLA_API_KEY` environment variable and fits in a single file. JavaScript examples run on Node.js 20 or later, without dependencies.
## Add a product from its URL [#add-a-product-from-its-url]
Reproduces what the admin panel does when a product URL is pasted: fetch the title, price and photo, upload the photo to Hopla, create the block.
```js title="add-product.mjs"
const api = 'https://app.hopla.page/api/v2';
const headers = { Authorization: `Bearer ${process.env.HOPLA_API_KEY}`, 'Content-Type': 'application/json' };
const call = async (method, path, body) => {
const r = await fetch(api + path, { method, headers, body: JSON.stringify(body) });
const json = await r.json();
if (!r.ok) throw new Error(json.error.message);
return json;
};
const url = process.argv[2]; // node add-product.mjs https://boutique.example.com/programme
const { preview } = await call('POST', '/fetch/preview', { url, image: 'share' });
const image = preview.image ? (await call('POST', '/media', { image: preview.image })).media.path : '';
const { block } = await call('POST', '/blocks', { type: 'product', title: preview.title, url, price: preview.price, image });
console.log(`Added "${block.title}", ${block.price || 'no price'}.`);
```
`product` blocks require the Starter plan or higher.
## Weekly report in Slack [#weekly-report-in-slack]
Posts the weekly summary and the three most clicked links to a Slack channel every Monday. Requires a Slack [incoming webhook](https://api.slack.com/messaging/webhooks) URL in `SLACK_WEBHOOK`. A read-only key is enough.
```js title="weekly-report.mjs"
const get = path => fetch(`https://app.hopla.page/api/v2${path}`, {
headers: { Authorization: `Bearer ${process.env.HOPLA_API_KEY}` },
}).then(r => r.json());
const { summary } = await get('/analytics/summary?period=7');
const { analytics } = await get('/analytics?period=7');
const top = analytics.blocks.toSorted((a, b) => b.clicks - a.clicks).slice(0, 3).map(b => `• ${b.title}: ${b.clicks} clicks`).join('\n');
await fetch(process.env.SLACK_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `${summary.sentence}\n${top}` }),
});
```
```python title="weekly_report.py"
import os, requests
def get(path):
return requests.get(
f"https://app.hopla.page/api/v2{path}",
headers={"Authorization": f"Bearer {os.environ['HOPLA_API_KEY']}"},
).json()
sentence = get("/analytics/summary?period=7")["summary"]["sentence"]
top = "\n".join(f"• {b['title']}: {b['clicks']} clicks" for b in sorted(get("/analytics?period=7")["analytics"]["blocks"], key=lambda b: -b["clicks"])[:3])
requests.post(os.environ["SLACK_WEBHOOK"], json={"text": f"{sentence}\n{top}"})
```
Schedule it every Monday at 9 am with cron (`0 9 * * 1 node weekly-report.mjs`), a scheduled GitHub Action, or a Make or n8n scenario.
## Schedule a launch [#schedule-a-launch]
A featured link, visible from Monday 9 am to Sunday night, with a countdown before the start. Scheduling requires the Starter plan.
```bash
curl -X POST https://app.hopla.page/api/v2/blocks \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "La masterclass est ouverte",
"url": "https://example.com/masterclass",
"featured": true,
"format": "card",
"startsAt": "2026-11-02T09:00:00+01:00",
"endsAt": "2026-11-08T23:59:00+01:00",
"countdown": true
}'
```
## Update a recurring link [#update-a-recurring-link]
A block at the top of the page whose URL changes regularly: latest episode, latest video, current offer. Its `id` is read from `HOPLA_BLOCK_ID`.
```js title="update-link.mjs"
// node update-link.mjs "Épisode 12 : la reprise" https://example.com/episode-12
const [title, url] = process.argv.slice(2);
const response = await fetch(`https://app.hopla.page/api/v2/blocks/${process.env.HOPLA_BLOCK_ID}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${process.env.HOPLA_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ title, url, visible: true }),
});
console.log(response.ok ? 'Link updated.' : (await response.json()).error.message);
```
For a blog, podcast or YouTube channel, the `feed` and `latest-video` blocks update automatically, without a script: see [Blocks](/blocks).
## Daily subscriber backup [#daily-subscriber-backup]
Exports the email list to a dated CSV file every night. A read-only key is enough.
```bash title="backup-subscribers.sh"
#!/bin/sh
mkdir -p ~/hopla
curl -sf "https://app.hopla.page/api/v2/subscribers?format=csv" \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-o ~/hopla/subscribers-$(date +%F).csv
```
## Generate a QR code for print [#generate-a-qr-code-for-print]
SVG scales without quality loss, whatever the print size.
```bash
curl "https://app.hopla.page/api/v2/qr-code?format=svg" \
-H "Authorization: Bearer $HOPLA_API_KEY" -o qr-hopla.svg
```
# Images (https://hopla.page/docs/images)
Pages only display images hosted by Hopla: fast loading, no third-party dependency, no visitor tracking. Uploading is a two-step process: `POST /media` stores the image and returns its `path`, then that path is used in settings or in a block.
## Upload [#upload]
Supported formats: WebP, JPEG or PNG, 1.5 MB maximum. Two upload methods:
The request body contains the binary image, with its MIME type in `Content-Type`.
```bash
curl -X POST https://app.hopla.page/api/v2/media \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @portrait.jpg
```
The image encoded as a data URL, in the `image` field. This is the format used by the MCP server.
```bash
curl -X POST https://app.hopla.page/api/v2/media \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"image\": \"data:image/png;base64,$(base64 -w0 logo.png)\"}"
```
```json title="201 Created"
{
"media": {
"path": "/media/054a3c6991bc.png",
"url": "https://hopla.page/media/054a3c6991bc.png"
}
}
```
The format is detected from the file signature (magic bytes), not from its extension: a GIF renamed to `.png` returns 415.
## Usage [#usage]
| Location | Field |
| --- | --- |
| Profile photo | `PATCH /settings` with `{"profile": {"avatar": "/media/…"}}` |
| Page background (Starter) | `PATCH /settings` with `{"backgroundImage": "/media/…"}` |
| Link thumbnail, product photo | Block `image` |
| Gallery | `images` of a `gallery` block, a list of paths |
```js title="Update the profile photo with Node.js"
import { readFile } from 'node:fs/promises';
const key = process.env.HOPLA_API_KEY;
const api = 'https://app.hopla.page/api/v2';
const upload = await fetch(`${api}/media`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'image/jpeg' },
body: await readFile('portrait.jpg'),
});
const { media } = await upload.json();
await fetch(`${api}/settings`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: { avatar: media.path } }),
});
```
## Errors [#errors]
| Status | `code` | Cause |
| --- | --- | --- |
| 400 | `missing_image` | Empty body, or `image` is not a data URL |
| 413 | `image_too_large` | Image over 1.5 MB |
| 413 | `storage_quota_exceeded` | Image storage quota exceeded: delete images in the admin panel |
| 415 | `unsupported_image_format` | Format other than WebP, JPEG or PNG |
| 403 | `read_only_key` | The key is read-only |
# Introduction (https://hopla.page/docs)
## Key concepts [#key-concepts]
The API and MCP server are included in the Pro plan and in the 14-day trial.
API keys are created in the admin panel. Read-only keys cannot modify any data.
120 requests per minute and 5,000 per day per key. Above that, the API returns 429 with a Retry-After header.
## Base URLs [#base-urls]
| | |
| --- | --- |
| REST API | `https://app.hopla.page/api/v2` |
| MCP server | `https://app.hopla.page/mcp` |
| OpenAPI spec | [`hopla.page/docs/openapi.json`](https://hopla.page/docs/openapi.json), for client generation |
| AI agents | [`hopla.page/docs/llms.txt`](https://hopla.page/docs/llms.txt), and the full documentation in [`llms-full.txt`](https://hopla.page/docs/llms-full.txt) |
## Versions [#versions]
This documentation covers API v2. API v1 (`/api/v1`), released on October 28, 2026 with French field names, remains supported for existing integrations: see [Migrating from v1](/migrating-from-v1).
Every page of this documentation is available as Markdown: use **Copy Markdown** to pass it to an agent as is.
# MCP server (https://hopla.page/docs/mcp)
The Hopla MCP server exposes the same operations as the REST API to AI agents: read the page, add links, change the theme, query analytics. The agent selects which tools to call from natural language instructions.
- **Endpoint**: `https://app.hopla.page/mcp`
- **Transport**: Streamable HTTP, JSON responses, stateless
- **Authentication**: `Authorization: Bearer hopla_…`, as for the REST API
## Installation [#installation]
First create an [API key](/authentication), then configure the server in your client.
```bash title="Terminal"
claude mcp add --transport http hopla https://app.hopla.page/mcp \
--header "Authorization: Bearer hopla_…"
```
Add `--scope user` to enable it in all your projects. Then check the connection with `/mcp` in Claude Code.
In Réglages (Settings), **API et MCP**, the full command, key included, can be copied right after the key is created.
```json title="~/.cursor/mcp.json"
{
"mcpServers": {
"hopla": {
"url": "https://app.hopla.page/mcp",
"headers": { "Authorization": "Bearer hopla_…" }
}
}
}
```
A project-level `.cursor/mcp.json` file is also supported. Hopla then appears in Cursor settings, under MCP.
```json title=".vscode/mcp.json"
{
"servers": {
"hopla": {
"type": "http",
"url": "https://app.hopla.page/mcp",
"headers": { "Authorization": "Bearer ${input:hopla-api-key}" }
}
},
"inputs": [
{ "id": "hopla-api-key", "type": "promptString", "description": "Hopla API key", "password": true }
]
}
```
VS Code prompts for the key on first start and stores it securely: it is not written to the file.
```json title="~/.codeium/windsurf/mcp_config.json"
{
"mcpServers": {
"hopla": {
"serverUrl": "https://app.hopla.page/mcp",
"headers": { "Authorization": "Bearer hopla_…" }
}
}
}
```
Claude Desktop runs MCP servers locally: use `mcp-remote` as a proxy to Hopla (requires Node.js).
```json title="claude_desktop_config.json"
{
"mcpServers": {
"hopla": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://app.hopla.page/mcp", "--header", "Authorization:${HOPLA_AUTH}"],
"env": { "HOPLA_AUTH": "Bearer hopla_…" }
}
}
}
```
Open the file from Claude Desktop: Settings, Developer, Edit Config. Then restart the app.
Custom connectors in claude.ai and ChatGPT require OAuth, which is not supported yet. Use Claude Code, Cursor, VS Code, Windsurf or Claude Desktop.
## Tools [#tools]
25 tools, covering the REST API operations. With a read-only key, only the 16 read tools are exposed. Tool names and fields match API v2; the former French tool names (`lire_page`, `ajouter_bloc`…) are still accepted, with French field names.
### Read tools [#read-tools]
| Tool | Description |
| --- | --- |
| `get_page` | Full page: URL, plan, settings, and blocks with their ids |
| `get_account` | Plan, trial, subscription, and included features |
| `get_analytics` | Views, visitors and clicks for a period (`days`, or `from` and `to`) |
| `get_weekly_views` | Views for the last 7 days and the 7 days before |
| `get_analytics_summary` | One-sentence summary of the period and its highlights |
| `get_email_list` | Subscriber count and connected integration |
| `list_subscribers` | Subscribers, paginated |
| `get_qr_code` | QR code of the page, as an image |
| `fetch_url_preview` | Title, description, price and image of a URL |
| `fetch_feed` | Latest posts of a feed, or latest video of a YouTube channel |
| `fetch_review_rating` | Rating of a review page, out of 5 |
| `list_themes` | Available themes |
| `list_social_networks` | Social networks displayed as icons |
| `list_icons` | Block icons |
| `list_block_types` | Block types available to the page |
| `list_api_keys` | Account API keys, without their secret |
### Write tools [#write-tools]
| Tool | Description |
| --- | --- |
| `update_settings` | Update profile, theme, social links, colors, SEO… |
| `create_block` | Create a block at the top or at a given position |
| `update_block` | Partially update a block; `visible: false` hides it |
| `duplicate_block` | Duplicate a block right below the original, optionally hidden |
| `delete_block` | Permanently delete a block |
| `reorder_blocks` | Reorder all blocks |
| `upload_image` | Upload a data URL image, for a block or a profile photo |
| `delete_subscriber` | Permanently delete a subscriber |
| `sync_subscribers` | Sync pending subscribers to the connected newsletter |
## Example prompts [#example-prompts]
Hopla pages are in French, so prompts usually are too. Agents also understand English instructions.
- "Add a link to my new podcast at the top of my Hopla page: https://example.com/podcast."
- "Switch my page to a darker theme and update my bio: coach sportive à Lyon."
- "Hide the summer sale block, without deleting it."
- "Schedule my masterclass link to appear on Monday at 9 am, with a countdown."
- "Duplicate the November workshop block, hidden, and change its date to December."
- "Which link got the most clicks in the last 30 days, and where did visitors come from?"
- "Summarize my week on Hopla in three lines for my team."
- "Compare October and September: views, clicks, and growing countries."
- "How many people joined my list this week, and where did they come from?"
- "Delete ana@example.com from my list: she asked me to."
- "I connected Brevo: sync the pending subscribers."
- "Add this product to my page, with its photo and price: https://boutique.example.com/programme."
- "Read my blog and feature my latest post."
- "Give me the QR code of my page, for my poster."
The agent first calls `get_page` to get block ids. Changes are visible immediately on the page and in the admin panel.
## Protocol details [#protocol-details]
The server implements the MCP specification (versions `2025-06-18`, `2025-03-26` and `2024-11-05`). Each `POST` request carries one JSON-RPC message, or a batch of up to 50, and the response is JSON. The server opens no session and no stream: `GET` returns 405.
```bash title="List tools manually"
curl https://app.hopla.page/mcp \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
```
# Migrating from v1 (https://hopla.page/docs/migrating-from-v1)
API v1 was released on October 28, 2026 with French names (`/blocs`, `titre`, `reglages`). API v2, released on October 29, 2026, exposes the same operations with English names. Both versions read and write the same data.
**API v1 remains supported**: existing integrations keep working without changes. New integrations should use v2.
## What changes [#what-changes]
| | v1 | v2 |
| --- | --- | --- |
| Base URL | `https://app.hopla.page/api/v1` | `https://app.hopla.page/api/v2` |
| Paths, fields, enum values | French | English |
| Error object | `{ "erreur": { "code", "message" } }`, French codes | `{ "error": { "code", "message" } }`, English codes |
| CSV export header | `email,prenom,inscrit,source` | `email,first_name,subscribed_at,source` |
| MCP tools | `lire_page`, `ajouter_bloc`… | `get_page`, `create_block`… |
Authentication, rate limits, HTTP statuses and response structure do not change. In v2 requests, a field sent with its v1 name is still accepted: you can migrate one call at a time.
Theme ids (`onyx`), social network ids (`instagram`), icon ids and the text of your page (titles, bio) are not translated.
## MCP server [#mcp-server]
The MCP server lists its tools with English names and fields. Former tool names are still accepted and keep their French fields, so an agent configured before October 29, 2026 keeps working. Reconnect the client to load the new tool list.
## Endpoints [#endpoints]
Query parameters:
## Fields [#fields]
In `GET /api-keys`, `debut` becomes `prefix`; in `GET /email-list`, `semaine` becomes `thisWeek`; at the root of `GET /social-networks`, `reseaux` becomes `socialNetworks`.
## Block types [#block-types]
## Enum values [#enum-values]
## Plan features [#plan-features]
Keys of `features` in `GET /account`:
## Error codes [#error-codes]
# QR code (https://hopla.page/docs/qr-code)
```bash
curl "https://app.hopla.page/api/v2/qr-code?format=png&size=1200" \
-H "Authorization: Bearer $HOPLA_API_KEY" -o qr-hopla.png
```
| Parameter | Values |
| --- | --- |
| `format` | `svg` (default), scalable without quality loss for print, or `png` |
| `size` | Size in pixels, from 120 to 2000; default 720 |
The QR code points to the public page URL (the custom domain, if any), followed by `?s=qr`: scans are counted separately, as the "QR code" source, in [analytics](/analytics).
The MCP server returns it as an image (`get_qr_code`).
# Quickstart (https://hopla.page/docs/quickstart)
### Create an API key [#create-an-api-key]
On [app.hopla.page](https://app.hopla.page/admin), open **Réglages** (Settings), then **API et MCP**, and create a key with write access.
The key starts with `hopla_` and is shown only once: copy it immediately.
### Store the key in an environment variable [#store-the-key-in-an-environment-variable]
Never hardcode the key in source code: store it in an environment variable.
```bash title="Terminal"
export HOPLA_API_KEY="hopla_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```
### Get the page [#get-the-page]
```bash
curl https://app.hopla.page/api/v2/page \
-H "Authorization: Bearer $HOPLA_API_KEY"
```
```js title="get-page.mjs"
const response = await fetch('https://app.hopla.page/api/v2/page', {
headers: { Authorization: `Bearer ${process.env.HOPLA_API_KEY}` },
});
const { page, settings, blocks } = await response.json();
console.log(page.url, blocks.length, 'blocks');
```
```python title="get_page.py"
import os, requests
response = requests.get(
"https://app.hopla.page/api/v2/page",
headers={"Authorization": f"Bearer {os.environ['HOPLA_API_KEY']}"},
)
data = response.json()
print(data["page"]["url"], len(data["blocks"]), "blocks")
```
The response contains the page URL (`url`), its plan (`plan`), its settings (`settings`) and its blocks (`blocks`), each with an `id`.
### Add a link [#add-a-link]
```bash
curl -X POST https://app.hopla.page/api/v2/blocks \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Mon nouveau podcast", "url": "https://example.com/podcast"}'
```
```js title="create-link.mjs"
const response = await fetch('https://app.hopla.page/api/v2/blocks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HOPLA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ title: 'Mon nouveau podcast', url: 'https://example.com/podcast' }),
});
const { block } = await response.json();
console.log('Created:', block.id);
```
```python title="create_link.py"
import os, requests
response = requests.post(
"https://app.hopla.page/api/v2/blocks",
headers={"Authorization": f"Bearer {os.environ['HOPLA_API_KEY']}"},
json={"title": "Mon nouveau podcast", "url": "https://example.com/podcast"},
)
print("Created:", response.json()["block"]["id"])
```
The link is added at the top of the page and is live immediately.
## Next steps [#next-steps]
Block types and fields.
The same operations from Claude, Cursor or any MCP client.
Every endpoint, with an interactive console.
# Reference values (https://hopla.page/docs/reference-values)
These lists are generated from the Hopla source code and updated with every release. They are also available through the endpoints above.
## Block types [#block-types]
Types and their fields: [Blocks](/blocks).
## Themes [#themes]
For `theme`, in `PATCH /settings`.
## Social networks [#social-networks]
For `socialLinks`, in `PATCH /settings`, and for the `icon` field of a block.
## Icons [#icons]
For the `icon` field of a block: filled, monochrome icons, rendered in the theme color.
# Settings (https://hopla.page/docs/settings)
`GET /page` returns the full page: settings and blocks. `GET /settings` returns settings only.
`PATCH /settings` updates only the fields sent. The `profile`, `button`, `seo`, `colors` and `redirect` objects are merged field by field; the `socialLinks` list is replaced as a whole.
```bash
curl -X PATCH https://app.hopla.page/api/v2/settings \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"profile": {"bio": "Coach sportive à Lyon"}, "theme": "onyx"}'
```
The response contains the full settings after the update.
## Fields [#fields]
| Setting | Value |
| --- | --- |
| `profile` | Profile: `{ name, bio, avatar, photo }`. `name` is the display name, `avatar` an https image URL, `photo` is `round` or `large` |
| `theme` | A theme id, see below |
| `socialLinks` | Social links: `[{ network, url }]`, 20 maximum |
| `socialLinksPosition` | `top` (below the bio) or `bottom` (bottom of the page) |
| `colors` | Colors: `{ background, buttons, accent }` as `#rrggbb`; empty: theme default |
| `button` | Buttons: `{ style, shape, shadow }`: `solid`, `outline`, `soft`, `raised` or `glass`; `pill`, `rounded` or `square`; `none`, `subtle` or `strong` |
| `seo` | `{ title, description }` for search engines and link previews |
| `shareButton` | Show the Share button at the top of the page |
| `formal` | Page copy uses the formal "vous" form |
| `redirect` | Redirect: `{ url, endsAt }` sends the whole page to another URL until `endsAt`; `{ "url": "" }` stops it |
## Social links [#social-links]
```json
{
"socialLinks": [
{ "network": "instagram", "url": "https://instagram.com/camille" },
{ "network": "youtube", "url": "https://youtube.com/@camille" },
{ "network": "email", "url": "mailto:camille@example.com" }
]
}
```
Always send the full list, in display order: a network missing from the list is removed from the page. Valid values:
## Themes [#themes]
`GET /themes` returns the same list. Social networks, icons and block types have their own endpoints: see [Reference values](/reference-values).
# Web fetching (https://hopla.page/docs/web-fetching)
When a URL is pasted in the admin panel, Hopla extracts its title, image and, for products, its price. These three endpoints expose the same extraction, to fill a block from a script or an agent.
Each endpoint expects an `https://` URL in `url`. The page is fetched from Hopla servers, without cookies, and only public addresses are allowed (SSRF protection). Rate limit: 30 requests per account per 10 minutes; above that, 429 `rate_limited`.
## URL metadata [#url-metadata]
```bash
curl -X POST https://app.hopla.page/api/v2/fetch/preview \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://boutique.example.com/programme", "image": "shareButton"}'
```
```json
{
"preview": {
"title": "Programme 8 semaines",
"description": "Un plan simple pour reprendre le sport.",
"price": "49 €",
"image": "data:image/webp;base64,…"
}
}
```
| `image` | Result |
| --- | --- |
| `true` (default) | The site social sharing image, or its favicon as a fallback |
| `false` | Text only, faster |
| `"shareButton"` | The sharing image, or nothing: use it for product photos |
| `"logo"` | The site logo, from its declared icons |
| `"thumbnail"` | The best thumbnail for a link button |
The image is returned as a data URL: to use it in a block, send it as is to [`POST /media`](/images). An unreachable site, or one that does not return HTML, returns 422 `fetch_failed`.
## RSS feeds and YouTube channels [#rss-feeds-and-youtube-channels]
```bash
curl -X POST https://app.hopla.page/api/v2/fetch/feed \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/blog"}'
```
Returns the three latest posts, newest first: `title`, `url`, `date`, `image`, and `video` for an episode. The URL can be the feed itself (RSS, Atom) or a page that declares it. With `{"video": true}` and a YouTube channel URL, returns the latest long-form video. Without a usable feed: `{"articles": []}`.
## Review rating [#review-rating]
```bash
curl -X POST https://app.hopla.page/api/v2/fetch/rating \
-H "Authorization: Bearer $HOPLA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://fr.trustpilot.com/review/example.com"}'
```
```json
{ "rating": { "rating": 4.6, "count": 1820, "source": "Trustpilot" } }
```
The rating (`rating`) is normalized to 5, with the review count (`count`). It comes from Trustpilot, or from any page that declares it in its structured data (`aggregateRating`). Without a declared rating, or if the page blocks bots, the response is `{"rating": null}`. Google Business Profile pages are not supported: enter the rating manually in the `proof` block.