# n8n

Create and schedule posts from n8n with the HTTP Request node, and start workflows from Pozy webhooks with a verified signature.

n8n talks to Pozy through its built-in nodes: **HTTP Request** for actions and **Webhook** for triggers. This works on n8n Cloud and self-hosted n8n, with no community node to install.

You need a Pozy API key (**Developers → API keys** in the dashboard) and at least one connected account.

## Create the credential

### Add a Bearer credential

In n8n open **Credentials → Add credential** and choose **Bearer Auth**. Paste your `pz_live_` key as the token and name it "Pozy".

### Check it

Add an **HTTP Request** node with:

- **Method:** GET
- **URL:** `https://api.pozy.ai/v1/accounts`
- **Authentication:** Generic Credential Type → Bearer Auth → Pozy

Click **Execute step**. The output lists your connected accounts. Note the `_id` of each account you want to post to.

If your n8n version has no Bearer Auth type, use **Header Auth** with the name `Authorization` and the value `Bearer pz_live_...`.

## Create a post

Add an **HTTP Request** node:

| Field | Value |
| --- | --- |
| Method | POST |
| URL | `https://api.pozy.ai/v1/posts` |
| Authentication | Generic Credential Type → Bearer Auth → Pozy |
| Send Headers | On. Name `Idempotency-Key`, value `{{ $execution.id }}-{{ $itemIndex }}` |
| Send Body | On, **Body Content Type** JSON, **Specify Body** Using JSON |

Body, with values mapped from earlier nodes:

```json
{
  "content": "{{ $json.text }}",
  "platforms": [
    { "platform": "linkedin", "accountId": "6650f1c2a8b4e3d2c1b0a333" }
  ],
  "mediaItems": [
    { "type": "image", "url": "{{ $json.imageUrl }}" }
  ],
  "publishNow": true
}
```

Drop `mediaItems` for a text-only post. The response contains `post._id` and, per platform, `status` and `platformPostUrl` once it is live.

> **Quotes inside mapped text:**
> 
> When you put `{{ $json.text }}` inside a JSON string, a quote or a line break in the text breaks the JSON. Map the whole value instead: `"content": {{ JSON.stringify($json.text) }}`, without surrounding quotes.

## Schedule for later

Replace `publishNow` with a time and a timezone. Compute the time in a **Code** node before the request:

```js
const when = new Date();
when.setDate(when.getDate() + 1);
const day = when.toISOString().slice(0, 10);

return [{ json: { ...$json, scheduledFor: `${day}T09:00:00` } }];
```

```json
{
  "content": {{ JSON.stringify($json.text) }},
  "platforms": [{ "platform": "linkedin", "accountId": "6650f1c2a8b4e3d2c1b0a333" }],
  "scheduledFor": "{{ $json.scheduledFor }}",
  "timezone": "Europe/Madrid"
}
```

A time without an offset is read in `timezone`, so this posts at 9:00 in Madrid tomorrow. See [Scheduling and timezones](https://pozy.ai/docs/publishing/scheduling).

## Post a file

Posts take media as URLs. For a file that only exists inside n8n (from Google Drive, an email attachment or a generated image):

1. **HTTP Request:** `POST https://api.pozy.ai/v1/media/presign` with body `{ "filename": "photo.jpg", "contentType": "image/jpeg" }`. Keep `uploadUrl` and `publicUrl` from the response.
2. **HTTP Request:** `PUT` to `{{ $json.uploadUrl }}`, **Authentication** None, **Body Content Type** n8n Binary File, with header `Content-Type: image/jpeg`.
3. Use `publicUrl` in `mediaItems` of the create-post request.

The upload URL goes straight to storage, so it must not carry your Pozy key. See [Media uploads](https://pozy.ai/docs/publishing/media).

## Start a workflow from a Pozy event

### Add a Webhook node

Set **HTTP Method** to POST and **Respond** to Immediately. Under **Options**, turn on **Raw Body** so you can verify the signature. Copy the **Production URL**, then activate the workflow.

### Point Pozy at it

Create the endpoint in the dashboard under webhooks, or with one more HTTP Request node:

```json
{
  "name": "n8n alerts",
  "url": "https://your-n8n.example.com/webhook/pozy-events",
  "events": ["post.failed", "account.disconnected"]
}
```

Copy the endpoint's `secret` from the response.

### Verify and route

Add a **Code** node after the Webhook node:

```js
const crypto = require("crypto");
const secret = "whsec_...";                          // the endpoint secret
const raw = await this.helpers.getBinaryDataBuffer(0, "data");
const headers = $input.first().json.headers;

const expected = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const given = headers["x-pozy-signature"] ?? "";
if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
  throw new Error("Bad signature");
}

const event = JSON.parse(raw.toString("utf8"));
return [{ json: { eventId: headers["x-pozy-event-id"], ...event } }];
```

Then branch with a **Switch** node on `{{ $json.event }}`.

On self-hosted n8n, the Code node can only load `crypto` when the instance allows it: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto`. Keep the secret in an environment variable or n8n credential rather than in the node where you can.

Pozy retries a delivery that does not get a 2xx within 5 seconds, so the same event can arrive twice. If a duplicate would hurt (an email, a Slack alert), store `eventId` and skip ids you have seen.

## Workflow ideas

- **Content calendar.** Schedule Trigger every morning, Google Sheets reads today's rows, HTTP Request creates one post per row with `Idempotency-Key` set to the row id.
- **RSS to social.** RSS Feed Read on your blog, IF for new items, HTTP Request creates a LinkedIn post and a shorter X post with `customContent` per platform.
- **Failure alerts.** Webhook on `post.failed`, then Slack or email with `post.platforms[].error`.
- **Weekly report.** Schedule Trigger on Mondays, `GET /v1/analytics/dashboard?fromDate=...&toDate=...`, then an email with the totals and top posts.

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| `401` with `invalid_api_key` | The credential is not attached to the node, or the key was revoked |
| `400` with `param` set | The body is missing a field or a value is wrong; the `error` text says which |
| JSON parse error in the node | A mapped value broke the JSON. Use `JSON.stringify(...)` for text values |
| `429` with `rate_limit_exceeded` | Too many requests in a minute. Add a Wait node in loops, or enable **Batching** in the node's options |
| Signature never matches | Raw Body is off, or the secret is from a different endpoint |
