The API is part of the Agency plan. The account owner creates a key under My account -> API keys. The full key is shown exactly ONCE, so store it right away.
Get started
1. Create a key
My account -> API keys -> New key. Give it a name you will recognise later.
2. Choose scopes
Tick only the modules this key needs. Ticking write includes read.
3. Make the first call
Send the key in the Authorization header. Start with /api/v1/me - it reports what your key may do.
4. Build
Every endpoint answers with JSON shaped { data: ... }. Errors are { error: "code" }.
Authentication
Every request carries the API key. Both forms below work the same way - use whichever your tool supports. A key always starts with the prefix hsly_live_. A request with no key, or with a revoked one, gets a 401.
Never put a key in browser-side code or in a public repository - it reaches the whole account. Keep it in a server-side environment variable. A leaked key can be revoked immediately and replaced.
Scopes
A key carries exactly the scopes you ticked when you created it. A missing scope answers 403 insufficient_scope. Give each key only what it truly needs: a Zapier key that reads form replies has no business deleting sites.
| Scope | Allows |
|---|---|
| sites:read | List the account's sites and read one site's details |
| sites:write | Create a new site (counts against the plan's site quota) |
| pages:read | List pages, read a page including its block tree |
| pages:write | Create, update and delete pages; publish and unpublish |
| blog:read | List and read blog posts, categories and tags |
| blog:write | Create, update and delete posts and terms |
| media:read | List the media library |
| media:write | Import an image by URL, retitle it, move it, trash it |
| forms:read | List forms and their submissions |
| forms:write | Mark a submission read, delete a submission |
| redirects:read | List the site's URL redirects |
| redirects:write | Create, update and delete redirects |
| navigation:read | Read the header and footer menu trees |
| navigation:write | Replace a menu's items |
| analytics:read | Read daily traffic totals and dimension breakdowns |
| webhooks:read | List webhooks and their delivery attempts |
| webhooks:write | Create, update and delete webhooks |
Endpoints
Every path is relative to https://api.husly.app. :siteId is the site id from the GET /api/v1/sites response. The site must belong to your account - a stranger's site id answers 404, not 403, because a key must not learn that other accounts exist.
Account
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/me | any valid key |
| GET | /api/v1/webhook-events | webhooks:read |
Sites
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites | sites:read |
| GET | /api/v1/sites/:siteId | sites:read |
| POST | /api/v1/sites | sites:write |
Pages
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/pages | pages:read |
| GET | /api/v1/sites/:siteId/pages/:pageId | pages:read |
| POST | /api/v1/sites/:siteId/pages | pages:write |
| PUT | /api/v1/sites/:siteId/pages/:pageId | pages:write |
| DELETE | /api/v1/sites/:siteId/pages/:pageId | pages:write |
Blog
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/posts | blog:read |
| GET | /api/v1/sites/:siteId/posts/:postId | blog:read |
| POST | /api/v1/sites/:siteId/posts | blog:write |
| PUT | /api/v1/sites/:siteId/posts/:postId | blog:write |
| DELETE | /api/v1/sites/:siteId/posts/:postId | blog:write |
| GET | /api/v1/sites/:siteId/terms | blog:read |
| POST | /api/v1/sites/:siteId/terms | blog:write |
| PUT | /api/v1/sites/:siteId/terms/:termId | blog:write |
| DELETE | /api/v1/sites/:siteId/terms/:termId | blog:write |
Media
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/media | media:read |
| POST | /api/v1/sites/:siteId/media | media:write |
| PATCH | /api/v1/sites/:siteId/media/:mediaId | media:write |
| DELETE | /api/v1/sites/:siteId/media/:mediaId | media:write |
Forms
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/forms | forms:read |
| GET | /api/v1/sites/:siteId/form-submissions | forms:read |
| GET | /api/v1/forms/submissions?siteId= | forms:read |
| PATCH | /api/v1/sites/:siteId/form-submissions/:id | forms:write |
| DELETE | /api/v1/sites/:siteId/form-submissions/:id | forms:write |
Redirects
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/redirects | redirects:read |
| POST | /api/v1/sites/:siteId/redirects | redirects:write |
| PUT | /api/v1/sites/:siteId/redirects/:id | redirects:write |
| DELETE | /api/v1/sites/:siteId/redirects/:id | redirects:write |
Menus
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/menus | navigation:read |
| GET | /api/v1/sites/:siteId/menus/:key | navigation:read |
| PUT | /api/v1/sites/:siteId/menus/:key | navigation:write |
Analytics
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/analytics | analytics:read |
| GET | /api/v1/sites/:siteId/analytics/breakdown | analytics:read |
Webhooks
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/sites/:siteId/webhooks | webhooks:read |
| GET | /api/v1/sites/:siteId/webhooks/:id/deliveries | webhooks:read |
| POST | /api/v1/sites/:siteId/webhooks | webhooks:write |
| PUT | /api/v1/sites/:siteId/webhooks/:id | webhooks:write |
| DELETE | /api/v1/sites/:siteId/webhooks/:id | webhooks:write |
Example: publish a blog post
A post's body is a block tree - the same shape the builder saves. The simplest start is a single richtext block. Leave layoutJson out and you get an empty post you can finish in the builder.
Errors
An error is always JSON shaped { "error": "code" }, sometimes with extra fields (required, limit and so on). The code is machine-readable and stable - branch on the code, never on the message.
| HTTP | error | What it means |
|---|---|---|
| 400 | invalid_input | The body failed validation; the details field lists what failed |
| 401 | unauthorized | No key was sent |
| 401 | invalid_key | Unknown or revoked key |
| 401 | key_expired | The key passed its expiry date |
| 402 | plan_required | The plan does not include this feature; the feature field names it |
| 402 | site_quota_exceeded | The plan's site limit is reached |
| 402 | storage_quota_exceeded | The media upload would exceed the storage quota |
| 403 | api_not_entitled | The account's plan has no API access |
| 403 | insufficient_scope | The key lacks the scope named in the required field |
| 403 | forbidden_widget | The block tree uses a widget this account may not place |
| 404 | site_not_found | No such site, or it belongs to another account |
| 409 | slug_taken | That slug already exists on this site |
| 413 | file_too_large | The imported file is over the size limit |
| 415 | unsupported_type | The imported file is not an allowed image type |
| 429 | rate_limited | Too many requests; retry after the Retry-After header |
Limits
Requests are capped per key per minute. Going over answers 429 with a Retry-After header, and the body's `limit` field says which cap applied. Plan quotas - number of sites, number of pages, storage - apply through the API as well: a script cannot walk around them.
The current version is v1. We will not reshape a v1 response in a way that breaks your integration - new fields may appear, existing ones will not vanish. A breaking change would mean a new path (/api/v2/).
Frequently asked questions
The account owner. A key belongs to the account rather than to a single site, and reaches every site on that account.
No. We store only a hash of the key, never the key itself. The list shows just the prefix (for example hsly_live_ab12cd34). If you lose a key, create a new one and revoke the old.
It stops working immediately - the next request gets a 401. The row stays in the list so the record of when and from where it was used survives.
No. The CRM is the platform's own tool and is not opened to customers. The API covers the site side: sites, pages, blog, media, forms, redirects, menus, analytics and webhooks.
Yes - create a webhook. Your server receives a POST the moment a form is submitted or a post is published, so you never have to poll the API on a timer.
Yes. An expired key answers 401 key_expired. It is a good idea for short-lived work, such as a one-off migration script.