Skip to main content

Documentation

No results found.
Features Members

API

WebProCMS includes a built-in REST API — give external tools and integrations secure, token-based access to your site's content without opening up the dashboard. Pull content into a mobile app or another website, push new items in from an a...

WebProCMS includes a built-in REST API — give external tools and integrations secure, token-based access to your site's content without opening up the dashboard. Pull content into a mobile app or another website, push new items in from an automation platform, wire the site into a custom pipeline. For the site pushing data out as things happen, see the standalone Webhooks feature.


What it does

  • A versioned JSON REST API at /api/v1 covering the site's public pages and their image slots, content types (including their taxonomy vocabularies), content items, the media library (read, upload, edit, delete), forms and their submissions, CRM contacts, marketing subscribers, shop products and orders (including fulfillment), appointments, invoices and estimates, donations, event ticket orders and attendees, support tickets, tracked projects, reviews, directory listings, and MLS property listings. Every response — including errors — is JSON.
  • Token-based authentication: mint as many tokens as you need from the dashboard, one per integration. Tokens are shown exactly once at creation and stored hashed — the CMS itself can't recover a lost token, only replace it.
  • Per-token abilities: each token carries only the permissions it needs — one read/write pair per module, grouped in the create dialog under Content, Forms, Contacts, Commerce, and Platform — so a read-only widget can never modify your site even if its token leaks.
  • REST-hooks webhook management: with the standalone Webhooks feature also enabled, integrations can subscribe and unsubscribe outbound webhooks over REST — the pattern Zapier-style platforms use to register their triggers programmatically.
  • OpenAPI 3.1 spec + generated docs: the full machine-readable spec is served at /api/v1/openapi.json, and a human-readable documentation page in the dashboard is generated from the same document — it can never drift from what's served.
  • Optional expiry: tokens can expire automatically after 30 days, 90 days, or a year — or live until revoked.
  • Full content-item CRUD: list, read, create, update, and delete items of any content type — including custom types you've defined — with the same per-field validation the dashboard's own forms enforce.
  • Rate limiting: 120 requests per minute per token (unauthenticated probes get a much tighter per-IP budget), with standard 429 responses when exceeded.
  • Usage visibility: the token list shows when each token was last used, so stale or broken integrations are easy to spot.

Turn it on under Settings → Features → API (off by default). Manage tokens under Settings → API (admins only); the generated endpoint reference lives at Settings → API → View API documentation.

Authentication

Send the token as a Bearer header on every request:

curl https://example.com/api/v1/content-items \
  -H "Authorization: Bearer wpcms_..." \
  -H "Accept: application/json"

Requests without a valid token get a 401; a valid token missing the required ability gets a 403 naming the ability it needs. When the feature is toggled off, every API route returns 404 — existing tokens are kept but useless until the feature is re-enabled.

Endpoints

Endpoint Ability
GET /api/v1/pages content:read
GET /api/v1/pages/images content:read
PATCH/PUT /api/v1/pages/images content:write
GET /api/v1/content-types content:read
GET /api/v1/content-types/{slug} content:read
GET /api/v1/content-types/{slug}/terms content:read
GET /api/v1/content-items content:read
GET /api/v1/content-items/{id} content:read
POST /api/v1/content-items content:write
PATCH/PUT /api/v1/content-items/{id} content:write
DELETE /api/v1/content-items/{id} content:write
GET /api/v1/media media:read
GET /api/v1/media/{id} media:read
POST /api/v1/media media:write
PATCH/PUT /api/v1/media/{id} media:write
DELETE /api/v1/media/{id} media:write
GET /api/v1/forms forms:read
GET /api/v1/forms/{id} forms:read
GET /api/v1/forms/{id}/submissions forms:read
GET /api/v1/forms/{id}/submissions/{id} forms:read
POST /api/v1/forms/{id}/submissions forms:write
GET /api/v1/crm/contacts crm:read
GET /api/v1/crm/contacts/{id} crm:read
POST /api/v1/crm/contacts crm:write
PATCH/PUT /api/v1/crm/contacts/{id} crm:write
GET /api/v1/marketing/subscribers marketing:read
GET /api/v1/marketing/subscribers/{idOrEmail} marketing:read
POST /api/v1/marketing/subscribers marketing:write
POST /api/v1/marketing/subscribers/{id}/unsubscribe marketing:write
GET /api/v1/shop/products shop:read
GET /api/v1/shop/products/{idOrSlug} shop:read
GET /api/v1/shop/orders shop:read
GET /api/v1/shop/orders/{idOrNumber} shop:read
POST /api/v1/shop/orders/{idOrNumber}/fulfill shop:write
GET /api/v1/booking/appointments booking:read
GET /api/v1/booking/appointments/{id} booking:read
POST /api/v1/booking/appointments/{id}/cancel booking:write
POST /api/v1/booking/appointments/{id}/complete booking:write
POST /api/v1/booking/appointments/{id}/no-show booking:write
GET /api/v1/invoicing/invoices invoicing:read
GET /api/v1/invoicing/invoices/{idOrNumber} invoicing:read
POST /api/v1/invoicing/invoices/{idOrNumber}/payments invoicing:write
GET /api/v1/donations donations:read
GET /api/v1/donations/{id} donations:read
GET /api/v1/ticketing/orders ticketing:read
GET /api/v1/ticketing/orders/{id} ticketing:read
GET /api/v1/ticketing/tickets ticketing:read
GET /api/v1/ticketing/tickets/{idOrCode} ticketing:read
POST /api/v1/ticketing/tickets/{idOrCode}/check-in ticketing:write
GET /api/v1/support/tickets support:read
GET /api/v1/support/tickets/{id} support:read
POST /api/v1/support/tickets/{id}/replies support:write
POST /api/v1/support/tickets/{id}/status support:write
POST /api/v1/support/tickets/{id}/assign support:write
GET /api/v1/projects projects:read
GET /api/v1/projects/{id} projects:read
POST /api/v1/projects/{id}/updates projects:write
POST /api/v1/projects/{id}/milestones/{milestoneId} projects:write
POST /api/v1/projects/{id}/completion projects:write
GET /api/v1/reviews reviews:read
GET /api/v1/reviews/{id} reviews:read
POST /api/v1/reviews/{id}/approve reviews:write
POST /api/v1/reviews/{id}/hide reviews:write
GET /api/v1/directory/listings directory:read
GET /api/v1/directory/listings/{idOrSlug} directory:read
POST /api/v1/directory/listings/{idOrSlug}/status directory:write
GET /api/v1/real-estate/listings real_estate:read
GET /api/v1/real-estate/listings/{idOrSlug} real_estate:read
GET /api/v1/analytics/{summary,timeseries,pages,referrers,sources,conversions} analytics:read
GET /api/v1/webhooks webhooks:manage
POST /api/v1/webhooks webhooks:manage
DELETE /api/v1/webhooks/{id} webhooks:manage
GET /api/v1/openapi.json (no token — feature-gated only)

Feature-dependent groups are double-gated: CRM endpoints 404 unless the CRM feature is on, marketing endpoints 404 unless Marketing is on, shop endpoints 404 unless E-Commerce is on, booking endpoints 404 unless Online Booking is on, invoicing endpoints unless Client Invoicing is on, donation endpoints unless Donations is on, ticketing endpoints unless Events is on (event ticketing is folded into it), support endpoints unless the Ticket System is on, project endpoints unless Project Tracking is on, review endpoints unless Reviews is on, directory endpoints unless Directory is on, property endpoints unless Real Estate is on, analytics endpoints unless Analytics is on — a disabled feature never leaks so much as a 403 hint.

Two naming collisions are worth knowing about, because both modules use the word "ticket": /ticketing/* is event ticketing (orders and attendee tickets), while /support/tickets is the help desk. Likewise /projects is the Project Tracking module's operational client jobs, whereas the projects portfolio content type lives at /content-items?type=projects.

List endpoints paginate (per_page up to 100, page) and return the standard data / links / meta envelope. Content-item lists filter by type, status (published by default; draft, scheduled, unpublished, or all), and search; owned child items (like repeating-event occurrences) are hidden unless you pass include_owned=1. Media lists filter by search and category. Submission lists exclude quarantined spam unless you pass include_spam=1. Product lists filter by status (published by default), search, and category; order lists filter by status (including the computed abandoned view the dashboard shows), email, and since. Contact lists filter by search, exact email, and lifecycle. Subscriber lists filter by status (active, pending, unsubscribed, or all), search, and exact email. Appointment lists filter by status (including the computed upcoming view), email, service_id, staff_id, and a from/to window over the appointment's start time. Invoice lists filter by status (including the derived outstanding and overdue views), type, client_id, email, and since. Donation lists filter by status, frequency (recurring spans monthly + yearly), email, campaign_id, and since. Ticket-order lists filter by status, event_id, email, and since; attendee-ticket lists by status, event_id, order_id, and purchaser email. Support-ticket lists filter by status (including the awaiting_staff needs-attention lens), priority, email, assigned_to, category_id, and since, and exclude quarantined spam unless you pass include_spam=1. Project lists filter by status (including open and overdue), email, client_id, and since. Review lists filter by status (public, pending, hidden), provider, min_rating/max_rating, and since. Directory lists filter by status (including the live view), category, featured, search, and member_id. Property lists filter by status, city/state/postal_code, property_type, min_price/max_price, min_beds/min_baths, agent_mls_id, and since.

Money and dates on the wire. Every amount is an integer in minor units on a *_cents key, alongside the record's own lowercase ISO currency — no formatted strings, because how to display a total is the client's decision. Timestamps are UTC ISO-8601. Deliberate exceptions, all because the underlying value is not an instant: calendar dates (an invoice's issue_date / due_date, a project's started_at / due_at, a property's on_market_date) are plain YYYY-MM-DD; a ticket order's occurrence_start — the occurrence of a repeating event the buyer picked — is the raw local wall-clock string it was chosen as; and MLS prices are whole currency units, not cents (list_price: 750000 is $750,000), because that is what the feed supplies and rounding it into minor units would invent precision the source never had. An appointment's starts_at is a true UTC instant; render it in the accompanying timezone to get the customer's wall clock.

Update endpoints accept PATCH and PUT interchangeably — both have partial-update semantics.

Content-type responses include the full field schema, so an integration can discover what a type's items look like before writing any. GET /content-types/{slug}/terms returns the type's taxonomy vocabulary (its categories, tags, and any other taxonomy fields) so a consumer can build filter UIs and resolve the term ids that appear on items. Form responses likewise include the submit-relevant field schema — key, type, required flag, and allowed option values.

A single content item can be fetched by numeric id, or by slug plus a type query param (slugs are only unique within a type). Products resolve by id or slug; orders by internal id or the customer-facing order number; subscribers by id or email; invoices by id or the customer-facing number (INV-0007 / EST-0003); attendee tickets by id or the printed door code.

Putting an image on a page

Uploading through POST /api/v1/media gets a file into the library. The page endpoints are what make it appear on the site, so an integration can run the whole loop — render, upload, place — without a dashboard session or shell access.

A page image is not a column on a page; it's an override row keyed by the row's slug and its image field. So the flow is always discover, then write, and the discovery step is not optional — row slugs carry a random suffix (hero-photo-bg:A48mtOh) and cannot be guessed.

# 1. Which pages exist?
curl -s https://example.com/api/v1/pages -H "Authorization: Bearer wpcms_..."

# 2. Which image slots are on one of them, and what size fills each?
curl -s "https://example.com/api/v1/pages/images?page=home" -H "Authorization: Bearer wpcms_..."

# 3. Upload the file...
curl -s https://example.com/api/v1/media -H "Authorization: Bearer wpcms_..." \
  -F [email protected] -F alt="Crew installing a new panel" -F category=pages

# 4. ...and point the slot at it.
curl -s -X PUT https://example.com/api/v1/pages/images -H "Authorization: Bearer wpcms_..." \
  -H "Content-Type: application/json" \
  -d '{"page":"home","row_slug":"hero-photo-bg:A48mtOh","key":"background_image","media_id":42}'

Things worth knowing before you script it:

  • recommended tells you what to render. Each spot reports {width, aspect, openai_size} read from that slot's own widths and aspect-* declarations — the same source of truth the public srcset uses. Generating against it means the image fills the slot instead of being cropped or upscaled. A null means the slot declares neither; crop generously. Note that uploads are capped at 2400px wide, so asking for more is wasted.
  • A shared: true spot renders on more than one page. Writing to it changes all of them at once — which is how a site-wide page-title banner is set from a single call. The write response repeats the flag so an integration can report it.
  • The write does the housekeeping. It recompiles the page's data sidecar and evicts the affected cached pages, including every page embedding a shared row. The change is live immediately; nothing else has to be run.
  • media_id: null clears a slot back to whatever the row's own default is.
  • Alt text follows the media item unless you pass alt explicitly, and is only written when the row declares a companion alt field (alt_key in the spot listing says whether it does; alt_written in the response says whether it happened).
  • Per-record images are a different thing. A page listed as parameterized: true is a template rendering many records — a blog post, a service, a property. Its artwork is the record's own featured image, set with featured_media_id on POST/PATCH /api/v1/content-items, not with a page image.

Reading content items

Each item returns its core attributes (id, type, title, slug, excerpt, status, published_at), its public detail URL when the type has one, the featured image URL, SEO meta fields, a fields object holding every custom field value the type defines, and a terms object with its assigned taxonomy terms resolved to {id, name, slug} and grouped by taxonomy field.

Writing content items

Create with a type and a data object of field values; optionally set slug, excerpt, status (draft by default), published_at, meta_title, meta_description, and featured_media_id:

curl -X POST https://example.com/api/v1/content-items \
  -H "Authorization: Bearer wpcms_..." \
  -H "Content-Type: application/json" \
  -d '{"type": "services", "status": "published", "data": {"title": "Gutter cleaning", "body": "<p>We clean gutters.</p>"}}'

Writes behave like a dashboard save, not a raw database insert:

  • Per-field validation uses the same rules as the dashboard's content forms — required fields, boolean toggles, composite address/hours/date fields — plus API-specific guards the dashboard enforces visually: select/radio/checkbox values must match the field's declared options, and image/gallery values must be path strings/arrays.
  • The title and slug derive automatically from the first text field in data (matching how the dashboard works); pass an explicit slug to override, with per-type uniqueness enforced.
  • PATCH merges: send only the fields you're changing — everything else keeps its current value, and only the keys you send are validated.
  • Unknown keys are dropped: payloads can't smuggle values outside the type's declared field schema.
  • Side effects fire normally: search indexing, page-cache invalidation, relation/taxonomy pivot syncing, and repeating-event child generation all run exactly as if the item were saved in the dashboard.
  • The featured image is featured_media_id, a media-library id — the companion to POST /api/v1/media, and the artwork that renders on the item's own detail page and in every listing card. On PATCH, omitting the key leaves the current image alone and sending null clears it, so a partial update of the metas can't silently drop it. Responses report both featured_media_id (what you send back to change it) and featured_image (the URL, for rendering).
  • Publishing an item without an explicit published_at stamps it with the current time.

Submitting forms

POST /api/v1/forms/{id}/submissions with a data object keyed by field key runs the form's full downstream pipeline — the submission is stored (when the form keeps submissions), the notification email goes out, and the CRM captures the contact — exactly as if a visitor had submitted the public form. What it skips: the spam gauntlet (the token already authenticates the caller) and analytics conversion tracking (an API call isn't a site visitor).

Payloads are validated against the form's field schema: required fields are enforced, email fields must be valid, and select/radio/checkbox-group values must match the declared options. File fields aren't supported over the API and are ignored. Forms with submission storage turned off return 202 (processed, nothing stored) instead of 201.

CRM contacts

POST /api/v1/crm/contacts is an upsert by email with the same semantics as every other capture path in the CMS: a new email creates a contact (201); an existing one matches it (200) and only fills fields that are currently blank — operator-entered data is never overwritten. Company names resolve to CRM company records automatically, and auto-assignment rules (default owner / round-robin) apply to new contacts.

PATCH /api/v1/crm/contacts/{id} is a direct update — sent keys overwrite. Email is deliberately immutable (it's the upsert identity key). There is no delete endpoint; contact deletion stays a dashboard operation.

Marketing subscribers

POST /api/v1/marketing/subscribers is the "add subscriber" integration action — an upsert by email with the same fill-blanks-only semantics as CRM contacts. New subscribers are created confirmed and active (double opt-in is a public-signup-form concern; an API caller is a trusted integration, like a dashboard add). An existing unsubscribed subscriber is deliberately not re-activated by an upsert — silent re-subscription is a compliance hazard — unless the payload explicitly sends resubscribe: true.

POST /api/v1/marketing/subscribers/{id}/unsubscribe unsubscribes. There is no delete endpoint: an unsubscribed row is also a suppression record, and deleting it would let a later import silently re-add the address.

New subscribers fire the subscriber.created webhook event and unsubscribes fire subscriber.unsubscribed (see Webhooks) — regardless of whether they arrived via the API, the public signup form, or a dashboard import.

Fulfilling orders

POST /api/v1/shop/orders/{idOrNumber}/fulfill is the shipping/3PL write: it marks a paid order fulfilled (anything else gets a 422), records optional carrier, tracking_number, and tracking_url (derived automatically from carrier + number for known carriers when omitted), and sends the customer's shipped email unless send_email: false. The status change fires the order.status_changed webhook event, exactly like a dashboard fulfillment. Refunds and cancellations stay dashboard operations — they move money, so they keep a human in the loop.

Appointments

GET /api/v1/booking/appointments is the read side of Online Booking: every appointment with its customer, service and staff snapshots, its start and end as UTC instants plus the visitor's timezone, its money (price, deposit, discount, amount paid, and the balance_due_cents still owed in person on a deposit booking), the intake answers, and the customer's own manage_url.

There is deliberately no create endpoint. Booking a slot runs the availability engine and its locking, mints a Stripe session for paid services, sends the confirmation emails and pushes the event to any synced calendar — the public booking page owns that flow, and a second implementation of it would drift out of step with the first.

The three writes offered are the ones the dashboard exposes as one-click status changes:

  • POST .../cancel sends the cancellation email, records the cancellation on the customer's CRM timeline, and removes the event from synced calendars. It is idempotent, and it never refunds — the dashboard makes refunding a separate, explicit checkbox, and moving money on an unattended API call isn't a decision to take silently. Fires booking.cancelled.
  • POST .../complete and POST .../no-show only act on confirmed appointments (422 otherwise). A no-show also lands on the customer's CRM timeline, which is what makes repeat offenders visible when the team next books them.

Invoices and estimates

GET /api/v1/invoicing/invoices covers Client Invoicing. Invoices and estimates share one collection — filter with type. Alongside the stored status, each row carries the display_status the dashboard shows: overdue, partial and accepted are derived on read rather than swept into the table when a due date passes. Fetching a single invoice inlines its line items and its recorded payments.

POST /api/v1/invoicing/invoices/{idOrNumber}/payments records a payment that arrived outside Stripe — a cheque, a bank transfer, cash. It runs through the same single path a dashboard entry does, so the balance recalculates, the invoice flips to paid when it clears, the receipt goes out, and the invoice.paid webhook fires. method is restricted to the offline set (cash, check, bank_transfer, other): an integration can't forge a stripe payment record. Estimates and void invoices are rejected (422), and a bare paid_at date is read in the site's timezone, not UTC.

Creating or sending an invoice stays a dashboard operation — numbering, line items, the deposit percent and the signed-contract gate all live in that flow, and a half-built invoice reaching a client is worse than no endpoint.

Donations

GET /api/v1/donations is read-only. Each gift carries its amount, the fee_cover_cents the donor added to absorb processing fees, its designation and tribute, a nested donor object with the full mailing address, and its campaign and peer-to-peer fundraiser when it has them.

There is no write side: every donation is a settled Stripe charge, so there's no non-payment path that could legitimately create one, and refunding moves money. To react as a gift lands, subscribe to the donation.received webhook rather than polling this list.

Event tickets and attendees

GET /api/v1/ticketing/orders lists ticket orders with their event snapshot, totals, promo code and ticket count; fetching one inlines every issued ticket. GET /api/v1/ticketing/tickets is the attendee list — filterable by event, order, or purchaser email — and a single ticket resolves by internal id or by the printed door code (case-insensitive, so a scanner can hand back whatever it read).

POST /api/v1/ticketing/tickets/{idOrCode}/check-in is the door write, built for a third-party scanner app. The claim is atomic: two scanners racing on the same code produce exactly one check-in, and the loser gets a 200 with meta.already_checked_in: true rather than an error — so the app can show "already scanned at 7:42 PM" instead of a failure. A voided ticket is rejected (422). checked_in_by stays null on an API check-in, since a token is an integration rather than a person.

Selling a ticket stays with the public checkout, which holds capacity, validates the event's promo codes, mints the Stripe session and issues the QR codes.

Support tickets

GET /api/v1/support/tickets is the help-desk mirror: tickets with their category, assignee, CSAT rating and message count, filterable down to the awaiting_staff set the Unified Inbox treats as needing attention. Quarantined spam is excluded unless you ask for it. Fetching one ticket returns the full thread — internal notes included and flagged with is_internal, because the token is staff-side and a mirror that silently dropped them would lose half the conversation.

POST .../replies posts a staff reply through the same path the dashboard uses: a public reply emails the requester, hands the ticket back to them (pending), stamps the first-response clock and clears the stale-customer nudge. Send internal: true for a note instead — no email, no status change, and it doesn't count as your first response. The message shows in the thread as "Support team", since a token is an integration rather than a person. Replying to a closed ticket is rejected (422) — reopen it first.

POST .../status moves the ticket; resolving for the first time also sends the satisfaction-rating email when CSAT is on, and closing fires ticket.closed. POST .../assign assigns (emailing the new assignee) or unassigns with user_id: null.

Opening a ticket over the API isn't offered — the public support form owns that path, with its spam gauntlet, attachments and owner notification. POST /forms/{id}/submissions already covers filing something on a visitor's behalf.

Projects

GET /api/v1/projects covers the Project Tracking module — operational client jobs, not the projects portfolio content type. Each carries its milestones and a progress_percent that is milestone-driven when milestones exist, the manual progress field otherwise, and always 100 once completed. The email filter matches either side of the client link — the project's own client_email or the linked invoicing client's — because a project can be created with a bare email and no client record; it's the same both-ways match the member portal uses, so the two can't disagree.

The writes are the field-crew workflow: POST .../updates posts a progress note (is_public, default true, decides whether the client sees it on their tracking page), POST .../milestones/{milestoneId} ticks or unticks a milestone — which is what moves the client-facing progress bar — and POST .../completion marks the job done. Completion goes through the module's own completion path, so it logs to the client's CRM timeline, enrolls them in the configured marketing sequence and fires project.completed; those follow-ups are the whole point of marking a job complete. Send completed: false to reopen. Both directions are idempotent, and a milestone belonging to another project is a 404.

Reviews

GET /api/v1/reviews returns collected reviews from every connected platform plus first-party submissions. Two independent flags decide visibility — is_visible (the owner's show/hide) and pending (the first-party moderation queue) — so the resource also reports is_public, which is what actually renders on the site. Lists are ordered by when the review was left, not when it synced, so importing a backlog can't bury new feedback.

POST .../approve clears a review out of the moderation queue and awards the loyalty review bonus to a matching member exactly once. POST .../hide takes one off the public site but never deletes it — the row stays so a re-sync can't silently resurrect it and the rating history stays intact.

Replying is deliberately not available over the API. A reply to a Google Business review publishes to Google itself, and that logic lives in the reviews dashboard rather than in a shared action class — an API reply would be a second copy of a third-party publish call. If you need it, the fix is to extract the dashboard's reply path first and route both through it.

Directory listings

GET /api/v1/directory/listings returns listings with their categories, logo/cover URLs and full address. The status filter distinguishes the stored status from the effective one: published matches the column, while live means published and unexpired — a listing can lapse past expires_at and stop rendering without its status ever changing.

POST .../status is the moderation write: approve a public submission (published), take one down (archived), or send it back to the queue (pending), optionally setting featured in the same call. It clears the public page cache, so an approval is visible immediately rather than whenever the cache ages out. Creating and editing listings stay with the public submit form and the dashboard — a listing is a page's worth of fields, and a partial API write is a good way to blank a member's profile by accident.

Property listings (MLS)

GET /api/v1/real-estate/listings exposes synced MLS inventory with the fields an integration actually joins and filters on — listing_key, status, price, beds/baths, address, coordinates, agent and office MLS ids, and photo URLs resolved the same way the public pages resolve them (locally cached copies where the photo cache holds them, feed CDN links for the rest).

It defaults to status=active rather than all, because the table keeps years of sold inventory and an unfiltered first page of that is never what "the listings" means. since filters on the feed's modification stamp rather than ours, so an incremental sync can ask "what changed since my last pull". A single property resolves by internal id, URL slug, or MLS listing key.

This surface is read-only by design. These rows are owned by the feed synchronizer, so anything written here would be overwritten at the next sync — an endpoint that looked like it worked and silently lost the change is worse than no endpoint. There is no real_estate:write ability to grant.

Media

POST /api/v1/media accepts a multipart body with file (jpg, jpeg, png, gif, webp, svg, avif, mp4, webm, mov — 100 MB cap), optional alt, and optional category (slug). Uploads run the exact pipeline dashboard uploads do: SVGs are sanitised (script-capable markup is scrubbed or the upload is rejected), oversized images are capped at 2400px wide, decompression bombs are rejected at the door, and files land in the same year/month storage layout — an API-uploaded item is indistinguishable from a dashboard one.

PATCH /api/v1/media/{id} edits metadata — alt, caption, and category. The file itself is immutable through the API (replacing a file warns about site-wide usage, which is a dashboard concern); updating metadata refreshes the URL's cache-buster automatically. DELETE /api/v1/media/{id} removes the file, its resized variants, and every reference to it across the site — the same cleanup as a dashboard delete.

Outbound webhooks (REST hooks)

Outbound webhooks are their own feature — see Webhooks for events, delivery format, signing, and retries. What the API adds is programmatic subscription management via the REST-hooks style POST /api/v1/webhooks / DELETE /api/v1/webhooks/{id} pair (the pattern Zapier-style platforms use to register their triggers). These endpoints manage the same webhook rows as Settings → Webhooks and require both features: they 404 when either the API feature or the Webhooks feature is off. The create response is the only place the signing secret appears.

OpenAPI spec and generated docs

GET /api/v1/openapi.json serves the complete OpenAPI 3.1 document — importable into Postman, Insomnia, code generators, or an AI agent's toolbox. It's served without a token (it describes the endpoints, not your data) but still 404s when the feature is off.

The dashboard's API documentation page renders from the very same document — every endpoint with its method, path, required ability, query parameters, and body fields, plus the webhook event catalog and signature-verification instructions. Adding an endpoint to the spec updates both automatically.

Errors

All errors are JSON: 401 unauthenticated, 403 missing ability, 404 not found ({"message": "Not Found."}), 422 validation failures (standard Laravel message + errors shape), 429 rate limited.

Security notes

  • Create one token per integration and grant the smallest ability set that works — revoking one integration then never disturbs another.
  • Tokens are hashed at rest (SHA-256); the plaintext exists only in the one-time reveal at creation.
  • Webhook endpoints must be HTTPS; signing secrets let receivers reject forged deliveries (see Webhooks).
  • Token and webhook management lives behind the admin role; managers and editors can't see or mint either.