# WordPress

Connect a WordPress.com or self-hosted WordPress site to publish and schedule articles, run its WooCommerce store and install ad pixels.

A connected WordPress site is a blog account. You use it to create, schedule, update and delete the site's posts through the [Blogs API](https://pozy.ai/docs/commerce/blogs). A self-hosted site that runs WooCommerce is also a store, so the same account works with the [Commerce API](https://pozy.ai/docs/commerce). WordPress accounts are never targets of `POST /v1/posts` and have no inbox or social analytics.

## What you can do

| Job | WordPress.com | Self-hosted |
| --- | --- | --- |
| Publish, schedule, edit and delete articles | Yes | Yes |
| Featured images, tags and authors | Yes | Yes |
| WooCommerce products, stock, coupons and orders | No | Yes, when WooCommerce is installed |
| WordPress pages (Commerce pages endpoints) | Yes | Yes |
| Ad pixels as theme widgets | Plans with plugin support only | Yes |

| Property | Value |
| --- | --- |
| Platform id | `wordpress` (store objects report `platform: "woocommerce"`) |
| Connect | WordPress.com: OAuth. Self-hosted: username plus an Application Password |
| Blogs per account | One. Self-hosted sites use blog id `"1"`; WordPress.com uses the site's numeric id |
| Scheduling | Native: WordPress publishes at `publishDate` |
| Article body | Raw HTML in `bodyHtml` |

## Before you start

The WordPress user who connects must be able to publish posts and upload files. Pozy checks both before it saves the account, so a contributor role that can only write drafts is refused. Each account is one site. To manage several sites, connect each one.

## Connect WordPress.com

```bash cURL
curl "https://api.pozy.ai/v1/connect/wordpress?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https://app.example.com/connected" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/wordpress?profileId=6710c3a9e4b0d2f1a8c95e21&redirect_url=https:%2F%2Fapp.example.com%2Fconnected", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
// Redirect the site owner to data.authUrl
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/connect/wordpress",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={"profileId": "6710c3a9e4b0d2f1a8c95e21", "redirect_url": "https://app.example.com/connected"},
)
auth_url = resp.json()["authUrl"]
```

The site owner approves access and picks one site on WordPress.com. Pozy asks for single-site access only, never access to every site on the WordPress.com account. Reconnecting the same site on the same profile refreshes the account in place.

When the connection fails, the browser returns to your `redirect_url` with `error` and `platform=wordpress`:

| `error` | Meaning |
| --- | --- |
| `wordpress_access_denied` | The user declined |
| `wordpress_code_rejected` | The authorization expired or was already used. Start again |
| `wordpress_no_site` | No site was picked |
| `wordpress_insufficient_permissions` | The user is not an Author, Editor or Administrator of the site |
| `wordpress_site_unavailable`, `wordpress_unavailable` | The site or WordPress.com did not answer. Try again in a few minutes |
| `wordpress_connection_failed` | Anything else. Start again |

## Connect a self-hosted site

The site owner opens **Users > Profile** in WordPress admin, creates an Application Password, and gives it to you with their login username.

```bash cURL
curl -X POST https://api.pozy.ai/v1/connect/wordpress/token \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profileId": "6710c3a9e4b0d2f1a8c95e21",
    "siteUrl": "https://blog.example.com",
    "username": "editor@example.com",
    "applicationPassword": "abcd efgh ijkl mnop qrst uvwx"
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/connect/wordpress/token", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    profileId: "6710c3a9e4b0d2f1a8c95e21",
    siteUrl: "https://blog.example.com",
    username: "editor@example.com",
    applicationPassword: "abcd efgh ijkl mnop qrst uvwx",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.account._id);
```
```python Python
resp = requests.post(
    "https://api.pozy.ai/v1/connect/wordpress/token",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={
        "profileId": "6710c3a9e4b0d2f1a8c95e21",
        "siteUrl": "https://blog.example.com",
        "username": "editor@example.com",
        "applicationPassword": "abcd efgh ijkl mnop qrst uvwx",
    },
)
account_id = resp.json()["account"]["_id"]
```

`siteUrl` must be the public HTTPS root of the site. Leave out `/wp-json`, paths and any credentials. Before saving, Pozy finds the site's REST API, refuses private or local network addresses, checks the credentials and confirms the user can publish and upload.

> **Application Passwords are secrets:**
> 
> Store them like any other credential. Revoking the password in WordPress admin cuts access at once. Reconnect with a new password to restore it; the account keeps its id.

## Publish an article

List the blog first and use `blogs[0].id`. That way the same code works for self-hosted (`"1"`) and WordPress.com (numeric site id) accounts.

```bash cURL
curl -X POST https://api.pozy.ai/v1/accounts/6710c4b2e4b0d2f1a8c95e3a/blogs/1/articles \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Five things we learned shipping our mobile app",
    "bodyHtml": "<p>We spent six months on it. Here is what surprised us.</p>",
    "excerpt": "Lessons from six months of mobile work.",
    "tags": ["engineering", "mobile"],
    "author": "7",
    "image": { "url": "https://cdn.example.com/mobile-launch.jpg", "altText": "Phone showing the app home screen" },
    "publishDate": "2026-11-12T08:00:00Z"
  }'
```

What WordPress does with each field:

| Field | WordPress behaviour |
| --- | --- |
| `isPublished` | New articles default to draft. `true` publishes now. `false` always wins, even with a future date, so leave it out when you schedule |
| `publishDate` | A future date schedules the post natively (`status: "future"`). The site's own cron publishes it |
| `tags` | Tag names, up to 100. Existing tags match by name, case-insensitive. Missing tags are created if the user may create terms |
| `author` | The numeric WordPress user id as a string, not a login or email |
| `image` | Downloaded into the media library and set as the featured image. Public JPEG, PNG, WebP or GIF up to 10 MB |
| `seo` | Rejected with `400`. Core WordPress has no portable SEO fields |

Responses include WordPress's own `status` (`publish`, `future`, `draft`, `pending` or `private`). `isPublished` is `true` only for `publish`. On update, leaving out `image` keeps the current featured image, and there is no way to remove one through the API.

## WooCommerce

When a self-hosted site runs WooCommerce and the connected user can manage it, `GET /v1/commerce/store?accountId=...` lists store capabilities and the account works with the [Commerce API](https://pozy.ai/docs/commerce). WordPress.com sites cannot reach WooCommerce through Pozy.

| Area | On WooCommerce |
| --- | --- |
| Not available | Sales channels, menus, redirects, metaobjects, markets, price lists, marketing activities, discount codes, reordering collection products (`400 platform_not_supported`) |
| Products | `vendor`, `productType` and `seo` are rejected. `deactivate` sets the product to draft, `archive` sets it to private |
| Collections | Product categories, with no SEO fields or sort order |
| Discounts | Coupons only. A coupon cannot start in the future or require a minimum quantity |
| Stock | One location with the id `default`. `adjust` reads then writes, so a sale in between can be overwritten |
| Metafields | Product custom fields only |
| Orders | Read-only, up to 100 per page, no extra permission needed |
| Optional permissions | None. `grantPermissionsUrl` is always `null` |

Pozy registers order and product webhooks on the site when you connect it, so you get `commerce.order.*` and `commerce.product.*` events. Order attribution needs WooCommerce 8.5 or later with Order Attribution turned on, and records only the last visit.

## Limits

- No social posts, comments, DMs, analytics or inbox on a WordPress account.
- You cannot create, rename or delete the site's blog (`405`).
- Custom post types and categories are not managed through the Blogs API. Pages are available through the Commerce pages endpoints.
- Deletes are permanent. Pozy keeps no copy. WordPress keeps the post's media and tags for other content.

## Errors

| Error | Cause | Fix |
| --- | --- | --- |
| `400` on self-hosted connect | `siteUrl` is not HTTPS, contains credentials, points at a private address or has no usable REST API | Pass the public HTTPS site root |
| `401` or `403` on connect | Wrong or revoked Application Password, or the user cannot publish and upload | Create a new password for a user with publish and upload rights |
| `400` on an article | Invalid field, non-numeric `author`, or `seo` was sent | Fix the field named in `param` |
| `404 blog_not_found` | Wrong `blogId` | Call List blogs and use the id it returns |
| `422 tracking_tag_install_blocked` | The site cannot run pixel scripts. `details.reason` says why | See [Site pixels](https://pozy.ai/docs/commerce/site-pixels#wordpress) |
| `502`, `503` | The site failed or timed out | Check the article state before retrying a write; a timed-out write may have landed |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/connect/wordpress`](https://pozy.ai/docs/api/getWordPressAuthUrl) | Get WordPress.com OAuth connect URL |
| [`POST /v1/connect/wordpress/token`](https://pozy.ai/docs/api/connectWordPressWithApplicationPassword) | Connect self-hosted WordPress with an application password |
| [`GET /v1/accounts/{accountId}/blogs`](https://pozy.ai/docs/api/listBlogs) | List blogs |
| [`GET /v1/accounts/{accountId}/blogs/{blogId}`](https://pozy.ai/docs/api/getBlog) | Get a blog |
| [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/listBlogArticles) | List blog articles |
| [`POST /v1/accounts/{accountId}/blogs/{blogId}/articles`](https://pozy.ai/docs/api/createBlogArticle) | Create a blog article |
| [`GET /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/getBlogArticle) | Get a blog article |
| [`PATCH /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/updateBlogArticle) | Update a blog article |
| [`DELETE /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}`](https://pozy.ai/docs/api/deleteBlogArticle) | Delete a blog article |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`account.disconnected`](https://pozy.ai/docs/webhooks/events#account-disconnected) | Account disconnected. |
| [`commerce.product.created`](https://pozy.ai/docs/webhooks/events#commerce-product-created) | Commerce product created. |
| [`commerce.product.updated`](https://pozy.ai/docs/webhooks/events#commerce-product-updated) | Commerce product updated. |
| [`commerce.product.deleted`](https://pozy.ai/docs/webhooks/events#commerce-product-deleted) | Commerce product deleted. |
| [`commerce.order.created`](https://pozy.ai/docs/webhooks/events#commerce-order-created) | Commerce order created. |
| [`commerce.order.updated`](https://pozy.ai/docs/webhooks/events#commerce-order-updated) | Commerce order updated. |
| [`commerce.order.refunded`](https://pozy.ai/docs/webhooks/events#commerce-order-refunded) | Commerce order refunded. |
