API Endpoints
Base URL: https://app.nuelink.com/api/public/v1 Contract: OpenAPI 3.0.3, version 1.2.0
The API has 17 operations. 1.3.0-alpha added six: validate a token, read a brand’s weekly schedule, list posts across a brand, reschedule, re-queue, or draft a post, delete a post, and read published results with engagement.
The full machine-readable contract is published at /openapi-v1.2.0.yaml. Point a client generator or a request validator at it to work against the same spec these pages describe.
Operation index
Section titled “Operation index”| Method | Path | Does | Introduced |
|---|---|---|---|
GET | /me | Get the authenticated profile | Original alpha |
GET | /auth | Validate a token | 1.3.0-alpha |
GET | /brands | List brands | Original alpha |
GET | /brands/{brand_id}/schedule | Read the weekly queue schedule | 1.3.0-alpha |
GET | /brands/{brand_id}/channels | List channels | Original alpha |
GET | /brands/{brand_id}/collections | List collections | Original alpha |
POST | /brands/{brand_id}/collections | Create a collection | 1.2.0-alpha |
GET | /brands/{brand_id}/automations | List feed automations | 1.2.0-alpha |
POST | /brands/{brand_id}/automations | Create a feed automation | 1.2.0-alpha |
GET | /brands/{brand_id}/media | List media | 1.2.0-alpha |
POST | /brands/{brand_id}/media | Upload media | Original alpha |
GET | /brands/{brand_id}/collections/{collection_id}/posts | List a collection’s posts | 1.2.0-alpha |
GET | /brands/{brand_id}/posts | List a brand’s posts | 1.3.0-alpha |
POST | /brands/{brand_id}/collections/{collection_id}/posts | Create a post | Original alpha |
PATCH | /brands/{brand_id}/posts/{post_id} | Reschedule, re-queue, or draft a post | 1.3.0-alpha |
DELETE | /brands/{brand_id}/posts/{post_id} | Delete a post | 1.3.0-alpha |
GET | /brands/{brand_id}/published-posts | List published results | 1.3.0-alpha |
All paths are relative to https://app.nuelink.com/api/public/v1.
Every request needs:
Authorization: Bearer YOUR_API_KEYAccept: application/jsonRequests with a JSON body (POST and PATCH) also need:
Content-Type: application/jsonMedia uploads are the exception: they’re sent as multipart/form-data.
Shared behavior
Section titled “Shared behavior”Every list operation accepts page and per_page. See Pagination, Rate Limits, and Errors for the conventions that apply across all of them.
Dates and timezones
Section titled “Dates and timezones”The API uses several time formats, and they don’t share a timezone. Mixing them up is the usual way a post lands an hour off.
| Field | Format | Timezone |
|---|---|---|
scheduledAt (you send it) | Y-m-d H:i:s, e.g. 2026-10-12 09:00:00 | The brand’s |
postDate (you read it back) | Y-m-d H:i:s | UTC |
created_from, created_to, published_from, published_to | Y-m-d H:i:s | UTC |
Queue times: queues[].time, and time in the schedule | HH:mm | The brand’s |
createdAt, updatedAt, joinedAt, publishedAt | ISO 8601, e.g. 2026-10-05T12:00:00.000000Z | UTC |
lastRun (automations) | Unix timestamp, in seconds | — |
A post scheduled for 2026-10-12 09:00:00 in a brand set to Europe/London (UTC+1 in October) reads back as "postDate": "2026-10-12 08:00:00". Read the brand’s timezone from GET /brands before you schedule or compare times.
Profile
Section titled “Profile”GET /me
Section titled “GET /me”Returns the user the API key belongs to.
{ "status": "success", "data": { "id": "user_id", "name": "Example User", "joinedAt": "2026-01-01T00:00:00.000000Z", "timezone": "UTC" }}The id is an external hashed identifier, not the internal user ID.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/me"const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch('https://app.nuelink.com/api/public/v1/me', { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init('https://app.nuelink.com/api/public/v1/me');
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;| Field | Type | Description |
|---|---|---|
id | string | Opaque user identifier. Not the internal user ID; treat it as a string. |
name | string | Display name on the Nuelink account. |
joinedAt | string | ISO 8601 timestamp of account creation. |
timezone | string | null | The user’s timezone as stored by Nuelink, usually an IANA name such as Europe/London. Treat it as an opaque string that can be null. This is the user’s timezone, not a brand’s: schedule with the brand’s. |
/me and /auth count only against the 60-per-minute route limit, not the 30-per-minute resource limit, which makes them cheap health checks. Their X-RateLimit-* headers describe that larger bucket. See Rate Limits.
GET /auth
Section titled “GET /auth”Validates a token. It returns the same profile as GET /me, so use whichever name reads better in your code: a 200 means the key works, and a 401 says why it doesn’t in errors.authorization.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/auth"const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch('https://app.nuelink.com/api/public/v1/auth', { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init('https://app.nuelink.com/api/public/v1/auth');
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;The 401 bodies are listed in Authentication.
Brands
Section titled “Brands”GET /brands
Section titled “GET /brands”Returns the brands the key’s user is an active member of, newest first.
{ "status": "success", "data": [ { "id": 1001, "title": "Example Brand", "description": "Example workspace", "timezone": "Europe/London", "queueStatus": "ACTIVE", "createdAt": "2026-01-01T00:00:00.000000Z", "updatedAt": "2026-01-15T12:00:00.000000Z" } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}timezone is the one used to interpret scheduledAt for every post in that brand. Read it before you schedule anything.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands?page=1&per_page=25"const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch('https://app.nuelink.com/api/public/v1/brands?page=1&per_page=25', { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init('https://app.nuelink.com/api/public/v1/brands?page=1&per_page=25');
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;| Field | Type | Description |
|---|---|---|
id | integer | Brand identifier. Use as brand_id in every nested path. |
title | string | Display name of the brand. Match on this when resolving a brand by name. |
description | string | null | Optional free-text description. |
timezone | string | null | IANA timezone this brand schedules in, e.g. Europe/London. scheduledAt and queue times are interpreted here. |
queueStatus | string | ACTIVE or PAUSED. While PAUSED, no queued post publishes from any of the brand’s collections. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | ISO 8601 timestamp. |
GET /brands/{brand_id}/schedule
Section titled “GET /brands/{brand_id}/schedule”Every weekly queue slot in the brand, from every collection, grouped by day and ordered by time. Times are in the brand’s timezone. It isn’t paginated: one call returns the whole week.
{ "status": "success", "data": { "timezone": "Europe/London", "queueStatus": "ACTIVE", "schedule": { "MON": [ { "time": "09:00", "queueId": 55001, "collectionId": 2001, "collectionTitle": "Product Launches", "collectionStatus": "ACTIVE" } ], "TUE": [], "WED": [ { "time": "14:30", "queueId": 55002, "collectionId": 2001, "collectionTitle": "Product Launches", "collectionStatus": "ACTIVE" } ], "THU": [], "FRI": [], "SAT": [], "SUN": [] } }}curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/schedule"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/schedule`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/schedule");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;| Field | Type | Description |
|---|---|---|
timezone | string | null | The brand’s timezone. Every time below is in it. |
queueStatus | string | ACTIVE or PAUSED, as on GET /brands. |
schedule | object | Keyed MON through SUN. A day with no slots is an empty array. |
schedule.*[].time | string | HH:mm, 24-hour. |
schedule.*[].queueId | integer | The slot’s ID, the same value as queues[].id on the collection. |
schedule.*[].collectionId | integer | The collection that owns the slot. |
schedule.*[].collectionTitle | string | That collection’s title. |
schedule.*[].collectionStatus | string | ACTIVE or PAUSED. |
A slot only publishes queued posts when the brand’s queueStatus and the slot’s collectionStatus are both ACTIVE.
To change slots, use the dashboard. The API sets a collection’s slots when it creates the collection, but can’t edit them afterwards.
Channels
Section titled “Channels”GET /brands/{brand_id}/channels
Section titled “GET /brands/{brand_id}/channels”Returns the brand’s active and inactive channels, newest first.
{ "status": "success", "data": [ { "id": 5001, "name": "Example Instagram Account", "status": "ACTIVE", "type": "Instagram", "createdAt": "2026-01-01T09:00:00.000000Z", "updatedAt": "2026-01-15T14:30:00.000000Z" } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}Channels are for inspection and for building collections; the create-post endpoint does not accept channel IDs.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/channels?page=1&per_page=25"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/channels?page=1&per_page=25`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/channels?page=1&per_page=25");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;| Field | Type | Description |
|---|---|---|
id | integer | Channel identifier. Use it in channels when creating a collection, and in platforms.youtube.playlists[].channelId. |
name | string | Display name of the connected social account. |
status | string | ACTIVE (connected and working) or INACTIVE (disconnected, or needs re-auth). An INACTIVE channel will not receive posts. |
type | string | Platform type. See the values below. |
createdAt | string | ISO 8601 timestamp of when the channel was connected. |
updatedAt | string | ISO 8601 timestamp. |
Supported type values
Section titled “Supported type values”Facebook, Instagram, Threads, LinkedIn, Google Business, YouTube, TikTok, Pinterest, X, Mastodon, Bluesky, Telegram, Social Media.
Switch on these, but handle an unrecognized type without failing: new platforms are added over time.
Collections
Section titled “Collections”GET /brands/{brand_id}/collections
Section titled “GET /brands/{brand_id}/collections”Returns the brand’s collections, newest first, each with its status, evergreen settings, channels, weekly queue, and post counts.
{ "status": "success", "data": [ { "id": 2001, "title": "Product Launches", "description": "Launch content", "status": "ACTIVE", "evergreen": { "enabled": true, "maxRepublish": 2 }, "maxRepublish": 2, "timezone": "Europe/London", "createdAt": "2026-01-01T09:00:00.000000Z", "updatedAt": "2026-01-02T10:30:00.000000Z", "channels": [ { "id": 5001, "name": "Example Instagram Account", "status": "ACTIVE", "type": "Instagram" } ], "queues": [ { "id": 55001, "day": "MON", "time": "09:00", "date": "MON 09:00", "status": "PENDING" }, { "id": 55002, "day": "WED", "time": "14:30", "date": "WED 14:30", "status": "PENDING" } ], "postsCount": { "queued": 12, "scheduled": 1, "drafts": 3, "published": 40 } } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}The embedded channels array is where a post to this collection will go.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/collections?page=1&per_page=25"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/collections?page=1&per_page=25`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/collections?page=1&per_page=25");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;| Field | Type | Description |
|---|---|---|
id | integer | Collection identifier. Use as collection_id when creating or listing posts. |
title | string | Display name. Match on this when resolving a collection by name. |
description | string | null | Optional free-text description. |
status | string | ACTIVE or PAUSED. A paused collection doesn’t publish queued posts. |
evergreen.enabled | boolean | Whether published posts are added back to the queue. |
evergreen.maxRepublish | integer | 0 turns evergreen off, -1 republishes forever, and any other value N re-adds published posts after N weeks. |
maxRepublish | integer | The same value as evergreen.maxRepublish. |
timezone | string | null | Timezone of the queue times: the brand’s. |
channels | array | Channels this collection publishes to, active or inactive. Each carries id, name, status, and type, with no timestamps. Unavailable channels and Pinterest account-container records are left out. |
queues | array | Weekly slots, ordered Monday to Sunday. |
queues[].id | integer | Slot identifier, the same value as queueId in GET /schedule. |
queues[].day | string | MON through SUN. |
queues[].time | string | HH:mm, in the brand’s timezone. |
queues[].date | string | day and time together, e.g. MON 09:00. |
queues[].status | string | PENDING or PUBLISHED. |
postsCount | object | queued, scheduled, drafts, and published counts. Returned by this list only, not by create. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | ISO 8601 timestamp. |
queues holds one collection’s slots. For every slot in the brand at once, across collections, use GET /schedule.
POST /brands/{brand_id}/collections
Section titled “POST /brands/{brand_id}/collections”Create a collection, with its default channels and its weekly queue, in one call.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | Yes | 1–255 characters |
description | string | null | No | Max 1000 characters |
maxRepublish | integer | null | No | Evergreen. 0, the default, turns it off; N re-adds published posts to the queue after N weeks |
channels | integer[] | null | No | Channel IDs from this brand. Defaults to [] |
queues | string[] | null | No | Weekly slots in the brand’s timezone, format Mon 09:00: a three-letter day and 24-hour time. Defaults to [] |
Queue limits. Verified users can create up to 10 slots per collection per day, unverified users up to 5. Duplicate or overlapping slots are not rejected, so de-duplicate before you send.
curl --fail-with-body --silent --show-error \ -X POST \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/collections" \ --data '{ "title": "Example Collection", "description": "Example collection description", "maxRepublish": 2, "channels": [5001, 5002], "queues": ["Mon 09:00", "Wed 14:30", "Fri 18:00"] }'const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const payload = { title: 'Example Collection', description: 'Example collection description', maxRepublish: 2, channels: [5001, 5002], queues: ['Mon 09:00', 'Wed 14:30', 'Fri 18:00'],};
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/collections`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(payload),});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$payload = [ 'title' => 'Example Collection', 'description' => 'Example collection description', 'maxRepublish' => 2, 'channels' => [ 5001, 5002, ], 'queues' => [ 'Mon 09:00', 'Wed 14:30', 'Fri 18:00', ],];
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/collections");
curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', 'Content-Type: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;Returns 201 with the created collection, in the shape GET /collections returns, minus postsCount. Each queue slot gets its own id, and the day comes back uppercase: Mon 09:00 is returned as "day": "MON", "time": "09:00", "date": "MON 09:00".
{ "status": "success", "data": { "id": 2002, "title": "Example Collection", "description": "Example collection description", "status": "ACTIVE", "evergreen": { "enabled": true, "maxRepublish": 2 }, "maxRepublish": 2, "timezone": "Europe/London", "createdAt": "2026-10-05T09:00:00.000000Z", "updatedAt": "2026-10-05T09:00:00.000000Z", "channels": [ { "id": 5001, "name": "Example Instagram Account", "status": "ACTIVE", "type": "Instagram" } ], "queues": [ { "id": 55101, "day": "MON", "time": "09:00", "date": "MON 09:00", "status": "PENDING" }, { "id": 55102, "day": "WED", "time": "14:30", "date": "WED 14:30", "status": "PENDING" }, { "id": 55103, "day": "FRI", "time": "18:00", "date": "FRI 18:00", "status": "PENDING" } ] }}Field meanings are in the list response table.
Errors worth handling
400- a channel ID doesn’t belong to this brand, or a day has more slots than the user’s limit allows.403- the brand hit its collections limit on the current plan.404- brand not found, or you’re not a member of it.
Automations
Section titled “Automations”GET /brands/{brand_id}/automations
Section titled “GET /brands/{brand_id}/automations”Lists the brand’s feed automations, newest first. Automations of other kinds aren’t returned.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/automations?page=1&per_page=25"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/automations?page=1&per_page=25`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/automations?page=1&per_page=25");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;Each automation returns id, name, collectionId, status, type, feedUrl, runCount, importAsType, lastRun (Unix timestamp), title, caption, createdAt, and updatedAt.
{ "status": "success", "data": [ { "id": 8812, "name": "NASA Breaking News", "collectionId": 720, "status": "ACTIVE", "type": "RSS", "feedUrl": "https://www.nasa.gov/rss/dyn/breaking_news.rss", "runCount": 42, "importAsType": "IMAGE", "lastRun": 1767225600, "title": "{{title}}", "caption": "{{title}} {{link}}", "createdAt": "2026-01-01T09:00:00.000000Z", "updatedAt": "2026-01-15T14:30:00.000000Z" } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}| Field | Type | Description |
|---|---|---|
id | integer | Automation identifier. |
name | string | The automation’s name. |
collectionId | integer | The collection imported posts land in. |
status | string | Current state of the automation, e.g. ACTIVE. |
type | string | Feed source, one of the 27 values listed under POST /automations. |
feedUrl | string | The source being polled. |
runCount | integer | How many times the automation has run. |
importAsType | string | null | LINK, IMAGE, VIDEO, or CAROUSEL. |
lastRun | integer | Unix timestamp in seconds, not an ISO 8601 string like the other date fields on this page. 0 means it hasn’t run yet. |
title | string | null | Title template applied to each imported post. |
caption | string | null | Caption template applied to each imported post. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | ISO 8601 timestamp. |
POST /brands/{brand_id}/automations
Section titled “POST /brands/{brand_id}/automations”Create a feed automation. Nuelink fetches and parses the feed during the request, so an unreachable or malformed feed fails here rather than later.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | 1–255 characters |
collectionId | integer | Yes | A collection in this brand. Imported posts land there |
type | string | Yes | The feed source, see below |
feedUrl | string (uri) | Yes | Must be fetchable and parseable |
importAsType | string | Yes | LINK, IMAGE, VIDEO, or CAROUSEL |
title | string | null | No | Title template for each imported post, max 255 characters. Defaults to {{title}} |
caption | string | null | No | Caption template for each imported post, max 255 characters |
loadOldPosts | boolean | null | No | Defaults to false: only new feed items are imported. true also imports the items already in the feed, in the background |
addPostsAsDraft | boolean | null | No | Defaults to false. true imports posts as drafts instead of queueing them |
refreshRate | integer | null | No | Hours between checks: 1, 6, 12, or 24, the default |
Supported type values
YOUTUBE, RSS, ATOM, MEDIUM, SHOPIFY, WORDPRESS, WOOCOMMERCE, ETSY, GHOST, SUBSTACK, ANCHOR, TRANSISTOR, CAPTIVATE, SOUNDCLOUD, BLOGGER, WIX, SQUARESPACEBLOG, SQUARESPACESHOP, WEEBLY, TUMBLR, SHOPIFY2, WOOCOMMERCE2, PRESTASHOP, BUZZSPROUT, RSSCOM, CASTOS, PODCASTCO
curl --fail-with-body --silent --show-error \ -X POST \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/automations" \ --data '{ "name": "NASA Breaking News", "collectionId": 720, "type": "RSS", "feedUrl": "https://www.nasa.gov/rss/dyn/breaking_news.rss", "importAsType": "IMAGE", "title": "{{title}}", "caption": "{{title}} {{link}}", "loadOldPosts": false, "addPostsAsDraft": true }'const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const payload = { name: 'NASA Breaking News', collectionId: 720, type: 'RSS', feedUrl: 'https://www.nasa.gov/rss/dyn/breaking_news.rss', importAsType: 'IMAGE', title: '{{title}}', caption: '{{title}} {{link}}', loadOldPosts: false, addPostsAsDraft: true,};
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/automations`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(payload),});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$payload = [ 'name' => 'NASA Breaking News', 'collectionId' => 720, 'type' => 'RSS', 'feedUrl' => 'https://www.nasa.gov/rss/dyn/breaking_news.rss', 'importAsType' => 'IMAGE', 'title' => '{{title}}', 'caption' => '{{title}} {{link}}', 'loadOldPosts' => false, 'addPostsAsDraft' => true,];
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/automations");
curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', 'Content-Type: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;Templates shape each imported post. title and caption take placeholders that Nuelink fills from each feed item at import time. {{title}}, {{description}}, and {{link}} are the common ones; which placeholders a feed offers depends on its type.
Returns 201 with the created automation, in the same shape GET /automations returns:
{ "status": "success", "data": { "id": 8812, "name": "NASA Breaking News", "collectionId": 720, "status": "ACTIVE", "type": "RSS", "feedUrl": "https://www.nasa.gov/rss/dyn/breaking_news.rss", "runCount": 0, "importAsType": "IMAGE", "lastRun": 0, "title": "{{title}}", "caption": "{{title}} {{link}}", "createdAt": "2026-10-05T09:00:00.000000Z", "updatedAt": "2026-10-05T09:00:00.000000Z" }}lastRun is 0 until the first run. The automation starts polling after creation, so runCount and lastRun change on their own. Field meanings are in the list response table.
Errors worth handling
400- Nuelink couldn’t fetch or parse the feed. Check the URL in a browser first.403- the account hit its automations limit on the current plan.404- the brand or the target collection doesn’t exist, or isn’t yours.422- a field failed validation. Under the1.2.0contract, a request in the 1.2.0-alpha shape is invalid:nameis required, andFEEDisn’t atypevalue.
GET /brands/{brand_id}/media
Section titled “GET /brands/{brand_id}/media”Lists the media in a brand’s library, newest first. Each item returns an opaque string id, name, type, size, createdAt, and updatedAt.
Pass the optional type query parameter to filter the library: IMAGE, VIDEO, GIF, APPLICATION, DOCUMENT, or CSV. APPLICATION returns everything stored as a document; DOCUMENT narrows that to office and document file types. Omit type to return everything.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/media?type=IMAGE&page=1&per_page=25"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/media?type=IMAGE&page=1&per_page=25`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/media?type=IMAGE&page=1&per_page=25");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;The returned string id can be passed unchanged as media[].id when creating a post. Treat it as opaque: do not decode it, coerce it to a number, or infer storage details from its value.
{ "status": "success", "data": [ { "id": "encoded_media_id", "name": "launch.jpg", "type": "image/jpeg", "size": 245678, "createdAt": "2026-01-01T09:00:00.000000Z", "updatedAt": "2026-01-01T09:00:00.000000Z" } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}| Field | Type | Description |
|---|---|---|
id | string | Opaque media ID. Pass unchanged as media[].id when creating a post. |
name | string | null | Original filename as uploaded, when known. |
type | string | MIME type, e.g. image/jpeg, not the type filter value. The filter takes categories (IMAGE, VIDEO, …); the field returns a MIME type. |
size | integer | File size in bytes. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | ISO 8601 timestamp. |
POST /brands/{brand_id}/media
Section titled “POST /brands/{brand_id}/media”POST /brands/{brand_id}/mediaContent-Type: multipart/form-dataThe multipart field name is media, one file per request: JPEG, PNG, BMP, MP4, MOV, or PDF, up to 100 MiB (104,857,600 bytes). Infrastructure limits can reject a large upload before it reaches that size, so test with your real files. PDF is accepted for LinkedIn only, where it publishes as a document post; other platforms in the collection will skip it.
webp is no longer accepted. Convert those files to PNG or JPEG first.
The REST API never takes media as base64. Upload the file as multipart, or skip uploading altogether and pass a public url in the post’s media array.
curl --fail-with-body --silent --show-error \ -X POST \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/media" \ -F "media=@./launch.jpg"import { readFile } from 'node:fs/promises';
const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const formData = new FormData();formData.append('media', new Blob([await readFile('./launch.jpg')]), 'launch.jpg');
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/media`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', }, body: formData,});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/media");
curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'media' => new CURLFile('./launch.jpg'), ], CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;{ "status": "success", "data": { "id": "encoded_media_id", "type": "image/jpeg", "size": 245678 }}Keep this id. Media IDs are opaque strings throughout list, upload, and post creation. Pass the value unchanged as media[].id.
| Field | Type | Description |
|---|---|---|
id | string | Opaque media ID. Pass unchanged as media[].id when creating a post. |
type | string | MIME type detected by the server, e.g. image/jpeg. |
size | integer | File size in bytes. |
Rejected uploads come back as 422, with the accepted types in the message. Read that list rather than hardcoding one — it is the authoritative answer at runtime:
{ "message": "The given data was invalid.", "errors": { "media": ["The media must be a file of type: ..."] }}{ "message": "The given data was invalid.", "errors": { "media": ["The media file may not be greater than 100 megabytes."] }}GET /brands/{brand_id}/collections/{collection_id}/posts
Section titled “GET /brands/{brand_id}/collections/{collection_id}/posts”Lists a collection’s posts, newest first. Filter on the server with the query parameters below. Every filter is optional, and they combine with AND.
| Parameter | Values | Notes |
|---|---|---|
view | QUEUE, SCHEDULED, DRAFT, PUBLISHED | Shortcut for queued posts, posts with a fixed date, drafts, or published posts |
status | PENDING, DRAFT, PUBLISHED | Lifecycle status |
post_type | TEXT, IMAGE, VIDEO, MULTIMEDIA, SHORT, DOCUMENT, THREAD, SLIDES, REEL, TIKTOK, STORY, POLL, ENGAGE_COMMENT | Content type |
posting_type | NOW, SCHEDULE, QUEUE, DRAFT | Publishing mode |
created_from | Y-m-d H:i:s, UTC | Created at or after this time |
created_to | Y-m-d H:i:s, UTC | Created at or before this time. Can’t precede created_from |
sort_by | id (default), title, post_date, created_at, updated_at | Ties break on post ID, in the same direction |
sort_order | desc (default), asc |
Plus page and per_page. Encode the space in a timestamp filter (created_from=2026-10-01%2000:00:00), or let your HTTP client build the query string.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/collections/$COLLECTION_ID/posts?view=DRAFT&page=1&per_page=25"const brandId = process.env.BRAND_ID;const collectionId = process.env.COLLECTION_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/collections/${collectionId}/posts?view=DRAFT&page=1&per_page=25`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$collectionId = getenv('COLLECTION_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/collections/{$collectionId}/posts?view=DRAFT&page=1&per_page=25");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;Each post returns id, collectionId, title, body, media[], postType, postingType, status, postDate, createdAt, updatedAt, and a poll object on polls.
{ "status": "success", "data": [ { "id": 4364709, "collectionId": 2001, "title": "Spring Collection Launch", "body": "Our new Spring Collection is live.", "media": [{ "id": "encoded_media_id" }], "postType": "IMAGE", "postingType": "DRAFT", "status": "DRAFT", "postDate": null, "createdAt": "2026-01-01T09:00:00.000000Z", "updatedAt": "2026-01-01T09:00:00.000000Z" } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}| Field | Type | Description |
|---|---|---|
id | integer | Post identifier, the same value POST .../posts returned. |
collectionId | integer | The collection this post belongs to. |
title | string | null | Optional title. |
body | string | null | The caption. You send caption on create and read body back — the field is named differently in each direction. On a poll, body is null and the caption is in poll.caption. |
media | array | Attached media, each item carrying an id only. |
postType | string | Derived by the server from the media, poll, story, and thread inputs, e.g. IMAGE, THREAD, or POLL. The full list is under post_type above. |
postingType | string | NOW, SCHEDULE, QUEUE, or DRAFT. A post created with IMMEDIATE reads back as NOW. |
status | string | Lifecycle status, e.g. PENDING, DRAFT, or PUBLISHED. |
postDate | string | null | The scheduled time in UTC, formatted Y-m-d H:i:s: not ISO 8601, and not the brand-local value you sent as scheduledAt. null when no time is set, e.g. on a draft. |
createdAt | string | ISO 8601 timestamp. |
updatedAt | string | ISO 8601 timestamp. |
poll | object | Present only on polls, with caption, question, options, period, correctOptionIndex, explanation. |
This is how you verify a create actually did what you asked. A saved draft reads back as:
{ "postingType": "DRAFT", "status": "DRAFT", "postDate": null}Read it back without a view or status filter. If the mode was wrong, a filter would hide the post instead of showing you the mistake. With the default sort a post you just created is normally first, but match on the id the create returned rather than on position: another client can create a post between your two calls.
GET /brands/{brand_id}/posts
Section titled “GET /brands/{brand_id}/posts”Lists posts across every collection in the brand. It takes the same filters as the collection endpoint, plus collection_id to narrow it to one collection, and returns the same post shape.
view answers the common questions in one call: QUEUE for what’s waiting in the queues, SCHEDULED for what’s booked at a fixed time, DRAFT for what’s waiting for review, and PUBLISHED for what went out.
curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/posts?view=SCHEDULED&sort_by=post_date&sort_order=asc&page=1&per_page=25"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const params = new URLSearchParams({ view: 'SCHEDULED', sort_by: 'post_date', sort_order: 'asc', page: '1', per_page: '25',});
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/posts?${params}`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$query = http_build_query([ 'view' => 'SCHEDULED', 'sort_by' => 'post_date', 'sort_order' => 'asc', 'page' => 1, 'per_page' => 25,]);
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/posts?{$query}");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;Sorting on post_date ascending lists scheduled posts earliest first. postDate is UTC, so convert it to the brand’s timezone before you show it to anyone.
POST /brands/{brand_id}/collections/{collection_id}/posts
Section titled “POST /brands/{brand_id}/collections/{collection_id}/posts”Creates a text, media, story, thread, document, or poll post in a collection. The collection’s channels receive it; you don’t pass channel IDs. The rules:
publishModeis required, one ofIMMEDIATE,SCHEDULE,QUEUE,DRAFT.scheduledAtis required whenpublishMode=SCHEDULE: formatY-m-d H:i:s, in the brand’s timezone, and at least 10 minutes in the future. Nuelink stores it in UTC, and you read it back aspostDate.captionis required unless you provide apoll. Max 3000 characters.- Each
media[]item must have exactly one ofidorurl. - A caption split by triple newlines becomes a thread of up to 10 segments. See Threads.
- A brand can hold a maximum of 15,000 posts in total. Collection, scheduled-post, and plan limits also apply.
Optional content fields: title (max 255), alt (max 255), link (max 1000), postAsStory, autoThreadText.
Optional structured fields: poll, comment, and platforms for Instagram, Facebook, TikTok, YouTube, and Google Business Profile.
Those three are covered in full, with field rules and JSON examples for every platform, in Platform-Specific Options. Platform options are stored for compatible channels; each platform’s own publishing constraints still apply when the post goes out.
Request body fields
Section titled “Request body fields”| Field | Type | Required | Description |
|---|---|---|---|
publishMode | string | Yes | QUEUE, SCHEDULE, IMMEDIATE, or DRAFT. |
caption | string | Yes (unless poll is set) | Main post text. 1–3000 characters. null counts as missing. |
poll | object | Required if no caption | Poll configuration. A caption sent alongside becomes the poll’s caption. See Polls and quizzes. |
scheduledAt | string | Yes (if publishMode is SCHEDULE) | Format Y-m-d H:i:s, e.g. 2026-10-10 12:12:00. Interpreted in the brand’s timezone, and must be at least 10 minutes in the future. |
title | string | No | Used by YouTube, Pinterest, and similar. Max 255 characters. |
alt | string | No | Alt text for accessibility. Max 255 characters. |
link | string | No | Destination URL, max 1000 characters. Google Business Profile uses it for the post’s LEARN_MORE button, and Pinterest as the Pin’s destination link. See Links. |
postAsStory | boolean | No | Publish as a Story on channels that support them (Instagram and Facebook). Other platforms in the collection publish a regular feed post. |
autoThreadText | boolean | No | Turns on Nuelink’s automatic text splitting. See Threads. |
comment | object | No | Auto-comment posted after publishing. delay and comment are both required when it’s present. See Global auto-comment. |
media | array | No | Attachments. Each item carries an id from /media or a public url, never both. Nuelink downloads a url during the request. |
platforms | object | No | Per-platform non-content options. See Platform-Specific Options. |
Example: scheduled post with media
Section titled “Example: scheduled post with media”curl --fail-with-body --silent --show-error \ -X POST \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/collections/$COLLECTION_ID/posts" \ --data '{ "title": "Spring Collection Launch", "caption": "Our new Spring Collection is live. Link in bio to shop.", "alt": "Flat lay of skincare bottles on fresh herbs.", "publishMode": "SCHEDULE", "scheduledAt": "2026-10-10 12:12:00", "media": [{ "id": "encoded_media_id" }] }'const brandId = process.env.BRAND_ID;const collectionId = process.env.COLLECTION_ID;const apiKey = process.env.NUELINK_API_KEY;
const payload = { title: 'Spring Collection Launch', caption: 'Our new Spring Collection is live. Link in bio to shop.', alt: 'Flat lay of skincare bottles on fresh herbs.', publishMode: 'SCHEDULE', scheduledAt: '2026-10-10 12:12:00', media: [ { id: 'encoded_media_id', }, ],};
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/collections/${collectionId}/posts`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(payload),});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$collectionId = getenv('COLLECTION_ID');$apiKey = getenv('NUELINK_API_KEY');
$payload = [ 'title' => 'Spring Collection Launch', 'caption' => 'Our new Spring Collection is live. Link in bio to shop.', 'alt' => 'Flat lay of skincare bottles on fresh herbs.', 'publishMode' => 'SCHEDULE', 'scheduledAt' => '2026-10-10 12:12:00', 'media' => [ [ 'id' => 'encoded_media_id', ], ],];
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/collections/{$collectionId}/posts");
curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', 'Content-Type: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;A DRAFT version of the same call is in the Quick Start, and a multi-platform example is in Platform-Specific Options.
Response: 201 Created
Section titled “Response: 201 Created”{ "status": "success", "data": { "id": 4364709, "message": "Post created successfully" }}The id is the Nuelink post ID. The response doesn’t report delivery: read the post back with GET .../posts to confirm what was created, and once it has gone out, check each channel’s result with GET /published-posts filtered on this id. That endpoint returns only PUBLISHED entries by default, so ask for the other statuses too before you conclude every channel received the post.
Errors worth handling
Section titled “Errors worth handling”Missing caption with no poll — 422
{ "message": "The given data was invalid.", "errors": { "caption": ["The caption field is required when poll is not present."] }}scheduledAt less than 10 minutes away — 422
{ "status": "error", "message": "Scheduled post date must be at least 10 minutes in the future", "errors": { "scheduledAt": "Scheduled post date must be at least 10 minutes in the future" }}Before 1.3.0-alpha this case was documented as 400. Match it as a 422.
A media[].url Nuelink could not fetch — 400
{ "status": "error", "message": "Failed to download media from url: https://cdn.example.com/missing.jpg", "errors": { "media": "Failed to download media from url: https://cdn.example.com/missing.jpg" }}A media[].id that isn’t in this brand — 404
{ "status": "error", "message": "Media with id encoded_media_id not found", "errors": { "media": "Media with id encoded_media_id not found" }}Brand post limit reached — 403
{ "status": "error", "message": "This brand has reached the maximum total number of posts allowed (15,000).", "errors": { "limits": "This brand has reached the maximum total number of posts allowed (15,000)." }}A 403 also covers the scheduled-and-queued post limit on your plan, and a brand you’re not a member of (errors.brand_id: You are not a member of this brand).
The two 422 bodies above have different shapes: the missing caption maps the field to an array, the scheduling rule to a string. See Errors.
PATCH /brands/{brand_id}/posts/{post_id}
Section titled “PATCH /brands/{brand_id}/posts/{post_id}”Changes when, or whether, a post publishes: reschedule it, put it in its collection’s queue, move it to the front or back of that queue, or turn it into a draft. It works on drafts, queued posts, and scheduled posts. A post’s content — caption, media, options — can’t be edited through the API.
Body. Send at least one of publishMode or queuePosition.
| Field | Type | Notes |
|---|---|---|
publishMode | string | DRAFT turns the post into a draft. SCHEDULE reschedules it and needs scheduledAt. QUEUE puts it in its collection’s queue. IMMEDIATE isn’t accepted here. |
scheduledAt | string | Required with SCHEDULE, and not allowed without it. Brand-local Y-m-d H:i:s, at least 10 minutes in the future. |
queuePosition | string | FRONT or BACK. Moves a queued post to that end of its collection’s queue, on its own or together with publishMode: QUEUE. There’s no way to set an arbitrary position. |
curl --fail-with-body --silent --show-error \ -X PATCH \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/posts/$POST_ID" \ --data '{ "publishMode": "SCHEDULE", "scheduledAt": "2026-10-12 09:00:00" }'const brandId = process.env.BRAND_ID;const postId = process.env.POST_ID;const apiKey = process.env.NUELINK_API_KEY;
const payload = { publishMode: 'SCHEDULE', scheduledAt: '2026-10-12 09:00:00',};
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/posts/${postId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(payload),});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$postId = getenv('POST_ID');$apiKey = getenv('NUELINK_API_KEY');
$payload = [ 'publishMode' => 'SCHEDULE', 'scheduledAt' => '2026-10-12 09:00:00',];
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/posts/{$postId}");
curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', 'Content-Type: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;The other bodies you’ll send most:
| To | Send |
|---|---|
| Make a queued post the next one out | { "queuePosition": "FRONT" } |
| Take a post out of the schedule or queue | { "publishMode": "DRAFT" } |
| Queue a draft and publish it next | { "publishMode": "QUEUE", "queuePosition": "FRONT" } |
Returns 200 with the updated post, in the same shape the post lists return. The post rescheduled above, in a Europe/London brand, reads back with its postDate in UTC:
{ "status": "success", "data": { "id": 4364709, "collectionId": 2001, "title": "Spring Collection Launch", "body": "Our new Spring Collection is live.", "media": [{ "id": "encoded_media_id" }], "postType": "IMAGE", "postingType": "SCHEDULE", "status": "PENDING", "postDate": "2026-10-12 08:00:00", "createdAt": "2026-10-05T09:00:00.000000Z", "updatedAt": "2026-10-05T14:00:00.000000Z" }}Errors worth handling
409- the post is already published. Only draft, queued, or scheduled posts can be changed.422- the body broke a rule: neitherpublishModenorqueuePosition,scheduledAtmissing withSCHEDULEor sent without it, or a time less than 10 minutes away.403- you’re not a member of the brand, or a scheduled-post limit was reached.404- the post doesn’t exist in this brand.
{ "status": "error", "message": "Only draft, queued or scheduled posts can be edited", "errors": { "post_id": "Only draft, queued or scheduled posts can be edited" }}DELETE /brands/{brand_id}/posts/{post_id}
Section titled “DELETE /brands/{brand_id}/posts/{post_id}”Permanently deletes a post. It can’t be undone.
Before you delete, read the post back and confirm the brand and post ID. If you only need it not to go out, PATCH it to DRAFT instead: that’s reversible.
curl --fail-with-body --silent --show-error \ -X DELETE \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/posts/$POST_ID"const brandId = process.env.BRAND_ID;const postId = process.env.POST_ID;const apiKey = process.env.NUELINK_API_KEY;
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/posts/${postId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$postId = getenv('POST_ID');$apiKey = getenv('NUELINK_API_KEY');
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/posts/{$postId}");
curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;Returns 200:
{ "status": "success", "data": { "id": 4364709, "message": "Post deleted successfully" }}Errors worth handling
403- sensitive actions aren’t enabled for the account. The body names thepermissionskey, shown below.404- the post doesn’t exist in this brand.
{ "status": "error", "message": "Deleting posts through the API is disabled. Enable sensitive actions for AI in your API settings to allow it.", "errors": { "permissions": "Deleting posts through the API is disabled. Enable sensitive actions for AI in your API settings to allow it." }}Published results
Section titled “Published results”GET /brands/{brand_id}/published-posts
Section titled “GET /brands/{brand_id}/published-posts”What actually went out: one entry per channel, with the platform link and the engagement results. Use it to confirm delivery, find failures, and compare performance. Results are newest first, and include only PUBLISHED entries unless you ask for another status.
| Parameter | Values | Notes |
|---|---|---|
status | PUBLISHED (default), FAILED, RETRYING, SKIPPED, SENT | Delivery status, one per request. Entries in other statuses are left out |
collection_id | integer | |
channel_id | integer | |
post_id | integer | The Nuelink post the entries came from: the id that POST .../posts returned |
post_type | string | |
search | string, max 255 | Case-insensitive match on the published text |
published_from | Y-m-d H:i:s, UTC | Published at or after this time |
published_to | Y-m-d H:i:s, UTC | Published at or before this time. Can’t precede published_from |
sort_by | id (default), created_at, likes, comments, shares | |
sort_order | desc (default), asc |
Plus page and per_page.
curl --fail-with-body --silent --show-error --get \ -H "Authorization: Bearer $NUELINK_API_KEY" \ -H "Accept: application/json" \ --data-urlencode "sort_by=likes" \ --data-urlencode "published_from=2026-09-01 00:00:00" \ --data-urlencode "per_page=25" \ "https://app.nuelink.com/api/public/v1/brands/$BRAND_ID/published-posts"const brandId = process.env.BRAND_ID;const apiKey = process.env.NUELINK_API_KEY;
const params = new URLSearchParams({ sort_by: 'likes', published_from: '2026-09-01 00:00:00', per_page: '25',});
const response = await fetch(`https://app.nuelink.com/api/public/v1/brands/${brandId}/published-posts?${params}`, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json', },});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${await response.text()}`);}
const data = await response.json();console.log(data);<?php
$brandId = getenv('BRAND_ID');$apiKey = getenv('NUELINK_API_KEY');
$query = http_build_query([ 'sort_by' => 'likes', 'published_from' => '2026-09-01 00:00:00', 'per_page' => 25,]);
$ch = curl_init("https://app.nuelink.com/api/public/v1/brands/{$brandId}/published-posts?{$query}");
curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Accept: application/json', ], CURLOPT_RETURNTRANSFER => true,]);
$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status >= 400) { throw new RuntimeException("HTTP {$status}: {$response}");}
echo $response;{ "status": "success", "data": [ { "id": 120045, "postId": 4364709, "collectionId": 2001, "channel": { "id": 5001, "name": "Example Instagram Account", "type": "Instagram" }, "postType": "IMAGE", "body": "Our new Spring Collection is live.", "url": "https://www.instagram.com/p/EXAMPLE/", "status": "PUBLISHED", "message": null, "publishedAt": "2026-10-10T11:12:00.000000Z", "results": { "likes": 42, "comments": 5, "shares": 3 } } ], "pagination": { "currentPage": 1, "perPage": 25, "total": 1, "lastPage": 1, "nextPageUrl": null, "prevPageUrl": null }}| Field | Type | Description |
|---|---|---|
id | integer | This entry’s ID: one per post per channel. Not the post ID. |
postId | integer | null | The Nuelink post this was published from. |
collectionId | integer | null | That post’s collection. |
channel | object | The channel it went to: id, name, and type, any of which can be null. |
postType | string | null | Content type, e.g. IMAGE. |
body | string | null | The text as published. |
url | string | null | Link to the post on the platform. |
status | string | One of the status values above. |
message | string | null | A status message, when there is one. |
publishedAt | string | ISO 8601 timestamp. |
results | object | Engagement counts: likes, comments, and shares. |
Three questions it answers:
- Did my post go out everywhere? Filter on
post_id, but one call isn’t enough: with the defaultstatus, you only see the channels where the post was published. Repeat it withstatusset toFAILED,RETRYING,SKIPPED, andSENT, walking every page of each, or compare the published entries with the collection’schannelsto find the ones missing. - Did anything fail? Filter on
status=FAILEDto see which channels a post didn’t reach. - What performed best? Sort on
likes,comments, orshares, inside apublished_fromandpublished_towindow.