ChatCMM API Reference

OpenAPI 3.1 JSON
REST API
GET /api/api-keys — List API keys

Session only (Dashboard). Returns all API keys for the authenticated user. Keys are shown with prefix only — the full key is never returned after creation.

POST /api/api-keys — Create API key

Session only (Dashboard). Creates a new API key. The full key (`plainKey`) is returned only in this response — store it securely.

GET /api/api-keys/{id} — Get API key details

Session only (Dashboard).

PATCH /api/api-keys/{id} — Update or revoke API key

Session only (Dashboard).

DELETE /api/api-keys/{id} — Delete API key

Session only (Dashboard). Permanently deletes an API key. This action cannot be undone.

POST /api/api-keys/verify — Verify API key

Check whether an API key is valid. Send the key via the Authorization header.

GET /api/content — List content

Returns blog posts and articles. Filtered by organization and user role automatically.

POST /api/content — Create content

Creates a new blog post or article. The content starts as a draft.

GET /api/content/{id} — Get content by ID
PATCH /api/content/{id} — Update content
DELETE /api/content/{id} — Delete content

Soft-deletes the content. Optionally deletes from WordPress too.

POST /api/content/{id}/generate — Generate AI content

Uses AI to generate the blog post body based on the content's prompt, keywords, and site context. Consumes AI credits.

POST /api/content/{id}/publish — Publish to WordPress

Publishes the content to the connected WordPress site. Creates or updates the WordPress post.

POST /api/content/{id}/unpublish — Unpublish from WordPress

Removes the post from WordPress and resets the content status to draft.

POST /api/content/{id}/featured-image — Generate featured image

Generates an AI featured image based on the content's image prompt. Optionally uploads it to WordPress.

GET /api/content/{id}/changes — Change history

Change history of a content item during the customer approval process, newest first.

POST /api/content/{id}/generate-image — Generate featured image

Generates an AI featured image, stores it in the media library and sets it on the content. Optionally uploads it to the WordPress target. Billable (credits or an included revision).

POST /api/content/{id}/image-prompt-preview — Preview image prompt

Returns the suggested image prompt and the credit cost of the next image generation. No side effects.

GET /api/content/{id}/links — List in-text links
POST /api/content/{id}/links — Replace in-text links

Replaces all keyword links of the content (at most 3).

DELETE /api/content/{id}/links — Delete all in-text links
POST /api/content/{id}/publish-async — Queue publish

Schedules the content for immediate publishing and hands it to the background job. The result shows in the content status. Already published content is updated via `POST /api/content/{id}/publish`.

POST /api/content/{id}/reconcile-publish — Resolve a stuck publish

Checks a content item stuck in `publishing` read-only against WordPress and either finalizes it as published or releases it to draft.

POST /api/content/{id}/request-publication — Request marketplace publication

Creates a marketplace channel booking from the draft's marketplace target and submits it for publisher review. Idempotent: an existing booking is returned with `alreadyRequested: true` and status 200.

POST /api/content/{id}/restore-published-url — Restore published URL

Reads the published post back from WordPress to restore a missing public URL.

POST /api/content/{id}/upload-image — Upload featured image

Uploads a custom featured image (JPEG, PNG, GIF or WebP, at most 10 MB) and sets it on the content.

POST /api/content/generate — Generate free-standing content

Generates AI text for a prompt, or topic suggestions for a website when `websiteUrl` and `websiteName` are sent (then the suggestions branch wins). Not bound to a content item.

POST /api/content/generate-keywords — Suggest SEO keywords

AI keyword suggestions for a title and optional HTML content. Billable.

POST /api/content/generate-seo-meta — Generate SEO meta

AI meta title (at most 60 characters) and meta description (at most 160 characters). Billable.

POST /api/content/generate-suggestions — Suggest title, prompt and keywords

AI suggestions for a new article, with a hint when the topic conflicts with existing content. Billable.

GET /api/author-profiles — List author profiles

Returns public author identities for the active organization. Requires `content:read`.

POST /api/author-profiles — Create author profile

Creates an organization-owned public author identity. Requires `content:write`.

GET /api/author-profiles/{id} — Get author profile
PATCH /api/author-profiles/{id} — Update author profile
DELETE /api/author-profiles/{id} — Archive author profile
GET /api/wordpress-sites — List WordPress sites

Returns all connected WordPress sites for the authenticated user or organization.

POST /api/wordpress-sites — Connect WordPress site

Connects a WordPress site using an Application Password. Format for apiToken: `username:applicationpassword`.

GET /api/wordpress-sites/{id} — Get WordPress site

Site details incl. `maxPostsPerDay`; the API token stays redacted. With an API key only sites of the key's tenant (no admin cross-tenant access, no deleted sites); foreign, missing and malformed ids return 404 `not_found`.

PATCH /api/wordpress-sites/{id} — Update WordPress site

With an API key (`sites:write` plus RBAC `wordpress.sites.edit`) the body may ONLY contain `maxPostsPerDay` (positive integer or null = unlimited); any other field gives 400 `unknown_field`. Programme-bound sites give 409 `program_managed`; a site that is today's target of an active one-shot draft gives 409 `site_target_locked`. The dashboard session may change all fields listed below.

DELETE /api/wordpress-sites/{id} — Delete WordPress site

Session only (Dashboard). Soft-deletes the site. The site can be restored by an admin.

GET /api/wordpress-sites/daily-capacity — Get today's daily capacity of a site

Read-only, reserves nothing. With an API key: today's occupancy (Europe/Vienna calendar day, WordPress posts plus CMM reservations) of one site of the key's tenant, addressed by `siteId` (required); deleted, external and foreign sites give 404 `not_found`. The dashboard session may also pass a `channelId` and receives only a localized `{ warning }`.

POST /api/wordpress-sites/auto-connect — Auto-connect WordPress site

Session only (Dashboard). Automatically creates an Application Password via the WordPress REST API. The admin password is used once and never stored.

GET /api/wordpress-sites/{id}/categories — List site categories

Stored categories of the site. With `sync=true` they are first synced from WordPress; that needs site access and the `sites:write` scope (plain reads need `sites:read`; marketplace listings are readable without site access). If the sync fails but categories are stored, those are returned with 200.

POST /api/wordpress-sites/{id}/categories — Create local category

Creates a category record for the site in ChatCMM only; it is not pushed to WordPress. Requires the site edit permission.

POST /api/wordpress-sites/suggest — Suggest sites for an article

Up to 5 accessible sites with a selectable publishing channel, ranked for the article (keyword prefilter, then AI ranking). Billable by token usage after a successful AI ranking; if the AI ranking fails, a keyword-only result is returned without charge.

GET /api/site-groups — List site groups

Lists the caller's site groups with their member sites. Requires the RBAC permission `wordpress.sites.view`. API keys receive the allow-listed `SiteGroup` view; only sites of the key's tenant are listed as members. With an API key the group must belong to the key's organization (without an organization: to the key owner, `orgId IS NULL`). Foreign, missing and malformed ids all return an identical 404 `not_found` (no existence oracle). The dashboard session keeps its own, wider ownership rule and its unchanged response shape.

POST /api/site-groups — Create site group

Creates a group, optionally with member sites. Requires `wordpress.sites.create`. Every `siteIds` entry must be a non-deleted site of the key's tenant, otherwise 400 `site_not_owned` and nothing is created. The group can then be used as `channelFilter.groupId` (with `mode = "group"`) in `PATCH /api/campaigns/{id}/recurring`; that endpoint checks the group and its member sites with the same strict tenant rule (#1168). With an API key the group must belong to the key's organization (without an organization: to the key owner, `orgId IS NULL`). Foreign, missing and malformed ids all return an identical 404 `not_found` (no existence oracle). The dashboard session keeps its own, wider ownership rule and its unchanged response shape.

PATCH /api/site-groups/{id} — Update site group

Renames the group, changes its description and/or replaces its members (`siteIds`, atomic). Replaces the members visible to the key; members outside the key's tenant stay unchanged. Requires `wordpress.sites.edit`. With an API key the group must belong to the key's organization (without an organization: to the key owner, `orgId IS NULL`). Foreign, missing and malformed ids all return an identical 404 `not_found` (no existence oracle). The dashboard session keeps its own, wider ownership rule and its unchanged response shape.

DELETE /api/site-groups/{id} — Delete site group

Deletes the group; its memberships are removed with it (the sites stay). Requires `wordpress.sites.delete`. A recurring campaign that still points at the group via `channelFilter.groupId` is not changed here and fails its next check until a new group is set. With an API key the group must belong to the key's organization (without an organization: to the key owner, `orgId IS NULL`). Foreign, missing and malformed ids all return an identical 404 `not_found` (no existence oracle). The dashboard session keeps its own, wider ownership rule and its unchanged response shape.

GET /api/models — List AI models

Lists the text and image models of the AI Gateway catalog (public catalog data, no tenant data). Use `gatewayId` as `channelFilter.generationModels.text` / `.image` in `PATCH /api/campaigns/{id}/recurring` (a `text` model for `text`, an `image` model for `image`). With an API key `type` and `grouped` are validated strictly (400 `invalid_input`) and an unavailable or empty catalog answers 503 `model_catalog_unavailable`. The dashboard session receives the legacy response without `gatewayId` and keeps its lenient query parsing.

GET /api/campaigns — List campaigns

Returns campaigns with article counts and cost summaries. API keys receive the allow-listed view (`PublicCampaignListItem` / `PublicCampaignArticle`): no approval tokens, agent log or conversation, and `channelFilter` only with its editable fields. The dashboard session receives an undocumented superset.

POST /api/campaigns — Create campaign

Creates a new campaign with articles. Each article maps to a channel or own WordPress site.

GET /api/campaigns/{id} — Get campaign with articles

API keys receive the allow-listed view: `channelFilter` only with its editable fields, and approval tokens, agent log and server-owned fields are never returned. The dashboard session receives an undocumented superset; with a session and an API key at the same time, the session applies. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

PATCH /api/campaigns/{id} — Update campaign

Updates the plain campaign fields. Recurring settings are changed only via `/api/campaigns/{id}/recurring`, `/recurring/activate` and `/recurring/pause` (an API key sending recurring keys gets 400 `unknown_field`; the dashboard session additionally accepts the `RecurringConfigPatch` keys plus `isRecurring`). `status` is not settable (booking workflow); `summary` and `campaignLinks` are not settable via PATCH. Setting `customerOrganizationId`, `primaryContactUserId`, `campaignApprovalMode` or `campaignApprovalMinCount` can lift the customer approval block and therefore additionally requires the key scope `content:publish` and the RBAC permission `content.publish`. An API key has no admin bypass for the customer organization (`customer_org_not_owned`). Campaigns managed by an automation program return 409 `program_managed`. The response is the flat `PublicCampaign` view. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

DELETE /api/campaigns/{id} — Delete campaign

Moves the campaign to the trash (soft delete). A second DELETE on a trashed campaign returns 404. Permanent deletion is possible only for admins in the browser session. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

GET /api/campaigns/{id}/links — Get campaign-wide links

Returns the campaign-wide keyword links (the fallback for every generated article without its own article links). Legacy data with more than `maxLinks` entries is returned unchanged. An unknown stored `linkType` is returned as `dofollow`. `updatedAt` also changes when a recurring run claims a topic or remembers one. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

PUT /api/campaigns/{id}/links — Replace campaign-wide links

Replaces the whole list (an empty list removes the links). At most 10 links, `http`/`https` only without credentials, no duplicate URL (compared as `URL.href`) and no duplicate anchor text (case-insensitive); a violation returns 400 `invalid_input` with `details.reason = duplicate_link` and the `index`. `linkType` is a hint stored with the link; today only the mock generation path renders it as `rel`. Unknown fields return 400 `unknown_field`. `expectedUpdatedAt` is optional: after every recurring run the token is stale (409 `conflict_stale`), so re-read first. For a RECURRING campaign (`isRecurring` or a recurrence pattern set) the links flow into every automatically generated article, so an API key additionally needs the scope `content:publish` (otherwise 403 `forbidden_scope`) and the RBAC permission `content.publish` (403 `forbidden_permission`). Campaigns managed by an automation program return 409 `program_managed`; an active one-shot draft returns 409 `one_shot_active`. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

GET /api/campaigns/{id}/roadmap — List open roadmap topics

Returns the open roadmap topics of a recurring campaign (`status = planned`, no content, no booking) in the order the generator draws them (scheduled date, series order, id), at most 500; `truncated` is true when more exist. `eligible` and `blockedReason` mirror the worker candidate filter exactly (no normalisation): `locked`, `content_in_progress`, `already_generated_topic`, `missing_category` (daily pilot and draft upload need a category per topic) and `site_fit` (only with exactly one fixed target site: language mismatch, excluded topic or editorial site, see `siteFitReason`). The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

POST /api/campaigns/{id}/roadmap — Add roadmap topics

Adds 1 to 52 topics to a recurring campaign without AI and without credits. New topics are unlocked, so the generator draws them from the next cycle on. Duplicates (case-insensitive, whitespace and Unicode normalised) against open and generated topics, or within the request, are returned in `skipped`; cancelled topics do not block. At most 365 open topics per campaign (400 `invalid_input`, `details.reason = roadmap_full`, nothing written). A category needs exactly one fixed target site (`channelFilter.mode = list` with one site) and must belong to that site and be synced to WordPress (400 `category_invalid`, `details.reason` one of `no_single_target_site`, `category_not_found`, `category_not_synced`, `site_not_in_tenant`, `site_unavailable`, `category_required`); a foreign and a non-existent category are indistinguishable. The call is rejected with 409 `roadmap_cycle_in_progress` while a cycle is due (within 30 minutes after the planned time) or running (latest claim younger than 30 minutes without a booking), so a new topic never skips the due cycle. A non-recurring campaign returns 400 (`details.reason = campaign_not_recurring`). Requires the `content.publish` permission and, with an API key, BOTH scopes `campaigns:write` and `content:publish`. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

PATCH /api/campaigns/{id}/roadmap/{articleId} — Change a roadmap topic

Changes `topic`, `keywords`, `scheduledDate` (ordering hint only: the generator overwrites it on booking) or `wordPressCategoryId` of a planned topic. At least one field; `expectedUpdatedAt` is optional. Only topics without a site or channel can be changed (409 `roadmap_topic_not_planned`, `details.reason = site_bound`; edit those in the dashboard). A topic that is booked, generated, generating or reserved for a cycle returns 409 `roadmap_topic_not_planned` (`not_planned`, `content_in_progress`, `reserved_for_cycle`). A title that already exists returns 400 `invalid_input` with `details.reason = duplicate_topic`. The category rules and the cycle guard of `POST` apply. Requires the `content.publish` permission and, with an API key, BOTH scopes `campaigns:write` and `content:publish`. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

DELETE /api/campaigns/{id}/roadmap/{articleId} — Cancel a roadmap topic

Soft delete: sets the topic to `cancelled`. A cancelled topic is no longer drawn, does not use site capacity and does not block a new topic with the same title. Cancelling an already cancelled topic is idempotent (`changed: false`). Cancelling the last candidate with a category of a daily pilot pauses the campaign at its next run (intended). The request must have no body. Requires the `content.publish` permission. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

GET /api/campaigns/{id}/recurring — Get recurring configuration

Returns the editable recurring configuration as an allow-listed view. The stored `channelFilter` is never returned raw: server-owned fields are not readable. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

PATCH /api/campaigns/{id}/recurring — Update recurring configuration

Partial update of the recurring configuration. State and configuration are separate: `isRecurring` is NOT accepted (use `/recurring/activate` and `/recurring/pause`). Unknown fields are rejected with 400 `unknown_field`. `channelFilter` is deep-merged over the stored value; `null` clears `publicationMode`, `generationModels` and `contentLength`. `expectedUpdatedAt` is optional; if it differs from the stored `updatedAt` the call returns 409 `conflict_stale`. `updatedAt` has second granularity: a change made by the dashboard in the same second as your read is not detected. `nextGenerationAt` is recomputed only on a real schedule change (pattern, day, time, pauseOnHolidays, effective time zone); sending identical values changes nothing. Without `channelFilter.timeZone` the server time zone (UTC) applies. Requires the `content.publish` permission. With an API key the key needs BOTH scopes `campaigns:write` and `content:publish` (otherwise 403 `forbidden_scope` and nothing is written), because this call can enable automatic WordPress publishing. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

POST /api/campaigns/{id}/recurring/activate — Activate recurring

Activates (or resumes) the recurring automation. Idempotent: an already active campaign with a scheduled run is left unchanged (`changed: false`). The next run is computed once on activation (next occurrence after now). Requires a configured `recurrencePattern`. With an API key the key needs BOTH scopes `campaigns:write` and `content:publish` (otherwise 403 `forbidden_scope` and nothing is written), because activation starts automatic WordPress publishing. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

POST /api/campaigns/{id}/recurring/pause — Pause recurring

Pauses the recurring automation and clears the next run. Idempotent: an already paused campaign is left unchanged. Pause always wins; there is no compare-and-set on the schedule. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

GET /api/campaigns/{id}/recurring/status — Get recurring status

Derived status: next run (unix, ISO, time zone), counters, last successful run, pause reason (from the owner notification, never its text), pilot-guard result and costs. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

GET /api/campaigns/{id}/recurring/runs — List recurring runs

Run history: one entry per claimed cycle of the recurring generator, newest cycle first. Programme campaigns (editorial and product programmes) are not recorded. The campaign must belong to the caller's organization (without an organization: to the caller). Foreign, missing and deleted campaigns all return an identical 404 `not_found` (no existence oracle).

GET /api/channels — List marketplace channels

Session only (Dashboard). Returns available channel listings from the marketplace. Channels represent connected WordPress sites or manually fulfilled external publisher offers.

POST /api/channels — Create or update channel listing

Session only (Dashboard). Lists a WordPress site on the marketplace or updates an existing listing.

GET /api/channels/{id} — Get channel details

Session only (Dashboard).

PATCH /api/channels/{id} — Update channel listing

Session only (Dashboard).

DELETE /api/channels/{id} — Delete channel listing

Session only (Dashboard).

GET /api/channels/{id}/supplier — Get private supplier details

Offering owner or active offering-account owner/admin only; site ownership also required. Cache-Control: private, no-store.

PUT /api/channels/{id}/supplier — Replace private supplier details

Same offering-account guard as GET. Original import metadata is preserved.

GET /api/bookings — List bookings

Returns marketplace bookings. Filter by role to see bookings as creator (advertiser) or publisher.

POST /api/bookings — Create booking

Books a marketplace channel for a sponsored post.

GET /api/bookings/{id} — Get booking details

Session only (Dashboard).

PATCH /api/bookings/{id} — Update booking

Session only (Dashboard). Update booking status, schedule, or publish URL.

GET /api/media — List media items

Returns uploaded and AI-generated images.

POST /api/media — Create media item

Uploads an image (multipart, stored in the media bucket, variants processed in the background) or registers an image that already lives at an external URL (JSON).

POST /api/media/multipart/create — Start multipart video upload

Starts a direct-to-bucket multipart upload for a video (MP4, QuickTime, WebM). Upload the parts with presigned URLs from `/api/media/multipart/part-url`, then call `/complete` and register the file with `/api/media/confirm`. Requires a plan with video uploads.

POST /api/media/multipart/part-url — Presigned URL for one part
POST /api/media/multipart/complete — Complete multipart upload

Assembles the uploaded parts. Does not create a media item; register the file with `/api/media/confirm`.

POST /api/media/multipart/abort — Abort multipart upload

Frees the parts of an unfinished upload. An unknown or finished upload also returns 200.

POST /api/media/presign — Get presigned upload URL

Returns a presigned S3 URL for direct upload. Upload the file via PUT to the returned URL, then call `/api/media/confirm`.

POST /api/media/confirm — Confirm S3 upload

Saves media metadata after a successful S3 upload via presigned URL.

POST /api/media/generate — Generate AI image

Session only (Dashboard). Generates an image using AI (OpenAI gpt-image-1) and stores it in S3.

GET /api/team — List team members

Session only (Dashboard). Returns the team members of the publisher by role, the publisher's pending (not accepted, not expired) team invitations and the roles that can currently be invited (active roles only).

POST /api/team — Invite team member

Session only (Dashboard). Invites a user by email. Existing and new addresses get the same pending invitation and the same answer; the person joins only after accepting it. Inactive roles are rejected. Limited per publisher (50 per hour, 300 per day).

DELETE /api/team/{id} — Remove team member

Session only (Dashboard).

GET /api/organizations — List organizations

Session only (Dashboard). Returns all organizations the authenticated user is a member of.

POST /api/organizations — Create organization

Session only (Dashboard).

GET /api/organizations/{id} — Get organization details

Session only (Dashboard). Returns organization with members and campaign count.

PUT /api/organizations/{id} — Update organization

Session only (Dashboard).

DELETE /api/organizations/{id} — Delete organization

Session only (Dashboard). Deletes the organization. Fails if organization is used in active campaigns.

GET /api/orders — List orders

Returns publisher orders. Requires `orders:read`.

POST /api/orders — Create order

Creates an empty order wrapper for a customer organization. Requires `orders:write`.

GET /api/orders/{id} — Get order details

Returns the order plus customer organization, linked content/campaign (incl. articles) and the Claribill invoice URL. Requires `orders:read`.

PATCH /api/orders/{id} — Update order / link content

Updates order metadata or links a post. Linking behavior: the first `contentId` links directly; a second post automatically converts the order into an invisible container campaign; further posts append as articles. `contentExternalReference` sets the per-position reference for the newly linked post. Requires `orders:write`.

DELETE /api/orders/{id} — Delete order (soft delete)
POST /api/orders/{id}/status — Change order status

Moves the order through its lifecycle (draft → in_progress → [pending_approval → approved] → published → invoiced → paid → completed; cancelled). `in_progress` requires linked content, a campaign or an external contribution line item. Requires `orders:write`.

GET /api/orders/{id}/line-items — List order line items

Positions (posts/channels, external contributions, add-ons, discounts). Requires `orders:read`.

POST /api/orders/{id}/line-items — Add line item (external post, add-on, discount)

Adds a position. For an off-platform post use `source: "external"` with `externalUrl`, the customer price as `unitAmount` (cents) and optionally `internalCost` (internal purchase cost, never on the invoice) and `externalReference`. Adding an external item to a draft order auto-starts it. Requires `orders:write`.

PATCH /api/orders/{id}/line-items/{itemId} — Update line item
DELETE /api/orders/{id}/line-items/{itemId} — Delete line item
POST /api/orders/{id}/invoice — Create Claribill invoice

Creates the multi-line Claribill invoice from the order line items (internal costs are never included). Options: `draft: true` creates a draft only; `external: true` marks the order as externally invoiced. Without a stored Claribill customer, pass `claribillCustomerId` or `createCustomer: true` (confirm via the customer-match endpoint first). Requires `orders:invoice`.

POST /api/orders/{id}/invoice/create-customer — Resolve or create the Claribill customer for an order's organization

Separate step before invoicing: links the order's customer organization to a Claribill customer and persists the id, serialized through an org-wide atomic claim (`claribill_customer_claim_token`/`_expires_at`, #631) so two concurrent requests for the same unlinked organization cannot create duplicate Claribill customers or overwrite each other's persisted id. Pass `claribillCustomerId` for a UI-confirmed match, or omit it to search by email and, after UI confirmation, create a new one. Requires `orders:invoice`.

GET /api/orders/{id}/invoice/customer-match — Match order customer against Claribill

Checks whether the order's customer organization is linked to / found in Claribill. Exactly one customer with the organization's email → `match`; several customers sharing that email (or the same name in the name fallback) → `matches` for explicit selection. Includes a `taxReview` block when the customer's EU VAT ID suggests reverse charge but the order is set up with full VAT. Requires `orders:invoice`.

GET /api/orders/{id}/tax-review — Review order VAT against the customer's VAT ID

Compares the order's VAT snapshot with the authoritative customer VAT ID (organization, falling back to the linked Claribill customer). Returns a reverse-charge suggestion when the customer has an EU VAT ID from another country than the issuer but the order is set up with full VAT. Read-only. Requires `orders:invoice`.

POST /api/orders/{id}/tax-review — Apply reverse-charge suggestion

Applies the reverse-charge suggestion after validating the VAT ID via VIES (invalid → 422 `vat_id_invalid`): updates the customer organization defaults (backfills an empty VAT ID from Claribill), fixes the order's VAT snapshot and recomputes the totals. Requires `orders:invoice`.

POST /api/orders/{id}/invoice/sync — Sync invoice status from Claribill

Pulls the current invoice status (and final invoice number) from Claribill. Requires `orders:invoice`.

GET /api/orders/{id}/invoice/send-status — Read the stored invoice Send receipt

Reads the original CMM Send observation without provider calls. ACCEPTED is transport acceptance, not delivery. Aftercare is separate; resolved:true does not convert UNKNOWN into acceptance. Requires `orders:invoice`.

POST /api/orders/{id}/invoice/send-status — Check the original invoice Send status

Only GETs Claribill's send-status using the original persisted key and verifies the current company binding. Never sends, retries, creates a key, or uses invoice status as acceptance evidence. Requires `orders:invoice`.

POST /api/orders/{id}/invoice/reconcile — Resolve an order stuck on invoiceStatus "unclear" (#619)

Three escape hatches for an ambiguous Claribill invoice create/send result that the strict reconciliation could not uniquely resolve: `recheck` (re-run the same strict match), `link` (verify and attach a Claribill invoice id the caller names), or `reset` (restore the pre-claim status — requires `confirmNoInvoiceExists: true`, and is itself refused if a possible non-cancelled invoice for this order is still found in Claribill). Requires `orders:invoice`.

GET /api/orders/stats — Order KPI statistics

Aggregated counts and revenue. Requires `orders:read`.

GET /api/publishing-channels — List publishing channels

Channels of the organization plus own legacy channels, with usage counts. With an API key that also holds `channels:write`, missing channel rows of connected sites are created first (self-heal).

POST /api/publishing-channels — Create publishing channel

Creates a WordPress channel (`authToken` required, `username:applicationPassword`) or a custom API channel (`apiKey` and `apiEndpoint` required). Credentials are never returned in responses.

GET /api/dashboard/stats — Dashboard statistics

Content, site and order statistics for the caller's access scope (administrators: all content and sites; orders always of the caller's organization).

GET /api/social/accounts — List social accounts

Connected social accounts grouped by publishing channel (no-channel group last). Tokens are never returned.

GET /api/social/posts — List social posts
POST /api/social/posts — Create social post

Creates a draft (or `pending_approval` with `requiresApproval`). Without `publishingChannelId` the channel is derived when all target accounts share one.

POST /api/social/posts/validate — Validate social post

Validates a post body like create, without writing. Blocking findings are a 200 result, not an error.

POST /api/social/posts/from-content/{contentId} — Create social post from article

Creates a draft for all active accounts of the article's channel (or the given ones). `linkUrl` may be the placeholder `{{articleUrl}}`, filled on publish.

GET /api/social/posts/{id} — Get social post
PATCH /api/social/posts/{id} — Update social post

Partial update with optimistic lock (`scheduleVersion`): omitted fields stay unchanged (no defaults are applied, #1264). `targets` replaces the whole list; `status` and `scheduledAt` are ignored here. A scheduled post is rescheduled.

DELETE /api/social/posts/{id} — Cancel social post

Soft delete: the post becomes `cancelled` and open targets are cancelled. Nothing is deleted on the networks (see the remote target delete).

POST /api/social/posts/{id}/approve — Approve social post
POST /api/social/posts/{id}/reject — Reject social post

Back to `draft` with `approvalStatus: rejected`; a scheduled post is unscheduled.

POST /api/social/posts/{id}/duplicate — Duplicate social post
POST /api/social/posts/{id}/publish-now — Publish social post now

Queues the post for immediate publishing (background job). X credits are checked here and charged by the job.

POST /api/social/posts/{id}/schedule — Schedule social post

Schedules the post (at least 60 s, at most 365 days ahead). X credits are checked here and charged by the publish job.

POST /api/social/posts/{id}/unschedule — Unschedule social post
DELETE /api/social/posts/{id}/targets/{targetId}/remote — Delete published post on the network

Deletes the live post of one target on the social network, then cancels the target. Only for networks that support deletion via API.

POST /api/social/posts/{id}/targets/{targetId}/retry — Retry failed target

Queues a failed target for another publish attempt (background job).

GET /api/orders/{id}/approval — Approval context

Recipients, contacts, signature and article titles for sending editorial approval requests for all articles of an order.

POST /api/orders/{id}/approval — Send approval requests

Creates approval tokens and sends editorial approval emails for all articles of the order. Status 502 carries the same body with `success: false` when at least one email failed.

POST /api/orders/{id}/line-items/sync-channels — Sync channel line items

Rebuilds the order's channel line items from the linked campaign's articles in one transaction and recomputes the totals. Idempotent.

v0.14.11