API & webhooks

Plug Nouvico into your CRM, onboarding flow or another product. Client tokens never leave Nouvico — your tools ask us what access exists instead of holding credentials themselves.

Authentication

Create a key in Settings → API (Agency plan) and send it as a bearer token. Keys start with ec_live_, are shown once, and can be revoked at any time. The limit is 600 requests per minute per key and integration, and 1,200 a minute per key across all of them: send an api-client header (api_client works too, but some proxies drop underscores; zapier, make, n8n or looker_studio; anything else counts as direct) and each gets its own 600 (the MCP endpoint counts per connected app the same way). Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (the minute's end, in epoch seconds); a 429 also has Retry-After (seconds). A key that goes past its limit in 4 different minutes within an hour gets its account owners and admins an email; in 11 different minutes within an hour the key is turned off and has to be replaced.

curl https://app.nouvico.com/api/v1/connections?status=granted \
  -H "Authorization: Bearer ec_live_..."

What a key may use

A key can be given everything (the default) or only some areas, each read or read and write: access:read (clients, connections, access links), social:read and social:write (profiles, posts, the media library, inbox and reviews), analytics:read and analytics:write (the analytics endpoints and signed CSV links), tasks:read and tasks:write (MCP task tools), support:read and support:write (Support tickets). A call outside the key's areas answers 403 insufficient_scope. A key can also be limited to some clients: other clients' ids answer 404, lists hold only its clients, agency-wide data (access links, agency-wide media) answers 403 or is left out, and the analytics endpoints need client_id unless the key has one client. A signed CSV link stops working if its key loses analytics:read or that client. Change a key's access in Settings → API keys; it applies from its next request.

Endpoints

All responses are JSON. Lists are paginated with limit (1–100, default 25) and starting_after (an id); a response has data, has_more and next_cursor.

GET /api/v1/customers

Filter with email or website (a domain, e.g. acme.com) to find the customer for a site — the natural join key for another product.

GET /api/v1/customers/:id

One customer with all of their connections.

GET /api/v1/connections

Filter with status (pending, granted, revoked, unknown, failed), platform (meta, google, tiktok, linkedin) and customer_id.

GET /api/v1/links

Your access links and their public URLs.

Social

GET /api/v1/social/profiles

Your clients' connected profiles. Filter with client_id or network (facebook, instagram, linkedin, …).

GET /api/v1/social/profiles/:id/places?q=

Places to tag (Instagram, Facebook, TikTok): each result's value goes in the profile's location option (igLocation, fbLocation or tkLocation). Likewise …/:id/playlists (YouTube, for ytPlaylist) and …/:id/products (an Instagram shop, ids for igProducts). Demo profiles only for now: a live profile answers 422.

GET /api/v1/social/library

Media library items (id, name, type, size, client). Filter with type (image, video, document) and client_id (that client's items plus the agency-wide ones). Use the ids in image_ids, ytThumbId and igCoverId.

GET /api/v1/social/posts

Filter with status (comma-separated: draft, pending_approval, scheduled, publishing, reminder_sent, published, partially_failed, failed, rejected), client_id, and from / to (the scheduled time, or the publish time of a post sent right away). reminder_sent means a teammate was reminded to publish it by hand; each target then shows publish_method (reminder) and reminder_state (sent, published or rejected). A failed target also has error_hint when we recognise the network's error: cause and fix in plain words and help_url (the help article); otherwise null, with the network's own text in error. Webhook payloads carry it too. A published target the network later took down has removed_on_network_at, removed_reason (community_guidelines, copyright, spam, deleted_by_owner or unknown) and removal_hint (cause, fix, help_url); all three are null otherwise.

GET /api/v1/social/posts/:id

One post with each profile's status, error and publish time.

POST /api/v1/social/posts

Send client_id, profile_ids and text; add scheduled_at (ISO 8601) to schedule it, or publish: draft, schedule or now. Optional: first_comment or comments (up to 5, posted in order right after the post; on X, Bluesky and Threads as replies; each within the network's comment length: X 280, Bluesky 300, Threads 500, TikTok 150, Instagram, Facebook and LinkedIn 2,200, YouTube 10,000), tags, image_ids (media library image ids, for a carousel), requires_approval and options: network options by profile id, e.g. {"prof_1": {"igType": "reel"}, "prof_2": {"ytTitle": "Spring menu", "ytPrivacy": "unlisted"}} (switches take true or false). shorten_links: true turns the post's links into your counted short links. publish_method: auto (default) or reminder (a teammate is reminded at the time and publishes by hand), profile_methods for a profile's own method (e.g. {"prof_2": "reminder"}), and reminder_user (a teammate's id or email, needed when any profile is reminded). The same rules as the composer apply (an Instagram post needs an image, each network's length limit, at most 25 posts a day per profile): a post that breaks one answers 422 with the reason. At most 60 posts a minute.

Media per post: Facebook 10, Instagram 10, LinkedIn 9, TikTok 10, Google Business Profile 1, X 4, Threads 20, Bluesky 4, Pinterest 1, YouTube 1, Vimeo 1, Reddit 20, Tumblr 10, Snapchat 1. Images and videos mix in one carousel only on Instagram and Threads.

media_urls: up to 10 image addresses (https, any public host) fetched on our server and attached after image_ids, in order: JPEG, PNG, WebP or GIF, at most 1.5 MB each, all within 25 seconds together. Private and local addresses are refused, every redirect is checked again, and a failed address answers 422 naming it (e.g. media_urls[2]: …), or 502 upstream_unavailable when the image host was slow or failing (retry it: a 502 isn't kept by an Idempotency-Key). These share the bulk upload limit of 100 images every 10 minutes per agency (then 429). Videos need image_ids (upload them to the library first). save_to_library: true also keeps the fetched images in the client's media library.

Idempotency-Key: send a unique key (up to 255 characters) to make retries safe. Repeating the request with the same key and body within 24 hours answers the first response (with Idempotent-Replayed: true) instead of making another post; the same key with a different body answers 422 idempotency_key_reused, and one sent while the first is still running answers 409. Without a key the endpoint isn't idempotent: don't blindly retry a 5xx. Refusals (4xx) are kept too: after fixing the request, send a new key. Only 5xx and 429 answers are forgotten.

Network options (by profile id in options)
NetworkMain optionAlso
InstagramigTypepost, reel or story; igNoComment (comments off), igLocation, igCoverId or igCoverMs (reel cover), igProducts (shop product tags); reels only: igAudio (a trending audio track id) or igSound (a media library sound's token), igAudioVol and igVideoVol (0 to 100, default 100)
FacebookfbTypepost, reel or story; fbLocation, firstLike; feed posts only: fbAsImage (a link post without images goes out as a photo of the link's preview image, link in the caption), feed targeting:, fbCountries (ISO codes, up to 25), fbAgeMin (13 to 65), fbGender (male or female)
LinkedInliVideoTitleliDocTitle; liCountries (ISO codes LinkedIn targeting offers, up to 10), liLanguage (en, el, de, fr, es, it, pt, nl); firstLike; page mentions in the text as @[Name](urn:li:organization:123)
ThreadsthReplywho can reply: accounts_you_follow or mentioned_only (absent: everyone); thCountries (ISO codes, up to 25), thTopic (one topic, up to 50 characters, no . or &; absent: the first #tag)
XxReplywho can reply: following, mentioned or verified (absent or "everyone": everyone); firstLike. An X Premium profile's own text limit (up to 25,000) is set on its profile page
BlueskybsLabelscontent labels: sexual, nudity, porn, graphic-media (comma-separated); bsLangs (up to 3 of en, el, de, fr, es, it, pt, nl, tr, ar, ru, ja); firstLike
SnapchatsnapTypespotlight or story; Spotlight only: snapDesc (up to 160 characters), snapSave (save to profile), snapLocale (e.g. en_GB, el_GR)
YouTubeytTitle, ytPrivacyytCategory, ytPlaylist, ytNoNotify (don't notify subscribers), ytNoEmbed, ytSynthetic (altered or synthetic content), ytThumbId (thumbnail), ytShort, ytKids, firstLike
TikToktkPrivacytkNoComment, tkNoDuet, tkNoStitch, tkLocation, tkCoverMs, tkBrand (promotional content) or tkPaid (paid partnership), tkAigc (AI-generated content label); photo posts only: tkAutoMusic (TikTok adds music); videos only: tkAudio (a trending audio track id) or tkSound (a media library sound's token), tkAudioVol and tkVideoVol (0 to 100, default 100)
RedditredditTitle, subredditredditFlairId (one of the subreddit's flairs; some subreddits require one), redditFlairText (where the flair can be edited, up to 64 characters), redditNsfw, redditSpoiler
PinterestpinTitle, pinLink, boardboardSection (a section of the board, up to 50 characters); bulk CSV boards column: Board/Section
Google BusinessgbpTypeOFFER, EVENT or ALERT (gbpAlert: covid for COVID-19, absent: standard); gbpCta, gbpUrl; gbpAsImage (What's new and Alert posts: a link post without images shows the link's preview image, and the button is dropped)
TumblrtmbTypetext, photo, video or link; tmbTitle (text posts), tmbLink (link posts), tmbTags (up to 30, comma-separated)

PATCH /api/v1/social/posts/:id

Changes a post that hasn't gone out yet (a draft, one waiting for approval, scheduled, or with changes requested). Send only the fields to change: text, scheduled_at, publish (draft or schedule), first_comment / comments, tags, image_ids (replace the media), requires_approval, options (a profile named there gets exactly those options; the others keep theirs), publish_method, profile_methods and reminder_user. The client and profiles stay (delete the post and make it again to change them); media_urls and shorten_links are for new posts. Every rule of a new post is checked again (422), a change starts approval over, and the post's History shows it as changed by "API: <key name>". A post that is publishing or has gone out, or that changed while you sent this, answers 409. Scheduling (or keeping scheduled) drops profiles on networks turned off for the client, and a reminder profile whose teammate can no longer publish for the client becomes automatic unless you send reminder_user; the answer then carries warnings, e.g. {"code": "reminder_to_auto", "profile_id": "prof_2", "reason": "teammate_lost_access"}.

DELETE /api/v1/social/posts/:id

Deletes a post that hasn't gone out yet; one that is being published or has gone out answers 409.

GET /api/v1/social/inbox

Comments, messages, mentions, story replies, shares and collab invites, newest first (spam and button taps are left out; the team's notes are never included). Filters: client_id, profile_id, status (open or done; both when left out), kind and since. Each item has id, client_id, profile_id, network, kind, author_name, author_handle, text, post_text, thread_key, received_at, created_at, status, tags, starred, sentiment and is_ad.

GET /api/v1/social/reviews

Reviews and recommendations, newest first. Filters: client_id, profile_id, replied (true or false), rating (1 to 5) and since. Each review has id, client_id, profile_id, network, author_name, rating, recommends (Facebook), text, received_at, created_at, replied, reply_text and replied_at.

Both are ordered by created_at, when Nouvico stored the item, which never changes; received_at is the network's own time and can be earlier for something a network delivered late. since (ISO 8601, at most a year back) returns items stored after that time, and paging works as on the other lists (limit up to 100, starting_after with the previous page's next_cursor).

curl https://app.nouvico.com/api/v1/social/posts   -H "Authorization: Bearer ec_live_..." -H "Content-Type: application/json"   -d '{"client_id":"cmt0...","profile_ids":["cmt1..."],"text":"Fresh bread at 7","scheduled_at":"2026-10-05T07:00:00Z"}'
{
  "id": "cmt0...",
  "customer_id": "cmt0...",
  "platform": "google",
  "asset_type": "google_analytics",
  "asset_id": "properties/300100",
  "asset_name": "riverabakery.com — GA4",
  "access_level": "manage",
  "status": "granted",
  "granted_at": "2026-09-19T12:00:00.000Z",
  "revoked_at": null,
  "last_checked_at": "2026-09-19T12:00:00.000Z",
  "demo": false
}

Errors look like {"error":{"code":"invalid_key","message":"…"}} with a matching HTTP status.

Support tickets

Tickets on your Support desks, with a key that has support:read (list, read) or support:write (open, reply, note, assign, change status). A key limited to some clients sees only tickets about them, never your internal ones; another agency's ticket answers 404 like one that doesn't exist. What a key writes shows as “Automatic”: requesters never see a key's name.

GET /api/v1/support/tickets

Newest first by when they were opened. Filter with status (open, pending, on_hold, solved, closed), client_id, desk (agency or a client's id) and assignee_id (a member's id or none).

POST /api/v1/support/tickets

Opens a ticket: subject and body, and optionally desk, client_id, internal, priority, type, tags, requester (email, name, contact_id) and assignee_id. A requester with an address gets your “we got your request” email. Answers 201.

GET /api/v1/support/tickets/:id

The ticket, its public thread (messages) and the team's notes (notes), oldest first, with file names and sizes.

PATCH /api/v1/support/tickets/:id

status and/or assignee_id (null unassigns). Solving it emails the requester, as the team's Mark as solved does.

POST /api/v1/support/tickets/:id/replies

The team's public reply (body, optional status such as pending). Emailed to the requester. Answers 201 with the message's id.

POST /api/v1/support/tickets/:id/notes

A team-only note (body): never sent, never in a portal. Answers 201 with the note's id.

Errors: 400 invalid_request (with the reason), 403 insufficient_scope, 404 not_found, 409 conflict (the ticket changed meanwhile, or is closed). Not in this version: uploading files, links to a website or project, priority and tags after opening, idempotency keys.

curl https://app.nouvico.com/api/v1/support/tickets/cmt0.../replies \
  -H "Authorization: Bearer ec_live_..." -H "Content-Type: application/json" \
  -d '{"body":"The form works again: we renewed the mail key.","status":"pending"}'

Five Support webhook events (support.ticket_created, support.ticket_replied, support.reply_received, support.ticket_status_changed, support.ticket_assigned) carry ids and a small snapshot, never a subject, a message or an address. On the MCP server a key with the Support scope, or an app you connected with the Support boxes ticked, gets list_tickets, get_ticket, create_ticket, reply_to_ticket, add_ticket_note and update_ticket.

Zapier & Make

Both connect with a webhook step (HTTP request) and an API key: create the key in Settings, API, and send it as Authorization: Bearer ec_….

  • Create posts from a sheet or a form: POST to /api/v1/social/posts with the row's text, and map an image link column to media_urls (a list, so several columns make a carousel). Send the row's id as the Idempotency-Key so a retried step never posts twice.
  • Find the ids: GET /api/v1/social/profiles lists clients' profiles; GET /api/v1/social/library lists media you can use as image_ids.
  • Start a scenario when something happens: add a webhook (below) for published or failed posts, new inbox items or new reviews, and point it at the Zapier or Make "catch hook" address. It runs at once.
  • Or poll for new items: a polling trigger can call GET /api/v1/social/inbox or GET /api/v1/social/reviews every few minutes. Results come newest first and Zapier and Make skip ids they've already seen; to read only what's new, keep the newest created_at you got and send it as since(strictly after) next time, a few minutes earlier to be safe, and use the item's id as the deduplication key. Ask for limit=100, and while has_more is true read the next page with starting_after before moving since on, or a busy spell can be skipped.

Social analytics (Looker Studio, Google Sheets)

Read-only numbers for dashboards, on the same API keys. Each takes client_id (optional; every client without it) and a period: days (1–366, a rolling window ending now; 30 by default) or from and to (dates such as 2026-09-01, both days included, at most 366 days). Add format=csv for CSV instead of JSON. A wrong parameter answers 400 and names it; another agency's client answers 404.

  • GET /api/v1/social/analytics/profiles

    One row per profile per day (UTC): posts, impressions, engagements, clicks and engagement rate, plus totals, and the profile's daily followers, reach and profile_views (null when not read), then post_reach (the posts' own reach added up) and video_views (on an imported day, the import's). A day comes when posts were published, or from history imported from another tool (a day without published posts here gets a row with the import's numbers). For followers on every day, read /audience.

  • GET /api/v1/social/analytics/audience

    One row per profile per day (UTC) of the profile's own numbers, every day there is a reading (synced or imported): followers that day, followers_gained, followers_lost, reach and profile_views. A number the network doesn't report is empty (null). Removed profiles are left out, as in /profiles.

  • GET /api/v1/social/analytics/posts

    One row per published post per profile, with its numbers (impressions, engagements, clicks, and where the network reports them reach, video views, likes, comments, shares and saves), tags and when they were last updated. Takes network; JSON pages with limit and starting_after, CSV holds up to 10,000 rows.

  • GET /api/v1/social/analytics/inbox

    Messages received per day, network and kind, replies sent, completed, unanswered and the average first response time. Counts only: never the messages themselves. Spam and collab invites are left out, as in the reports.

CSV cells a spreadsheet would run as a formula (starting with =, +, - or @) get a leading apostrophe. New columns are added at the end, so a sheet that reads columns by position keeps working.

Signed CSV links

Google Sheets' IMPORTDATA and Looker Studio's CSV sources can't send an Authorization header, so make a signed link for one query instead of putting your key in a URL:

curl -X POST https://<your app address>/api/v1/social/analytics/links \
  -H "Authorization: Bearer ec_live_…" -H "Content-Type: application/json" \
  -d '{"endpoint":"profiles","client_id":"cus_…","days":30,"name":"Monthly dashboard","expires_in_days":90}'

The answer's url (shown once) returns that query's CSV with no header needed. The link only reads that one query, is limited to 60 requests a minute, and stops working when it expires (at most 90 days), when you revoke it (DELETE /api/v1/social/analytics/links/:id) or when the API key that made it is revoked. GET /api/v1/social/analytics/links lists your links (never their tokens). Treat the URL like a password.

Looker Studio connector

The Nouvico Social connector reads these endpoints with your API key, so charts stay current without a sheet in between. It only reads: it never posts, changes or deletes anything. It sends api-client: looker_studio, caches each answer for 10 minutes, and asks for long date ranges in parts.

  1. Create an API key in Settings → API keys and keep it for Looker Studio only (a key reads everything in your agency; revoke it when you stop).
  2. Open the connector (its link appears in Settings → API keys once it is published), choose Authorize and paste the key.
  3. Choose the data: Profiles by day (followers, followers gained and lost, reach, profile views, posts, impressions, engagements, clicks, video views, post reach, engagement rate), Published posts (one row per post per profile) or Inbox (messages received per day, network and kind). Then a client, or all, and if you like some of its profiles.
  4. The report's date range sets the period; days are UTC days. Followers shows the highest value by default: chart it by date, and with several profiles add Profile or Network as a dimension.

The connector runs on Google's Apps Script and its answers are shown in Looker Studio, under Google's terms. See our privacy policy and terms.

Looker Studio through Google Sheets

  1. Create an API key in Settings → API keys, then a signed link as above (one per client and endpoint you want to chart).
  2. In Google Sheets, put =IMPORTDATA("<the link>") in cell A1 of a new sheet. Sheets refreshes it about every hour.
  3. In Looker Studio, add a data source → Google Sheets, pick that sheet, and build the report from its columns.
  4. Use days rather than fixed dates, so the link keeps showing the latest period. Make a new link before it expires.

MCP server (AI assistants)

Connect an AI assistant (Claude, ChatGPT, Copilot Studio, Cursor…) to Social over the Model Context Protocol. The server speaks Streamable HTTP at POST /api/mcp with JSON responses, and uses the same API keys, rate limit and plan as the REST API: send Authorization: Bearer ec_live_….

{
  "mcpServers": {
    "social": {
      "type": "http",
      "url": "https://<your app address>/api/mcp",
      "headers": { "Authorization": "Bearer ec_live_…" }
    }
  }
}

Tools: list_clients, list_profiles, list_posts, get_post, create_post, update_post, delete_post, list_inbox_items, reply_to_inbox_item, list_reviews, get_performance_summary and list_best_times. create_post takes the same fields as POST /api/v1/social/posts, and update_post (with post_id) the same fields as PATCH /api/v1/social/posts/:id. Tools that publish, reply or delete are marked as such, so assistants ask before using them; inbox messages are written by the public and are labelled as content, never instructions.

Also: queues (get_queue, set_queue_slots to replace a profile's weekly slots, copy_queue to profiles of the same client, clear_queue), create_calendar_note, ideas (create_idea, update_idea), the media library (add_media_from_url for a public https image, find_media by name, tags and format; their ids go in image_ids), Work tasks (list_my_tasks for an app connected as a member, list_tasks, create_task, comment_on_task), create_calendar_share (a client's read-only calendar link, with the same options as the page) and list_team_members. Each runs the same checks as its page.

Or connect without a key: apps that support MCP authorization (OAuth 2.1 with PKCE) only need the URL. They register themselves, you sign in and press Allow, and the app then acts as you, with your own Social permissions and clients, within the scopes it asked for and you left ticked (social:read, social:write, analytics:read, tasks:read, tasks:write, support:read, support:write; none asked means all, and Support's are offered only to members with Support). With an API key, the tools are those the key's scopes allow, for its clients. Discovery lives at /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server; see and revoke connections in Settings, Connected apps. OAuth sign-in works for the MCP server only; the REST endpoints above take API keys. The create_post tool takes an optional idempotency_key, like the REST header, and so do create_task, comment_on_task, add_media_from_url, create_idea, create_calendar_note and create_calendar_share (only a successful result is remembered, so a refused call can be retried with the same key).

Webhooks

Add an endpoint in Settings → Webhooks (Pro plan). We POST JSON to it when something happens:

  • access.granted
  • access.revoked
  • access.status_changed
  • request.completed
  • feedback.thread_created
  • feedback.comment_created
  • feedback.status_changed
  • feedback.page_approved
  • feedback.signed_off
  • social.post_published
  • social.post_failed
  • social.post_removed
  • social.post_approved
  • social.post_rejected
  • social.inbox_item_received
  • social.review_received
  • social.automation_data_collected
  • support.ticket_created
  • support.ticket_replied
  • support.reply_received
  • support.ticket_status_changed
  • support.ticket_assigned
{
  "id": "evt_abc123",
  "type": "access.granted",
  "created_at": "2026-09-19T12:00:00.000Z",
  "data": {
    "connection": { "id": "…", "platform": "meta", "asset_type": "meta_ad_account",
                    "asset_id": "act_100200300", "access_level": "manage", "status": "granted" },
    "customer":   { "id": "…", "name": "Rivera Bakery", "email": "alex@riverabakery.com", "website": "riverabakery.com" }
  }
}

Social events (social.*) carry ids, statuses and times only, never post text, message bodies or media addresses: fetch the post with GET /api/v1/social/posts/:id when you need more. A post that went out to some profiles but not others sends both social.post_published and social.post_failed. social.post_removed is sent once when we learn a network took a published post down; its removed_profile_id names the profile, whose target carries removed_reason and removal_hint.

{
  "id": "evt_abc123",
  "type": "social.post_published",
  "created_at": "2026-10-05T07:00:04.000Z",
  "data": {
    "post": { "id": "…", "client_id": "…", "status": "published", "scheduled_at": "2026-10-05T07:00:00.000Z",
              "published_at": "2026-10-05T07:00:04.000Z",
              "targets": [{ "profile_id": "…", "network": "facebook", "status": "published", "error": null,
                            "published_at": "2026-10-05T07:00:04.000Z", "external_post_id": "…" }] }
  }
}

Verify the signature

Every delivery carries Webhook-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t + "." + rawBody keyed with your endpoint's signing secret. Reject anything older than five minutes to stop replays.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1, "hex");
  const want = Buffer.from(expected, "hex");
  return given.length === want.length && timingSafeEqual(given, want);
}

Answer with any 2xx within 10 seconds. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked failed — you can see every attempt in Settings → Webhooks.