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/).
The Rentals partner API
A separate API for travel agencies, channel managers and AI agents that sell one Husly rental business's cars, bikes, boats, gear or places to stay. The site owner makes the key on the Rentals "Integrations" tab (Partner API) and gives it to the partner. A key opens that one site only: its published catalogue and the bookings the partner made. No Agency plan is needed: it comes with the Rentals add-on.
A partner key starts with hsly_partner_ and works only under /api/partner/v1/rentals. An account API key (hsly_live_) does not work here, and a partner key does not work in the account API.
Partner API endpoints
| Method | Path | Needs |
|---|---|---|
| GET | /api/partner/v1/rentals/me | any partner key |
| GET | /api/partner/v1/rentals/listings | any partner key |
| GET | /api/partner/v1/rentals/listings/:slug | any partner key |
| GET | /api/partner/v1/rentals/listings/:slug/calendar | any partner key |
| GET | /api/partner/v1/rentals/listings/:slug/quote | any partner key |
| GET | /api/partner/v1/rentals/bookings | any partner key |
| POST | /api/partner/v1/rentals/bookings | a key that may book |
| GET | /api/partner/v1/rentals/bookings/:id | any partner key |
| POST | /api/partner/v1/rentals/bookings/:id/confirm | a key that may book |
| POST | /api/partner/v1/rentals/bookings/:id/cancel | a key that may book |
| GET | /api/partner/v1/rentals/webhook | any partner key |
| PUT | /api/partner/v1/rentals/webhook | any partner key |
| DELETE | /api/partner/v1/rentals/webhook | any partner key |
| POST | /api/partner/v1/rentals/webhook/test | any partner key |
| POST | /api/partner/v1/rentals/mcp | any partner key |
| GET | /api/partner/v1/rentals/openapi.json | no key |
| GET | /api/partner/v1/rentals/docs | no key |
"hold": true keeps the dates for 20 minutes (at most 60) and writes to nobody. POST /bookings/:id/confirm books them; a hold that is not confirmed lets the dates go by itself. Sending the same "reference" again returns the same booking, not a second one. The price and the free days are the same as on the rental business's own site. A partner's booking is not paid on Husly: the partner settles the money with the renter.
AI agents (MCP)
The same API is also an MCP server at POST /api/partner/v1/rentals/mcp, with the same key. It speaks MCP 2026-07-28, 2025-11-25, 2025-06-18, 2025-03-26. Tools: rentals_site_info, search_rentals, get_rental, get_rental_calendar, quote_rental, book_rental, confirm_booking, cancel_booking, get_booking, list_bookings. A read-only key gets no booking tools. book_rental holds the dates by default and tells the agent to get the traveller's yes before confirm_booking.
The partner's webhook
The partner sets its own address with PUT /api/partner/v1/rentals/webhook; the signing secret is shown once. Events: rental.booking.created, rental.booking.confirmed, rental.booking.cancelled, for the partner's own bookings only. The signature is the same as the account webhooks': X-Husly-Signature: sha256=<hex>, the HMAC-SHA256 of <timestamp>.<body>.
| HTTP | error | What it means |
|---|---|---|
| 401 | unauthorized / invalid_key / key_revoked | No partner key, an unknown one, or one the owner revoked |
| 403 | read_only_key | This key may read but not book |
| 403 | rentals_off | The site's Rentals add-on or module is off |
| 404 | listing_not_found / booking_not_found | No such listing, or not a booking this key made |
| 409 | unavailable | The dates are taken; the detail says why |
| 409 | hold_expired / already_started | The hold ran out, or the rental has begun |
| 422 | invalid_guest / not_bookable | Guest details are missing, or the range cannot be priced |
| 429 | rate_limited / too_many_bookings | 120 requests a minute or 60 new bookings an hour per key |
Every field and every answer's shape is in the partner API's own reference and its OpenAPI 3.1 file, which you can load into Postman or generate a client from.
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.
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.
Yes, through the Rentals partner API. The rental business gives the partner its key, and the key opens that one site only. An account API key does not do this.